Compare commits

...

52 Commits

Author SHA1 Message Date
Srikanth Chekuri
aea42ba5ca Merge branch 'main' into enhancement/cmd-click-stack 2025-10-30 00:33:25 +05:30
Shaheer Kochai
01e0b36d62 fix: overall improvements to span logs drawer empty state (i.e. trace logs empty state vs. span logs empty state + UI improvements) (#9252)
* chore: remove the applied filters in related signals drawer

* chore: make the span logs highlight color more prominent

* fix: add label to open trace logs in logs explorer button

* feat: improve the span logs empty state i.e. add support for no logs for trace_id

* refactor: refactor the span logs content and make it readable

* test: add tests for span logs

* chore: improve tests

* refactor: simplify condition

* chore: remove redundant test

* fix: make trace_id logs request only if drawer is open

* chore: fix failing tests + overall improvements

* Update frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: fix the failing test

* fix: fix the light mode styles for empty logs component

* chore: update the empty state copy

* chore: fix the failing tests by updating the assertions with correct empty state copy

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-10-29 16:20:52 +00:00
Ekansh Gupta
e90bb016f7 feat: add span percentile for traces (#8955)
* feat: add span percentile for traces

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: added span percentile

* feat: added span percentile

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: removed comments

* feat: moved everything to module

* feat: refactored span percentile

* feat: refactored span percentile

* feat: refactored module package

* feat: fixed tests for span percentile

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: added better error handling

* feat: added better error handling

* feat: addressed pr comments

* feat: addressed pr comments

* feat: renamed translator.go

* feat: added query settings

* feat: added full query test

* feat: added fingerprinting

* feat: refactored tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: changed errors

* feat: removed redundant tests

* feat: removed redundant tests

* feat: moved everything to trace aggregation and updated tests

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments for float64

* feat: cleaned up code

* feat: cleaned up code
2025-10-29 21:35:59 +05:30
Amlan Kumar Nandy
bdecbfb7f5 chore: add missing unit tests for getLegend (#9374) 2025-10-29 16:27:20 +05:30
Nageshbansal
3dced2b082 chore(costmeter): enable costmeter by default in docker installations (#9432)
* chore(costmeter): enable costmeter by default in docker installations

* chore(costmeter): enable costmeter by default in docker installations
2025-10-29 15:24:54 +05:30
primus-bot[bot]
1285666087 chore(release): bump to v0.99.0 (#9431)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-10-29 11:49:48 +05:30
Yunus M
1655397eaa feat: allowing switching between views when groupby is present (#9386)
* feat: allowing switching between views when groupby is present

* feat: allowing switching between views when groupby is present

* chore: remove console log
2025-10-29 05:21:10 +00:00
Shaheer Kochai
718360a966 feat: enhance s3 logs retention handling (#9371)
* feat(s3-retention): enhance S3 logs retention handling

* chore: overall improvements

* test: add tests for GeneralSettings S3 logs retention functionality

* test: improve S3 logs retention dropdown interaction and validation

* refactor: change s3 and logs response / payload keys

* chore: update the teststo adjust based on the recent payload keys changes

* chore: update the test mock value

* chore: update tests

* chore: skip the flaky test

* fix: fix the condition that would cause infinite loop and the test would fail as a result

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-10-28 17:09:55 +00:00
Ekansh Gupta
2f5995b071 feat: changed cold storage duration to seconds in v1 (#9405)
* feat: changed cold storage duration to seconds in v1

* feat: changed cold storage duration to seconds in v1

* feat: renamed json payload

* fix: response and integration tests

---------

Co-authored-by: nityanandagohain <nityanandagohain@gmail.com>
2025-10-28 16:57:43 +00:00
Aditya Singh
a061c9de0f feat: double encode view query (#9429)
* feat: double encode view query

* feat: update test cases
2025-10-28 16:33:53 +00:00
Aditya Singh
7b1ca9a1a6 Fix: Escape HTML rendering in log body (#9413)
* feat: logs html rendering fix

* feat: remove support for \n and \t in table explorer view
2025-10-28 04:29:52 +00:00
Amlan Kumar Nandy
0d1131e99f chore: add data test ids for alerts e2e tests (#9384) 2025-10-27 17:55:06 +00:00
Shaheer Kochai
44d1d0f994 feat(logs-context): implement priority-based resource attribute selection (#9303)
* feat(LogsExplorerContext): implement priority-based resource attribute selection

* chore: write tests for useInitialQuery custom hook

* fix: prevent duplicate context filters + revert the existing regex

* chore: improve the test

* chore: overall improvements

* refactor: make getFallbackItems single responsibility

* refactor: move util functions to util.ts

* refactor: simplify the findFirstPriorityItem util

* chore: improve assertions in useInitialQuery tests

* refactor: handle deduplication at the end

* chore: add comments to clarify the priority categories and prioritization strategy
2025-10-27 13:52:39 +00:00
Pranjul Kalsi
bdce97a727 fix: replace fmt.Errorf with signoz/pkg/errors and update golangci-li… (#9373)
This PR fulfills the requirements of #9069 by:

- Adding a golangci-lint directive (forbidigo) to disallow all fmt.Errorf usages.
- Replacing existing fmt.Errorf instances with structured errors from github.com/SigNoz/signoz/pkg/errors for consistent error classification and lint compliance.
- Verified lint and build integrity.
2025-10-27 16:30:18 +05:30
Shaheer Kochai
5f8cfbe474 feat(quick-filters): improve filter visibility and auto-open behavior (#9253)
* feat(quick-filters): improve filter visibility and auto-open behavior

- Prioritize checked filter values to top of list
- Add visual separator and count indicator when collapsed
- Auto-open filters when they contain active query filters

* chore: remove the unnecessary parentheses

* chore: write tests

* chore: overall improvements

* chore: remove the applied filters count from quick filters

* chore: run prettier on Checkbox.styles.scss

* test(quick-filters): consolidate the tests

* chore: memoize isSomeFilterPresentForCurrentAttribute
2025-10-26 17:24:31 +00:00
SagarRajput-7
55c2f98768 fix: removed option param cleanup from variable function (#9411) 2025-10-26 15:02:56 +05:30
Amlan Kumar Nandy
624bb5cc62 chore: enable editing of unit from metric details (#8839) 2025-10-25 16:33:48 +05:30
SagarRajput-7
95f8fa1566 fix: fix drag select not working in panel edit mode (#9130) 2025-10-25 10:46:22 +00:00
SagarRajput-7
fa97e63912 fix: added test cases for exportoption wrapper and export function (#9321) 2025-10-25 10:33:59 +00:00
SagarRajput-7
c8419c1f82 fix: changed metric time and space type reset and change logic (#9066) 2025-10-25 15:51:45 +05:30
SagarRajput-7
e05ede3978 fix: fix threshold validation mismatch (#9196) 2025-10-25 09:57:56 +00:00
SagarRajput-7
437d0d1345 feat: added variable in url and made dashboard sync around that and sharable with user friendly format (#8874) 2025-10-25 15:16:07 +05:30
Nageshbansal
64e379c413 chore(statsreporter): adds statscollector for config (#9407)
* chore(statsreporter): adds statscollector for config

* chore(statsreporter): resolves review comments
2025-10-24 19:28:19 +05:30
SagarRajput-7
d05d394f57 chore: update slow running test in tracesExplorer test (#9396) 2025-10-23 11:02:02 +05:30
Vikrant Gupta
b4e5085a5a fix(sqlschema): postgres sqlschema get table operation (#9395)
* fix(sqlschema): postgres sqlschema get table operation

* fix(sqlschema): postgres sqlschema get table operation
2025-10-22 19:02:15 +05:30
Abhi kumar
88f7502a15 fix: prevent memory leaks from uncleaned uPlot event listeners (#9320) 2025-10-22 07:19:11 +00:00
primus-bot[bot]
b0442761ac chore(release): bump to v0.98.0 (#9393)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-10-22 12:09:31 +05:30
Manika Malhotra
455ba0549f Merge branch 'main' into enhancement/cmd-click-stack 2025-10-22 11:32:29 +05:30
Vikrant Gupta
d539ca9bab feat(sql): swap mattn/sqlite with modernc.org/sqlite (#9343)
* feat(sql): swap mattn/sqlite with modernc.org/sqlite (#9325)

* feat(sql): swap mattn/sqlite with modernc.org/sqlite

* feat(sql): revert the dashboard testing changes

* feat(sql): enable WAL mode for sqlite

* feat(sql): revert enable WAL mode for sqlite

* feat(sql): use sensible defaults for busy_timeout

* feat(sql): add ldflags

* feat(sql): enable WAL mode for sqlite

* feat(sql): some fixes

* feat(sql): some fixes

* feat(sql): fix yarn lock and config defaults

* feat(sql): update the defaults in example.conf

* feat(sql): remove wal mode from integration tests
2025-10-21 18:45:48 +05:30
Vikrant Gupta
c8194e9abb fix(tokenizer): update the authn domains tooltips (#9388) 2025-10-21 11:25:44 +00:00
manika-signoz
5f2c302551 chore: extract isEventObject utility to separate file 2025-10-13 23:56:49 +05:30
manika-signoz
15c2dc700a chore: use requireActual 2025-10-13 23:41:21 +05:30
manika-signoz
02fa0dbc32 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 23:25:35 +05:30
manika-signoz
e0948033c8 chore: re-export isEventObject utility from mocks 2025-10-13 23:24:28 +05:30
manika-signoz
a1115ac65b Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 10:30:04 +05:30
manika-signoz
9bcb88c747 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 17:03:12 +05:30
manika-signoz
367bf7b4b5 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 00:57:31 +05:30
manika-signoz
59b68057b8 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-08 15:38:11 +05:30
manika-signoz
fa1b2ddf7c Merge branch 'main' into enhancement/cmd-click-stack 2025-10-07 23:07:15 +05:30
manika-signoz
642a0e5656 fix: dashboardandalertspopover.test to use usesafenav mock 2025-09-30 19:31:34 +05:30
manika-signoz
cb99ee1ac1 fix: failing test cases due to isEventObject, add to mock 2025-09-30 19:16:54 +05:30
manika-signoz
7616cb89e4 Merge branch 'enhancement/cmd-click-stack' of github.com:SigNoz/signoz into enhancement/cmd-click-stack 2025-09-30 17:41:07 +05:30
manika-signoz
bf780c7445 chore: resolve comments, improve type safety in usesafenav 2025-09-30 17:40:17 +05:30
manika-signoz
61062dfd8d Merge branch 'main' into enhancement/cmd-click-stack 2025-09-30 17:08:38 +05:30
manika-signoz
5b7af9651c Merge branch 'main' into enhancement/cmd-click-stack 2025-09-29 10:40:22 +05:30
manika-signoz
b9012f6150 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 13:12:36 +05:30
manika-signoz
7ab81780b3 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 10:06:51 +05:30
manika-signoz
a16f51457f Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:32:51 +05:30
manika-signoz
38a38b5645 test: add tests for history.push 2025-09-23 16:30:16 +05:30
manika-signoz
bb04bc5044 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:16:16 +05:30
manika-signoz
58736f40dc feat: add support for location object in history.push override 2025-09-22 19:05:42 +05:30
manika-signoz
91154249d6 feat: add history.push and safeNavigate method overrides 2025-09-22 18:55:52 +05:30
196 changed files with 6716 additions and 1071 deletions

View File

@@ -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

View File

@@ -84,7 +84,7 @@ jobs:
JS_INPUT_ARTIFACT_CACHE_KEY: enterprise-dotenv-${{ github.sha }}
JS_INPUT_ARTIFACT_PATH: frontend/.env
JS_OUTPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
JS_OUTPUT_ARTIFACT_PATH: frontend/build
JS_OUTPUT_ARTIFACT_PATH: frontend/build
DOCKER_BUILD: false
DOCKER_MANIFEST: false
go-build:
@@ -99,7 +99,7 @@ jobs:
GO_BUILD_CONTEXT: ./cmd/enterprise
GO_BUILD_FLAGS: >-
-tags timetzdata
-ldflags='-linkmode external -extldflags \"-static\" -s -w
-ldflags='-s -w
-X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }}
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
@@ -110,7 +110,6 @@ jobs:
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true

View File

@@ -98,7 +98,7 @@ jobs:
GO_BUILD_CONTEXT: ./cmd/enterprise
GO_BUILD_FLAGS: >-
-tags timetzdata
-ldflags='-linkmode external -extldflags \"-static\" -s -w
-ldflags='-s -w
-X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }}
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
@@ -109,7 +109,6 @@ jobs:
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true
@@ -125,4 +124,4 @@ jobs:
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: "{\"deployment\": \"${{ needs.prepare.outputs.deployment }}\", \"signoz_version\": \"${{ needs.prepare.outputs.version }}\"}"
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'

1
.gitignore vendored
View File

@@ -106,6 +106,7 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
lib64/
parts/
sdist/

View File

@@ -32,6 +32,10 @@ linters-settings:
iface:
enable:
- identical
forbidigo:
forbid:
- fmt.Errorf
- ^(fmt\.Print.*|print|println)$
issues:
exclude-dirs:
- "pkg/query-service"

View File

@@ -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
##############################################################

View File

@@ -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

View File

@@ -12,12 +12,6 @@ builds:
- id: signoz
binary: bin/signoz
main: ./cmd/enterprise
env:
- CGO_ENABLED=1
- >-
{{- if eq .Os "linux" }}
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }}
{{- end }}
goos:
- linux
- darwin
@@ -40,8 +34,6 @@ builds:
- -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
- -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
- >-
{{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }}
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- timetzdata

View File

@@ -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.

View File

@@ -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:

View File

@@ -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:

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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"))
}
})

View File

@@ -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

View File

@@ -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"))
}
})

View File

@@ -1,4 +1,6 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
export { isEventObject } from '../src/utils/isEventObject';
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;

View File

@@ -9,6 +9,7 @@ export interface UpdateMetricMetadataProps {
metricType: MetricType;
temporality?: Temporality;
isMonotonic?: boolean;
unit?: string;
}
export interface UpdateMetricMetadataResponse {

View File

@@ -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,
});

View File

@@ -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)`

View File

@@ -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}

View File

@@ -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,

View File

@@ -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,

View File

@@ -2,7 +2,8 @@ import './MetricsSelect.styles.scss';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { memo } from 'react';
import { memo, useCallback, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export const MetricsSelect = memo(function MetricsSelect({
@@ -16,19 +17,28 @@ export const MetricsSelect = memo(function MetricsSelect({
version: string;
signalSource: 'meter' | '';
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
return (
<div className="metrics-select-container">
<AggregatorFilter
onChange={handleChangeAggregatorAttribute}
onChange={handleAggregatorAttributeChange}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

View File

@@ -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>

View File

@@ -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);
}
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -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 ? (

View File

@@ -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;

View File

@@ -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}>

View File

@@ -4,6 +4,7 @@ export interface YAxisUnitSelectorProps {
placeholder?: string;
loading?: boolean;
disabled?: boolean;
'data-testid'?: string;
}
export enum UniversalYAxisUnit {

View File

@@ -50,4 +50,5 @@ export enum QueryParams {
tab = 'tab',
thresholds = 'thresholds',
selectedExplorerView = 'selectedExplorerView',
variables = 'variables',
}

View File

@@ -86,6 +86,7 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',

View File

@@ -183,6 +183,7 @@ function AlertThreshold({
}}
style={{ width: 80 }}
options={queryNames}
data-testid="alert-threshold-query-select"
/>
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
@@ -195,6 +196,7 @@ function AlertThreshold({
}}
style={{ width: 180 }}
options={THRESHOLD_OPERATOR_OPTIONS}
data-testid="alert-threshold-operator-select"
/>
<Typography.Text className="sentence-text">
the threshold(s)
@@ -209,6 +211,7 @@ function AlertThreshold({
}}
style={{ width: 180 }}
options={matchTypeOptionsWithTooltips}
data-testid="alert-threshold-match-type-select"
/>
<Typography.Text className="sentence-text">
during the <EvaluationSettings />
@@ -236,6 +239,7 @@ function AlertThreshold({
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
data-testid="add-threshold-button"
>
Add Threshold
</Button>

View File

@@ -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>
)}

View File

@@ -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',

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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

View File

@@ -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 */}

View File

@@ -30,7 +30,7 @@ function EvaluationSettings(): JSX.Element {
trigger="click"
showArrow={false}
>
<Button>
<Button data-testid="evaluation-settings-button">
<div className="evaluate-alert-conditions-button-left">
{getTimeframeText(evaluationWindow)}
</div>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -12,6 +12,7 @@ export interface IAdvancedOptionItemProps {
tooltipText?: string;
onToggle?: () => void;
defaultShowInput: boolean;
'data-testid'?: string;
}
export enum RollingWindowTimeframes {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -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}
/>
))}

View File

@@ -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;

View File

@@ -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');
});
});
});

View File

@@ -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 = (

View File

@@ -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);
});
});
});

View File

@@ -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[]

View File

@@ -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>
),
});
}

View File

@@ -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) {

View File

@@ -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);
});
});
});

View File

@@ -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: {

View File

@@ -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(),
),
};
};

View File

@@ -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(() => {

View File

@@ -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;
}
}
}

View File

@@ -23,11 +23,15 @@ const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useSafeNavigate', () => {
const actual = jest.requireActual('hooks/useSafeNavigate');
return {
...actual,
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
};
});
const mockSetQuery = jest.fn();
const mockUrlQuery = {

View File

@@ -2,11 +2,76 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
UniversalYAxisUnit,
YAxisUnitSelectorProps,
} from 'components/YAxisUnitSelector/types';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useNotificationsHooks from 'hooks/useNotifications';
import { SelectOption } from 'types/common/select';
import Metadata from '../Metadata';
// Mock antd select for testing
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Select: ({
children,
onChange,
value,
'data-testid': dataTestId,
options,
}: {
children: React.ReactNode;
onChange: (value: string) => void;
value: string;
'data-testid': string;
options: SelectOption<string, string>[];
}): JSX.Element => (
<select
data-testid={dataTestId}
value={value}
onChange={(e): void => onChange?.(e.target.value)}
>
{options?.map((option: SelectOption<string, string>) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{children}
</select>
),
}));
jest.mock(
'components/YAxisUnitSelector',
() =>
function MockYAxisUnitSelector({
onChange,
value,
'data-testid': dataTestId,
}: YAxisUnitSelectorProps): JSX.Element {
return (
<select
data-testid={dataTestId}
value={value}
onChange={(e): void => onChange?.(e.target.value as UniversalYAxisUnit)}
>
<option value="">Please select a unit</option>
<option value="By">Bytes (B)</option>
<option value="s">Seconds (s)</option>
<option value="ms">Milliseconds (ms)</option>
</select>
);
},
);
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): { invalidateQueries: () => void } => ({
invalidateQueries: jest.fn(),
}),
}));
const mockUseUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
@@ -75,7 +140,10 @@ describe('Metadata', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
metadata={{
...mockMetricMetadata,
unit: '',
}}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
@@ -90,6 +158,24 @@ describe('Metadata', () => {
target: { value: 'Updated description' },
});
const metricTypeSelect = screen.getByTestId('metric-type-select');
expect(metricTypeSelect).toBeInTheDocument();
fireEvent.change(metricTypeSelect, {
target: { value: MetricType.SUM },
});
const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument();
fireEvent.change(temporalitySelect, {
target: { value: Temporality.CUMULATIVE },
});
const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument();
fireEvent.change(unitSelect, {
target: { value: 'By' },
});
const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument();
fireEvent.click(saveButton);
@@ -99,6 +185,10 @@ describe('Metadata', () => {
metricName: mockMetricName,
payload: expect.objectContaining({
description: 'Updated description',
metricType: MetricType.SUM,
temporality: Temporality.CUMULATIVE,
unit: 'By',
isMonotonic: true,
}),
}),
expect.objectContaining({
@@ -219,4 +309,21 @@ describe('Metadata', () => {
const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument();
});
it('should not allow editing of unit if it is already set', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const unitSelect = screen.queryByTestId('unit-select');
expect(unitSelect).not.toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import ROUTES from 'constants/routes';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
@@ -80,6 +81,12 @@ jest.mock('hooks/useSafeNavigate', () => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): { invalidateQueries: () => void } => ({
invalidateQueries: jest.fn(),
}),
}));
describe('MetricDetails', () => {
it('renders metric details correctly', () => {
@@ -95,7 +102,9 @@ describe('MetricDetails', () => {
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
expect(screen.getByText(`${mockMetricData.unit}`)).toBeInTheDocument();
expect(
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
).toBeInTheDocument();
});
it('renders the "open in explorer" and "inspect" buttons', () => {

View File

@@ -1,6 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TreemapViewType } from '../types';
@@ -144,7 +145,7 @@ describe('formatDataForMetricsTable', () => {
// Verify unit rendering
const unitElement = result[0].unit as JSX.Element;
const { container: unitWrapper } = render(unitElement);
expect(unitWrapper.textContent).toBe('bytes');
expect(unitWrapper.textContent).toBe(getUniversalNameFromMetricUnit('bytes'));
// Verify samples rendering
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
@@ -162,10 +163,10 @@ describe('formatDataForMetricsTable', () => {
it('should handle empty/null values', () => {
const mockData = [
{
metric_name: 'test-metric',
description: 'test-description',
metric_name: '',
description: '',
type: MetricType.GAUGE,
unit: 'ms',
unit: '',
[TreemapViewType.SAMPLES]: 0,
[TreemapViewType.TIMESERIES]: 0,
lastReceived: '2023-01-01T00:00:00Z',
@@ -177,17 +178,17 @@ describe('formatDataForMetricsTable', () => {
// Verify empty metric name rendering
const metricNameElement = result[0].metric_name as JSX.Element;
const { container: metricNameWrapper } = render(metricNameElement);
expect(metricNameWrapper.textContent).toBe('test-metric');
expect(metricNameWrapper.textContent).toBe('-');
// Verify null description rendering
const descriptionElement = result[0].description as JSX.Element;
const { container: descriptionWrapper } = render(descriptionElement);
expect(descriptionWrapper.textContent).toBe('test-description');
expect(descriptionWrapper.textContent).toBe('-');
// Verify null unit rendering
const unitElement = result[0].unit as JSX.Element;
const { container: unitWrapper } = render(unitElement);
expect(unitWrapper.textContent).toBe('ms');
expect(unitWrapper.textContent).toBe('-');
// Verify zero samples rendering
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;

View File

@@ -10,6 +10,7 @@ import {
SamplesData,
TimeseriesData,
} from 'api/metricsExplorer/getMetricsTreeMap';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import {
BarChart,
BarChart2,
@@ -199,8 +200,8 @@ export const formatDataForMetricsTable = (
),
metric_type: <MetricTypeRenderer type={metric.type} />,
unit: (
<ValidateRowValueWrapper value={metric.unit}>
{metric.unit}
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
{getUniversalNameFromMetricUnit(metric.unit)}
</ValidateRowValueWrapper>
),
[TreemapViewType.SAMPLES]: (

View File

@@ -1,8 +1,11 @@
import './DashboardVariableSelection.styles.scss';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -28,6 +31,8 @@ function DashboardVariableSelection(): JSX.Element | null {
setVariablesToGetUpdated,
} = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { data } = selectedDashboard || {};
const { variables } = data || {};
@@ -61,8 +66,11 @@ function DashboardVariableSelection(): JSX.Element | null {
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
}, [variables]);
}, [getUrlVariables, updateUrlVariable, variables]);
useEffect(() => {
if (variablesTableData.length > 0) {
@@ -118,6 +126,12 @@ function DashboardVariableSelection(): JSX.Element | null {
const isDynamic = variable?.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (allSelected) {
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
} else {
updateUrlVariable(name || id, value);
}
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (prev) {

View File

@@ -66,6 +66,7 @@ function PromQLQueryBuilder({
defaultValue={queryData?.query}
addonBefore="PromQL Query"
style={{ marginBottom: '0.5rem' }}
data-testid="promql-query-input"
/>
<Input
@@ -75,6 +76,7 @@ function PromQLQueryBuilder({
defaultValue={queryData?.legend}
addonBefore="Legend Format"
style={{ marginBottom: '0.5rem' }}
data-testid="promql-legend-input"
/>
</QueryHeader>
);

View File

@@ -30,25 +30,21 @@ function LeftContainer({
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
// const { selectedDashboard } = useDashboard();
const { selectedTime: globalSelectedInterval } = useSelector<
const { selectedTime: globalSelectedInterval, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryResponse = useGetQueryRange(
requestData,
// selectedDashboard?.data?.version || DEFAULT_ENTITY_VERSION,
ENTITY_VERSION_V5,
{
enabled: !!stagedQuery,
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedInterval,
requestData,
],
},
);
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
enabled: !!stagedQuery,
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedInterval,
requestData,
minTime,
maxTime,
],
});
// Update parent component with query response for legend colors
useEffect(() => {

View File

@@ -43,6 +43,7 @@ function Threshold({
tableOptions,
thresholdTableOptions = '',
columnUnits,
yAxisUnit,
}: ThresholdProps): JSX.Element {
const [isEditMode, setIsEditMode] = useState<boolean>(isEditEnabled);
const [operator, setOperator] = useState<string | number>(
@@ -195,16 +196,13 @@ function Threshold({
const allowDragAndDrop = panelTypeVsDragAndDrop[selectedGraph];
const isInvalidUnitComparison = useMemo(
() =>
unit !== 'none' &&
convertUnit(
value,
unit,
getColumnUnit(tableSelectedOption, columnUnits || {}),
) === null,
[unit, value, columnUnits, tableSelectedOption],
);
const isInvalidUnitComparison = useMemo(() => {
const toUnitId =
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {})
: yAxisUnit;
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]);
return (
<div
@@ -318,7 +316,9 @@ function Threshold({
<Select
defaultValue={unit}
options={unitOptions(
getColumnUnit(tableSelectedOption, columnUnits || {}) || '',
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
)}
onChange={handleUnitChange}
showSearch
@@ -357,8 +357,12 @@ function Threshold({
</div>
{isInvalidUnitComparison && (
<Typography.Text className="invalid-unit">
Threshold unit ({unit}) is not valid in comparison with the column unit (
{getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'})
Threshold unit ({unit}) is not valid in comparison with the{' '}
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
{selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'
: yAxisUnit || 'none'}
)
</Typography.Text>
)}
{isEditMode && (

View File

@@ -95,6 +95,7 @@ function ThresholdSelector({
tableOptions={aggregationQueries}
thresholdTableOptions={threshold.thresholdTableOptions}
columnUnits={columnUnits}
yAxisUnit={yAxisUnit}
/>
))}
</div>

View File

@@ -0,0 +1,158 @@
/* eslint-disable react/jsx-props-no-spreading */
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { render, screen } from 'tests/test-utils';
import Threshold from '../Threshold';
// Mock the getColumnUnit function
jest.mock('lib/query/createTableColumnsFromQuery', () => ({
getColumnUnit: jest.fn(
(option: string, columnUnits: Record<string, string>) =>
columnUnits[option] || 'percent',
),
}));
// Mock the unitOptions function
jest.mock('container/NewWidget/utils', () => ({
unitOptions: jest.fn(() => [
{ value: 'none', label: 'None' },
{ value: 'percent', label: 'Percent' },
{ value: 'ms', label: 'Milliseconds' },
]),
}));
const defaultProps = {
index: 'test-threshold-1',
keyIndex: 0,
thresholdOperator: '>' as const,
thresholdValue: 50,
thresholdUnit: 'none',
thresholdColor: 'Red',
thresholdFormat: 'Text' as const,
isEditEnabled: true,
selectedGraph: PANEL_TYPES.TABLE,
tableOptions: [
{ value: 'cpu_usage', label: 'CPU Usage' },
{ value: 'memory_usage', label: 'Memory Usage' },
],
thresholdTableOptions: 'cpu_usage',
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
yAxisUnit: 'percent',
moveThreshold: jest.fn(),
};
const renderThreshold = (props = {}): void => {
render(
<DndProvider backend={HTML5Backend}>
<Threshold {...{ ...defaultProps, ...props }} />
</DndProvider>,
);
};
describe('Threshold Component Unit Validation', () => {
it('should not show validation error when threshold unit is "none" regardless of column unit', () => {
// Act - Render component with "none" threshold unit
renderThreshold({
thresholdUnit: 'none',
thresholdValue: 50,
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
).not.toBeInTheDocument();
});
it('should show validation error when threshold unit is not "none" and units are incompatible', () => {
// Act - Render component with incompatible units (ms vs percent)
renderThreshold({
thresholdUnit: 'ms',
thresholdValue: 50,
});
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(ms\) is not valid in comparison with the column unit \(percent\)/i,
),
).toBeInTheDocument();
});
it('should not show validation error when threshold unit matches column unit', () => {
// Act - Render component with matching units
renderThreshold({
thresholdUnit: 'percent',
thresholdValue: 50,
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
).not.toBeInTheDocument();
});
it('should show validation error for time series graph when units are incompatible', () => {
// Act - Render component for time series with incompatible units
renderThreshold({
selectedGraph: PANEL_TYPES.TIME_SERIES,
thresholdUnit: 'ms',
thresholdValue: 100,
yAxisUnit: 'percent',
});
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(ms\) is not valid in comparison with the y-axis unit \(percent\)/i,
),
).toBeInTheDocument();
});
it('should not show validation error for time series graph when threshold unit is "none"', () => {
// Act - Render component for time series with "none" threshold unit
renderThreshold({
selectedGraph: PANEL_TYPES.TIME_SERIES,
thresholdUnit: 'none',
thresholdValue: 100,
yAxisUnit: 'percent',
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
).not.toBeInTheDocument();
});
it('should not show validation error when threshold unit is compatible with column unit', () => {
// Act - Render component with compatible units (both in same category - Time)
renderThreshold({
thresholdUnit: 's',
thresholdValue: 100,
columnUnits: { cpu_usage: 'ms' },
thresholdTableOptions: 'cpu_usage',
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
).not.toBeInTheDocument();
});
it('should show validation error when threshold unit is in different category than column unit', () => {
// Act - Render component with units from different categories
renderThreshold({
thresholdUnit: 'bytes',
thresholdValue: 100,
yAxisUnit: 'percent',
});
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(bytes\) is not valid in comparison with the column unit \(percent\)/i,
),
).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,7 @@ export type ThresholdProps = {
selectedGraph: PANEL_TYPES;
tableOptions?: Array<{ value: string; label: string }>;
columnUnits?: ColumnUnit;
yAxisUnit?: string;
};
export type ShowCaseValueProps = {

View File

@@ -28,7 +28,15 @@ function ConfigureGoogleAuthAuthnProvider({
</Typography.Paragraph>
</section>
<Form.Item label="Domain" name="name" className="field">
<Form.Item
label="Domain"
name="name"
className="field"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>

View File

@@ -16,7 +16,14 @@ function ConfigureOIDCAuthnProvider({
</Typography.Text>
</section>
<Form.Item label="Domain" name="name">
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>

View File

@@ -16,7 +16,14 @@ function ConfigureSAMLAuthnProvider({
</Typography.Text>
</section>
<Form.Item label="Domain" name="name">
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
@@ -24,7 +31,7 @@ function ConfigureSAMLAuthnProvider({
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
tooltip={{
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
}}
>
<Input />
@@ -34,7 +41,7 @@ function ConfigureSAMLAuthnProvider({
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
tooltip={{
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
}}
>
<Input />

View File

@@ -0,0 +1,289 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getMockQuery, getMockQueryData } from './testUtils';
const mockQueryData = getMockQueryData();
const mockQuery = getMockQuery();
const MOCK_LABEL_NAME = 'mock-label-name';
describe('getLegend', () => {
it('should directly return the label name for clickhouse query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.CLICKHOUSE,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should directly return the label name for promql query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.PROM,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single builder query with single aggregation and alias (logs)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('alias_sum');
});
it('should return legend when single builder query with no alias but legend set (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
legend: 'custom-legend',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('custom-legend');
});
it('should return label when grouped by with single aggregation (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it("should return '<alias>-<label>' when grouped by with multiple aggregations (builder)", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should fallback to label or query name when no alias/expression', () => {
const legendsData = getLegend(mockQueryData, mockQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single query with multiple aggregations and no group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'total'" },
{ expression: 'count()' },
],
groupBy: [],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('total');
});
it("should return '<alias>-<label>' when multiple queries with group by", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should return label according to the index of the query', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_a'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(
{
...mockQueryData,
metaData: {
...mockQueryData.metaData,
index: 1,
},
} as QueryData,
payloadQuery,
MOCK_LABEL_NAME,
);
expect(legendsData).toBe(`count()-${MOCK_LABEL_NAME}`);
});
it('should handle trace operator with multiple queries and group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: 'A',
dataSource: DataSource.TRACES,
aggregations: [{ expression: 'count()' }],
},
],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [
{ expression: "count() as 'total_count' avg(duration_nano)" },
],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total_count-${MOCK_LABEL_NAME}`);
});
it('should handle single trace operator query with group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [{ expression: "count() as 'total' avg(duration_nano)" }],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A && B',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total-${MOCK_LABEL_NAME}`);
});
});

View File

@@ -0,0 +1,36 @@
import { initialQueryState } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
export function getMockQueryData(): QueryData {
return {
lowerBoundSeries: [],
upperBoundSeries: [],
predictedSeries: [],
anomalyScores: [],
metric: {},
queryName: 'test-query-name',
legend: 'test-legend',
values: [],
quantity: [],
unit: 'test-unit',
table: {
rows: [],
columns: [],
},
metaData: {
alias: 'test-alias',
index: 0,
queryName: 'test-query-name',
},
};
}
export function getMockQuery(overrides?: Partial<Query>): Query {
return {
...initialQueryState,
queryType: EQueryType.QUERY_BUILDER,
...overrides,
};
}

View File

@@ -9,4 +9,5 @@ export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
onSelect?: (value: BaseAutocompleteData) => void;
index?: number;
signalSource?: 'meter' | '';
setAttributeKeys?: (keys: BaseAutocompleteData[]) => void;
};

View File

@@ -37,6 +37,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
onSelect,
index,
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -97,6 +98,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
})) || [];
setOptionsData(options);
setAttributeKeys?.(data?.payload?.attributeKeys || []);
},
},
);
@@ -135,6 +137,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
onChange,
index,
query,
setAttributeKeys,
]);
const handleSearchText = useCallback((text: string): void => {
@@ -153,23 +156,25 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return 'Aggregate attribute';
}, [signalSource, query.dataSource]);
const getAttributesData = useCallback(
(): BaseAutocompleteData[] =>
const getAttributesData = useCallback((): BaseAutocompleteData[] => {
const attributeKeys =
queryClient.getQueryData<SuccessResponse<IQueryAutocompleteResponse>>([
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
debouncedValue,
queryAggregation.timeAggregation,
query.dataSource,
index,
])?.payload?.attributeKeys || [],
[
debouncedValue,
queryAggregation.timeAggregation,
query.dataSource,
queryClient,
index,
],
);
])?.payload?.attributeKeys || [];
setAttributeKeys?.(attributeKeys);
return attributeKeys;
}, [
debouncedValue,
queryAggregation.timeAggregation,
query.dataSource,
queryClient,
index,
setAttributeKeys,
]);
const getResponseAttributes = useCallback(async () => {
const response = await queryClient.fetchQuery(
@@ -188,6 +193,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
}),
);
setAttributeKeys?.(response.payload?.attributeKeys || []);
return response.payload?.attributeKeys || [];
}, [
queryAggregation.timeAggregation,
@@ -195,6 +201,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
queryClient,
searchText,
index,
setAttributeKeys,
]);
const handleChangeCustomValue = useCallback(

View File

@@ -194,7 +194,7 @@ describe('TableDrilldown', () => {
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
urlObj.searchParams.get('compositeQuery') || '{}',
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
);
// Verify the query structure includes the filters from clicked data
@@ -270,7 +270,7 @@ describe('TableDrilldown', () => {
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
urlObj.searchParams.get('compositeQuery') || '{}',
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
);
// Verify the query structure includes the filters from clicked data
expect(compositeQuery.builder).toBeDefined();

View File

@@ -137,7 +137,7 @@ const useBaseAggregateOptions = ({
);
let queryParams = {
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),

View File

@@ -12,7 +12,9 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
@@ -30,8 +32,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { useSpanContextLogs } from './useSpanContextLogs';
interface SpanLogsProps {
traceId: string;
spanId: string;
@@ -39,29 +39,29 @@ interface SpanLogsProps {
startTime: number;
endTime: number;
};
logs: ILog[];
isLoading: boolean;
isError: boolean;
isFetching: boolean;
isLogSpanRelated: (logId: string) => boolean;
handleExplorerPageRedirect: () => void;
emptyStateConfig?: EmptyLogsListConfig;
}
function SpanLogs({
traceId,
spanId,
timeRange,
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
handleExplorerPageRedirect,
emptyStateConfig,
}: SpanLogsProps): JSX.Element {
const { updateAllQueriesOperators } = useQueryBuilder();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
} = useSpanContextLogs({
traceId,
spanId,
timeRange,
});
// Create trace_id and span_id filters for logs explorer navigation
const createLogsFilter = useCallback(
(targetSpanId: string): TagFilter => {
@@ -236,9 +236,7 @@ function SpanLogs({
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
<Typography.Text className="no-data-text-1">
No logs found for selected span.
<span className="no-data-text-2">
Try viewing logs for the current trace.
</span>
<span className="no-data-text-2">View logs for the current trace.</span>
</Typography.Text>
</section>
<section className="action-section">
@@ -249,24 +247,45 @@ function SpanLogs({
onClick={handleExplorerPageRedirect}
size="md"
>
Log Explorer
View Logs
</Button>
</section>
</div>
);
const renderSpanLogsContent = (): JSX.Element | null => {
if (isLoading || isFetching) {
return <LogsLoading />;
}
if (isError) {
return <LogsError />;
}
if (logs.length === 0) {
if (emptyStateConfig) {
return (
<EmptyLogsSearch
dataSource={DataSource.LOGS}
panelType="LIST"
customMessage={emptyStateConfig}
/>
);
}
return renderNoLogsFound();
}
return renderContent;
};
return (
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
{(isLoading || isFetching) && <LogsLoading />}
{!isLoading &&
!isFetching &&
!isError &&
logs.length === 0 &&
renderNoLogsFound()}
{isError && !isLoading && !isFetching && <LogsError />}
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
{renderSpanLogsContent()}
</div>
);
}
SpanLogs.defaultProps = {
emptyStateConfig: undefined,
};
export default SpanLogs;

View File

@@ -0,0 +1,214 @@
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import SpanLogs from '../SpanLogs';
// Mock external dependencies
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
}),
}),
}));
// Mock window.open
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
// Mock Virtuoso to avoid complex virtualization
jest.mock('react-virtuoso', () => ({
Virtuoso: jest.fn(({ data, itemContent }: any) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => (
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
)),
}));
// Mock RawLogView component
jest.mock(
'components/Logs/RawLogView',
() =>
function MockRawLogView({
data,
onLogClick,
isHighlighted,
helpTooltip,
}: any): JSX.Element {
return (
<button
type="button"
data-testid={`raw-log-${data.id}`}
className={isHighlighted ? 'log-highlighted' : 'log-context'}
title={helpTooltip}
onClick={(e): void => onLogClick?.(data, e)}
>
<div>{data.body}</div>
<div>{data.timestamp}</div>
</button>
);
},
);
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({ children }: any): JSX.Element => (
<div>{children}</div>
),
}));
// Mock OverlayScrollbar
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
default: ({ children }: any): JSX.Element => (
<div data-testid="overlay-scrollbar">{children}</div>
),
}));
// Mock LogsLoading component
jest.mock('container/LogsLoading/LogsLoading', () => ({
LogsLoading: function MockLogsLoading(): JSX.Element {
return <div data-testid="logs-loading">Loading logs...</div>;
},
}));
// Mock LogsError component
jest.mock(
'container/LogsError/LogsError',
() =>
function MockLogsError(): JSX.Element {
return <div data-testid="logs-error">Error loading logs</div>;
},
);
// Don't mock EmptyLogsSearch - test the actual component behavior
const TEST_TRACE_ID = 'test-trace-id';
const TEST_SPAN_ID = 'test-span-id';
const defaultProps = {
traceId: TEST_TRACE_ID,
spanId: TEST_SPAN_ID,
timeRange: {
startTime: 1640995200000,
endTime: 1640995260000,
},
logs: [],
isLoading: false,
isError: false,
isFetching: false,
isLogSpanRelated: jest.fn().mockReturnValue(false),
handleExplorerPageRedirect: jest.fn(),
};
describe('SpanLogs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
});
afterEach(() => {
server.resetHandlers();
});
it('should show simple empty state when emptyStateConfig is not provided', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<SpanLogs {...defaultProps} />);
// Should show simple empty state (no emptyStateConfig provided)
expect(
screen.getByText('No logs found for selected span.'),
).toBeInTheDocument();
expect(
screen.getByText('View logs for the current trace.'),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /view logs/i,
}),
).toBeInTheDocument();
// Should NOT show enhanced empty state
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
});
it('should show enhanced empty state when entire trace has no logs', () => {
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
/>,
);
// Should show enhanced empty state with custom message
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
expect(screen.getByText('This could be because :')).toBeInTheDocument();
// Should show description list
expect(
screen.getByText('Logs are not linked to Traces.'),
).toBeInTheDocument();
expect(
screen.getByText('Logs are not being sent to SigNoz.'),
).toBeInTheDocument();
expect(
screen.getByText('No logs are associated with this particular trace/span.'),
).toBeInTheDocument();
// Should show documentation links
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
// Should NOT show simple empty state
expect(
screen.queryByText('No logs found for selected span.'),
).not.toBeInTheDocument();
});
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockHandleExplorerPageRedirect = jest.fn();
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
/>,
);
const logExplorerButton = screen.getByRole('button', {
name: /view logs/i,
});
await user.click(logExplorerButton);
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
type: '',
key: 'trace_id',
},
op: 'in',
op: '=',
value: traceId,
},
],

View File

@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Filter } from 'types/api/v5/queryRange';
import { v4 as uuid } from 'uuid';
import { getSpanLogsQueryPayload } from './constants';
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
interface UseSpanContextLogsProps {
traceId: string;
@@ -20,6 +20,7 @@ interface UseSpanContextLogsProps {
startTime: number;
endTime: number;
};
isDrawerOpen?: boolean;
}
interface UseSpanContextLogsReturn {
@@ -29,6 +30,7 @@ interface UseSpanContextLogsReturn {
isFetching: boolean;
spanLogIds: Set<string>;
isLogSpanRelated: (logId: string) => boolean;
hasTraceIdLogs: boolean;
}
const traceIdKey = {
@@ -110,6 +112,7 @@ export const useSpanContextLogs = ({
traceId,
spanId,
timeRange,
isDrawerOpen = true,
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
const [allLogs, setAllLogs] = useState<ILog[]>([]);
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
@@ -264,6 +267,43 @@ export const useSpanContextLogs = ({
setAllLogs(combined);
}, [beforeLogs, spanLogs, afterLogs]);
// Phase 4: Check for trace_id-only logs when span has no logs
// This helps differentiate between "no logs for span" vs "no logs for trace"
const traceOnlyFilter = useMemo(() => {
if (spanLogs.length > 0) return null;
const filters = getTraceOnlyFilters(traceId);
return convertFiltersToExpression(filters);
}, [traceId, spanLogs.length]);
const traceOnlyQueryPayload = useMemo(() => {
if (!traceOnlyFilter) return null;
return getSpanLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
traceOnlyFilter,
);
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
const { data: traceOnlyData } = useQuery({
queryKey: [
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
traceId,
timeRange.startTime,
timeRange.endTime,
],
queryFn: () =>
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
staleTime: FIVE_MINUTES_IN_MS,
});
const hasTraceIdLogs = useMemo(() => {
if (spanLogs.length > 0) return true;
return !!(
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
);
}, [spanLogs.length, traceOnlyData]);
// Helper function to check if a log belongs to the span
const isLogSpanRelated = useCallback(
(logId: string): boolean => spanLogIds.has(logId),
@@ -277,5 +317,6 @@ export const useSpanContextLogs = ({
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
spanLogIds,
isLogSpanRelated,
hasTraceIdLogs,
};
};

View File

@@ -37,7 +37,8 @@
align-items: center;
justify-content: space-between;
.open-in-explorer {
width: 30px;
display: flex;
align-items: center;
height: 30px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);

View File

@@ -11,39 +11,20 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { Span } from 'types/api/trace/getTraceV2';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface AppliedFiltersProps {
filters: TagFilterItem[];
}
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
return (
<div className="span-related-signals-drawer__applied-filters">
<div className="span-related-signals-drawer__filters-list">
{filters.map((filter) => (
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
<Typography.Text>
{filter.key?.key}={filter.value}
</Typography.Text>
</div>
))}
</div>
</div>
);
}
interface SpanRelatedSignalsProps {
selectedSpan: Span;
traceStartTime: number;
@@ -66,6 +47,23 @@ function SpanRelatedSignals({
);
const isDarkMode = useIsDarkMode();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: isOpen,
});
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
}, []);
@@ -75,25 +73,6 @@ function SpanRelatedSignals({
onClose();
}, [onClose]);
const appliedFilters = useMemo(
(): TagFilterItem[] => [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
[selectedSpan.traceId],
);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
@@ -146,6 +125,14 @@ function SpanRelatedSignals({
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
return (
<Drawer
width="50%"
@@ -210,23 +197,28 @@ function SpanRelatedSignals({
icon={<Compass size={18} />}
className="open-in-explorer"
onClick={handleExplorerPageRedirect}
/>
>
Open in Logs Explorer
</Button>
)}
</div>
{selectedView === RelatedSignalsViews.LOGS && (
<>
<AppliedFilters filters={appliedFilters} />
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
handleExplorerPageRedirect={handleExplorerPageRedirect}
/>
</>
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
/>
)}
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
expectedAfterFilterExpression,
expectedBeforeFilterExpression,
expectedSpanFilterExpression,
expectedTraceOnlyFilterExpression,
mockAfterLogsResponse,
mockBeforeLogsResponse,
mockEmptyLogsResponse,
@@ -217,19 +218,22 @@ const renderSpanDetailsDrawer = (props = {}): void => {
};
describe('SpanDetailsDrawer', () => {
let apiCallHistory: any[] = [];
let apiCallHistory: any = {};
beforeEach(() => {
jest.clearAllMocks();
apiCallHistory = [];
apiCallHistory = {
span_logs: null,
before_logs: null,
after_logs: null,
trace_only_logs: null,
};
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup API call tracking
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);
// Determine response based on v5 filter expressions
const filterExpression =
query.query?.builder?.queryData?.[0]?.filter?.expression;
@@ -238,14 +242,23 @@ describe('SpanDetailsDrawer', () => {
// Check for span logs query (contains both trace_id and span_id)
if (filterExpression.includes('span_id')) {
apiCallHistory.span_logs = query;
return Promise.resolve(mockSpanLogsResponse);
}
// Check for before logs query (contains trace_id and id <)
if (filterExpression.includes('id <')) {
apiCallHistory.before_logs = query;
return Promise.resolve(mockBeforeLogsResponse);
}
// Check for after logs query (contains trace_id and id >)
if (filterExpression.includes('id >')) {
apiCallHistory.after_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
// Check for trace only logs query (contains trace_id)
if (filterExpression.includes('trace_id =')) {
apiCallHistory.trace_only_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
@@ -287,7 +300,7 @@ describe('SpanDetailsDrawer', () => {
});
});
it('should make three API queries when logs tab is opened', async () => {
it('should make 4 API queries when logs tab is opened', async () => {
renderSpanDetailsDrawer();
// Click on logs tab to trigger API calls
@@ -296,11 +309,16 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Verify the three distinct queries were made
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
// Verify the four distinct queries were made
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
trace_only_logs: traceOnlyQuery,
} = apiCallHistory;
// 1. Span logs query (trace_id + span_id)
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
@@ -316,6 +334,11 @@ describe('SpanDetailsDrawer', () => {
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
expectedAfterFilterExpression,
);
// 4. Trace only logs query (trace_id)
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
expectedTraceOnlyFilterExpression,
);
});
it('should use correct timestamp ordering for different query types', async () => {
@@ -327,10 +350,14 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
} = apiCallHistory;
// Verify ordering: span query should use 'desc' (default)
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
@@ -463,24 +490,6 @@ describe('SpanDetailsDrawer', () => {
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('should handle empty logs state', async () => {
// Mock empty response for all queries
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
renderSpanDetailsDrawer();
// Open logs view
const logsButton = screen.getByRole('radio', { name: /logs/i });
fireEvent.click(logsButton);
// Wait and verify empty state is shown
await waitFor(() => {
expect(
screen.getByText(/No logs found for selected span/),
).toBeInTheDocument();
});
});
it('should display span logs as highlighted and context logs as regular', async () => {
renderSpanDetailsDrawer();
@@ -490,7 +499,7 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete first
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Wait for all logs to be rendered - both span logs and context logs

View File

@@ -12,7 +12,7 @@ export const mockSpan: Span = {
traceId: TEST_TRACE_ID,
name: TEST_SERVICE,
serviceName: TEST_SERVICE,
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
durationNano: 1000000000, // 1 second in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
@@ -207,3 +207,4 @@ export const mockEmptyLogsResponse = {
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;

View File

@@ -0,0 +1,253 @@
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import useVariablesFromUrl, {
LocalStoreDashboardVariables,
} from '../useVariablesFromUrl';
describe('useVariablesFromUrl', () => {
it('should initialize with empty variables when no URL params exist', () => {
const history = createMemoryHistory({
initialEntries: ['/'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual({});
});
it('should correctly parse variables from URL', () => {
const mockVariables = {
var1: 'value1',
var2: ['value2', 'value3'],
var3: 123,
};
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual(mockVariables);
});
it('should handle malformed URL parameters gracefully', () => {
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=invalid-json`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
// Should return empty object when JSON parsing fails
expect(result.current.getUrlVariables()).toEqual({});
});
it('should set variables to URL correctly', () => {
const history = createMemoryHistory({
initialEntries: ['/'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const mockVariables: LocalStoreDashboardVariables = {
var1: 'value1',
var2: ['value2', 'value3'],
};
act(() => {
result.current.setUrlVariables(mockVariables);
});
// Check if the URL was updated correctly
const searchParams = new URLSearchParams(history.location.search);
const urlVariables = searchParams.get(QueryParams.variables);
expect(urlVariables).toBeTruthy();
expect(JSON.parse(decodeURIComponent(urlVariables || ''))).toEqual(
mockVariables,
);
});
it('should remove variables param from URL when empty object is provided', () => {
const mockVariables = {
var1: 'value1',
var2: ['value2', 'value3'],
};
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
act(() => {
result.current.setUrlVariables({});
});
// Check if the URL param was removed
const searchParams = new URLSearchParams(history.location.search);
expect(searchParams.has(QueryParams.variables)).toBe(false);
});
it('should update a specific variable correctly', () => {
const initialVariables = {
var1: 'value1',
var2: ['value2', 'value3'],
};
const encodedVariables = encodeURIComponent(JSON.stringify(initialVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const newValue: IDashboardVariable['selectedValue'] = 'updated-value';
act(() => {
result.current.updateUrlVariable('var1', newValue);
});
// Check if only the specified variable was updated
const updatedVariables = result.current.getUrlVariables();
expect(updatedVariables.var1).toEqual(newValue);
expect(updatedVariables.var2).toEqual(initialVariables.var2);
});
it('should preserve other URL parameters when updating variables', () => {
const history = createMemoryHistory({
initialEntries: ['/?otherParam=value'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const mockVariables: LocalStoreDashboardVariables = {
var1: 'value1',
};
act(() => {
result.current.setUrlVariables(mockVariables);
});
// Check if other params are preserved
const searchParams = new URLSearchParams(history.location.search);
expect(searchParams.get('otherParam')).toBe('value');
expect(searchParams.has(QueryParams.variables)).toBe(true);
});
it('should handle different variable value types correctly', () => {
const mockVariables: LocalStoreDashboardVariables = {
stringVar: 'production',
numberVar: 123,
booleanVar: true,
arrayVar: ['service1', 'service2'],
mixedArrayVar: ['string', 456, false],
nullVar: null,
};
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const urlVariables = result.current.getUrlVariables();
expect(urlVariables.stringVar).toBe('production');
expect(urlVariables.numberVar).toBe(123);
expect(urlVariables.booleanVar).toBe(true);
expect(urlVariables.arrayVar).toEqual(['service1', 'service2']);
expect(urlVariables.mixedArrayVar).toEqual(['string', 456, false]);
expect(urlVariables.nullVar).toBeNull();
});
it('should handle edge cases in URL variable parsing', () => {
const edgeCaseVariables = {
emptyString: '',
emptyArray: [],
singleItemArray: ['solo'],
};
const encodedVariables = encodeURIComponent(
JSON.stringify(edgeCaseVariables),
);
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const urlVariables = result.current.getUrlVariables();
expect(urlVariables.emptyString).toBe('');
expect(urlVariables.emptyArray).toEqual([]);
expect(urlVariables.singleItemArray).toEqual(['solo']);
expect(urlVariables.undefinedVar).toBeUndefined();
});
it('should update variables with array values correctly', () => {
const history = createMemoryHistory({
initialEntries: ['/'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const arrayValue: IDashboardVariable['selectedValue'] = [
'value1',
'value2',
'value3',
];
act(() => {
result.current.updateUrlVariable('multiSelectVar', arrayValue);
});
const updatedVariables = result.current.getUrlVariables();
expect(updatedVariables.multiSelectVar).toEqual(arrayValue);
});
});

View File

@@ -0,0 +1,102 @@
import * as Sentry from '@sentry/react';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface LocalStoreDashboardVariables {
[name: string]:
| IDashboardVariable['selectedValue'][]
| IDashboardVariable['selectedValue'];
}
interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: (
name: string,
selectedValue: IDashboardVariable['selectedValue'],
) => void;
clearUrlVariables: () => void;
}
const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
const urlQuery = useUrlQuery();
const history = useHistory();
const getUrlVariables = useCallback((): LocalStoreDashboardVariables => {
const variablesParam = urlQuery.get(QueryParams.variables);
if (!variablesParam) {
return {};
}
try {
return JSON.parse(decodeURIComponent(variablesParam));
} catch (error) {
Sentry.captureEvent({
message: `Failed to parse dashboard variables from URL: ${error}`,
level: 'error',
});
return {};
}
}, [urlQuery]);
const setUrlVariables = useCallback(
(variables: LocalStoreDashboardVariables): void => {
const params = new URLSearchParams(urlQuery.toString());
if (Object.keys(variables).length === 0) {
params.delete(QueryParams.variables);
} else {
try {
const encodedVariables = encodeURIComponent(JSON.stringify(variables));
params.set(QueryParams.variables, encodedVariables);
} catch (error) {
Sentry.captureEvent({
message: `Failed to serialize dashboard variables for URL: ${error}`,
level: 'error',
});
}
}
history.replace({
search: params.toString(),
});
},
[history, urlQuery],
);
const clearUrlVariables = useCallback((): void => {
const params = new URLSearchParams(urlQuery.toString());
params.delete(QueryParams.variables);
history.replace({
search: params.toString(),
});
}, [history, urlQuery]);
const updateUrlVariable = useCallback(
(name: string, selectedValue: IDashboardVariable['selectedValue']): void => {
const currentVariables = getUrlVariables();
const updatedVariables = {
...currentVariables,
[name]: selectedValue,
};
setUrlVariables(updatedVariables as LocalStoreDashboardVariables);
},
[getUrlVariables, setUrlVariables],
);
return {
getUrlVariables,
setUrlVariables,
updateUrlVariable,
clearUrlVariables,
};
};
export default useVariablesFromUrl;

View File

@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
@@ -33,6 +33,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
} as BaseAutocompleteData,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: '',
aggregations: [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: 'test_metric',
temporality: '',
spaceAggregation: '',
},
],
having: [],
limit: null,
queryName: 'test_query',
@@ -131,5 +139,111 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
}),
);
});
it('should preserve aggregation operators when metric type remains the same (GAUGE to GAUGE)', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
const newAttribute: BaseAutocompleteData = {
key: 'new_gauge_metric',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.GAUGE,
};
act(() => {
result.current.handleChangeAggregatorAttribute(newAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({
aggregateAttribute: newAttribute,
aggregations: [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: 'new_gauge_metric',
temporality: '',
spaceAggregation: '',
},
],
}),
);
});
it('should reset aggregation operators when metric type changes (GAUGE to SUM) with v5 from start', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
const newAttribute: BaseAutocompleteData = {
key: 'new_sum_metric',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.SUM,
};
act(() => {
result.current.handleChangeAggregatorAttribute(newAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({
aggregations: [
{
timeAggregation: MetricAggregateOperator.RATE,
metricName: 'new_sum_metric',
temporality: '',
spaceAggregation: '',
},
],
}),
);
});
it('should preserve aggregation operators when metric type remains the same (SUM to SUM)', () => {
const sumMockQuery: IBuilderQuery = {
...defaultMockQuery,
aggregateAttribute: undefined,
aggregateOperator: '',
timeAggregation: undefined,
spaceAggregation: undefined,
aggregations: [
{
timeAggregation: MetricAggregateOperator.RATE,
metricName: 'original_sum_metric',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
],
};
const { result } = renderHook(() =>
useQueryOperations({
query: sumMockQuery,
index: 0,
entityVersion: ENTITY_VERSION_V5,
}),
);
const newAttribute: BaseAutocompleteData = {
key: 'new_sum_metric',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.SUM,
};
act(() => {
result.current.handleChangeAggregatorAttribute(newAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({
aggregateAttribute: newAttribute,
aggregations: [
{
timeAggregation: MetricAggregateOperator.RATE,
metricName: 'new_sum_metric',
temporality: '',
spaceAggregation: '',
},
],
}),
);
});
});
});

View File

@@ -73,6 +73,26 @@ export const useQueryOperations: UseQueryOperations = ({
SelectOption<string, string>[]
>([]);
const [previousMetricInfo, setPreviousMetricInfo] = useState<{
name: string;
type: string;
} | null>(null);
useEffect(() => {
if (query) {
const metricName =
query.aggregateAttribute?.key ||
(query.aggregations?.[0] as MetricAggregation)?.metricName;
const metricType = query.aggregateAttribute?.type;
if (metricName && metricType) {
setPreviousMetricInfo({
name: metricName,
type: metricType,
});
}
}
}, [query]);
const { dataSource, aggregateOperator } = query;
const getNewListOfAdditionalFilters = useCallback(
@@ -214,12 +234,19 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
(
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
): void => {
const newQuery: IBuilderQuery = {
...query,
aggregateAttribute: value,
};
const getAttributeKeyFromMetricName = (metricName: string): string =>
attributeKeys?.find((key) => key.key === metricName)?.type || '';
if (
newQuery.dataSource === DataSource.METRICS &&
entityVersion === ENTITY_VERSION_V4
@@ -267,61 +294,97 @@ export const useQueryOperations: UseQueryOperations = ({
}
if (!isEditMode) {
if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.RATE,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
},
];
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
},
];
}
// Get current metric info
const currentMetricName = newQuery.aggregateAttribute?.key || '';
const currentMetricType = newQuery.aggregateAttribute?.type || '';
newQuery.aggregateOperator = '';
newQuery.spaceAggregation = '';
const prevMetricType = previousMetricInfo?.type
? previousMetricInfo.type
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'rate', space - 'sum', op - 'count'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
// Check if metric type has changed by comparing with tracked previous values
const metricTypeChanged =
!prevMetricType || !currentMetricType
? false
: prevMetricType !== currentMetricType;
// Only reset operators if metric type has changed or if this is the first metric selection
if (metricTypeChanged || !previousMetricInfo) {
if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.RATE,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
},
];
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
spaceAggregation: '',
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
spaceAggregation: '',
},
];
}
newQuery.aggregateOperator = '';
newQuery.spaceAggregation = '';
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'rate', space - 'sum', op - 'count'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.COUNT,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
}
}
} else {
// If metric type hasn't changed, preserve existing aggregations but update metric name
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
if (currentAggregation) {
newQuery.aggregations = [
{
...currentAggregation,
metricName: newQuery.aggregateAttribute?.key || '',
},
];
}
}
// Update the tracked metric info for next comparison only if we have valid data
if (currentMetricName && currentMetricType) {
setPreviousMetricInfo({
name: currentMetricName,
type: currentMetricType,
});
}
}
}
@@ -334,6 +397,7 @@ export const useQueryOperations: UseQueryOperations = ({
handleSetQueryData,
index,
handleMetricAggregateAtributeTypes,
previousMetricInfo,
],
);

View File

@@ -1,7 +1,14 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import {
Location,
NavigateFunction,
useLocation,
useNavigate,
} from 'react-router-dom-v5-compat';
import { isEventObject } from 'utils/isEventObject';
// state uses 'any' because react-router's NavigateOptions interface uses it
interface NavigateOptions {
replace?: boolean;
state?: any;
@@ -83,6 +90,74 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
return newKeys.length > 0;
};
// Helper function to extract options from arguments
const extractOptions = (
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
): NavigateOptions => {
const isEvent = isEventObject(optionsOrEvent);
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
const shouldOpenInNewTab =
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
return {
...actualOptions,
newTab: shouldOpenInNewTab || actualOptions?.newTab,
};
};
// Helper function to create target URL
const createTargetUrl = (
to: string | SafeNavigateParams,
location: Location,
): URL => {
if (typeof to === 'string') {
return new URL(to, window.location.origin);
}
return new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
};
// Helper function to handle new tab navigation
const handleNewTabNavigation = (
to: string | SafeNavigateParams,
location: Location,
): void => {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
};
// Helper function to perform navigation
const performNavigation = (
to: string | SafeNavigateParams,
navigationOptions: NavigateOptions,
navigate: NavigateFunction,
location: Location,
): void => {
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,
@@ -90,6 +165,11 @@ export const useSafeNavigate = (
): {
safeNavigate: (
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => void;
} => {
@@ -97,30 +177,25 @@ export const useSafeNavigate = (
const location = useLocation();
const safeNavigate = useCallback(
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
(
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => {
const finalOptions = extractOptions(optionsOrEvent, options);
const currentUrl = new URL(
`${location.pathname}${location.search}`,
window.location.origin,
);
const targetUrl = createTargetUrl(to, location);
let targetUrl: URL;
if (typeof to === 'string') {
targetUrl = new URL(to, window.location.origin);
} else {
targetUrl = new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
}
// If newTab is true, open in new tab and return early
if (options?.newTab) {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
// Handle new tab navigation
if (finalOptions?.newTab) {
handleNewTabNavigation(to, location);
return;
}
@@ -132,23 +207,13 @@ export const useSafeNavigate = (
}
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
...finalOptions,
replace: isDefaultParamsNavigation || finalOptions?.replace,
};
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
performNavigation(to, navigationOptions, navigate, location);
},
[navigate, location.pathname, location.search, preventSameUrlNavigation],
[navigate, location, preventSameUrlNavigation],
);
return { safeNavigate };

View File

@@ -0,0 +1,634 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { LocationDescriptorObject } from 'history';
import history from '../history';
jest.mock('history', () => {
const actualHistory = jest.requireActual('history');
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockGo = jest.fn();
const mockGoBack = jest.fn();
const mockGoForward = jest.fn();
const mockBlock = jest.fn(() => jest.fn());
const mockListen = jest.fn(() => jest.fn());
const mockCreateHref = jest.fn((location) => {
if (typeof location === 'string') return location;
return actualHistory.createPath(location);
});
const baseHistory = {
length: 2,
action: 'PUSH' as const,
location: {
pathname: '/current-path',
search: '?existing=param',
hash: '#section',
state: { existing: 'state' },
key: 'test-key',
},
push: mockPush,
replace: mockReplace,
go: mockGo,
goBack: mockGoBack,
goForward: mockGoForward,
block: mockBlock,
listen: mockListen,
createHref: mockCreateHref,
};
return {
...actualHistory,
createBrowserHistory: jest.fn(() => baseHistory),
};
});
interface TestUser {
id: number;
name: string;
email: string;
}
interface TestState {
from?: string;
user?: TestUser;
timestamp?: number;
}
describe('Enhanced History Methods', () => {
let mockWindowOpen: jest.SpyInstance;
let originalPush: jest.MockedFunction<typeof history.push>;
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
originalPush = history.originalPush as jest.MockedFunction<
typeof history.push
>;
});
afterEach(() => {
mockWindowOpen.mockRestore();
});
describe('history.push() - String Path Navigation', () => {
it('should handle simple string path navigation', () => {
history.push('/dashboard');
expect(originalPush).toHaveBeenCalledTimes(1);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with state', () => {
const testState: TestState = { from: 'home', timestamp: Date.now() };
history.push('/dashboard', testState);
expect(originalPush).toHaveBeenCalledWith('/dashboard', testState);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with query parameters', () => {
history.push('/logs?filter=error&timeRange=24h');
expect(originalPush).toHaveBeenCalledWith(
'/logs?filter=error&timeRange=24h',
undefined,
);
});
it('should handle string path with hash', () => {
history.push('/docs#installation');
expect(originalPush).toHaveBeenCalledWith('/docs#installation', undefined);
});
it('should handle complex URL with all components', () => {
const complexUrl = '/api/traces?service=backend&status=error#span-details';
const state: TestState = {
user: { id: 1, name: 'John', email: 'john@test.com' },
};
history.push(complexUrl, state);
expect(originalPush).toHaveBeenCalledWith(complexUrl, state);
});
});
describe('history.push() - Location Object Navigation', () => {
it('should handle location object with only pathname', () => {
const location: LocationDescriptorObject = {
pathname: '/metrics',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle location object with pathname and search', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
search: '?filter=error&severity=high',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with all properties', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/traces',
search: '?service=api-server&duration=slow',
hash: '#span-123',
state: { from: 'dashboard', timestamp: Date.now() },
key: 'unique-key',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with state passed separately', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?type=critical',
};
const separateState: TestState = { from: 'monitoring' };
history.push(location, separateState);
expect(originalPush).toHaveBeenCalledWith(location, separateState);
});
it('should handle empty location object', () => {
const location: LocationDescriptorObject = {};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should preserve current pathname when updating search', () => {
const location: LocationDescriptorObject = {
pathname: history.location.pathname,
search: '?newParam=value',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(originalPush.mock.calls[0][0]).toHaveProperty(
'pathname',
'/current-path',
);
});
});
describe('history.push() - Event Handling (Cmd/Ctrl+Click)', () => {
describe('MouseEvent handling', () => {
it('should open in new tab when metaKey is pressed with string path', () => {
const event = new MouseEvent('click', { metaKey: true });
history.push('/dashboard', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with string path', () => {
const event = new MouseEvent('click', { ctrlKey: true });
history.push('/metrics', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/metrics', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when both metaKey and ctrlKey are pressed', () => {
const event = new MouseEvent('click', { metaKey: true, ctrlKey: true });
history.push('/logs', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle normal click without meta/ctrl keys', () => {
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'nav' };
history.push('/alerts', event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/alerts', state);
});
});
describe('KeyboardEvent handling', () => {
it('should open in new tab when metaKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { metaKey: true });
history.push('/traces', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/traces', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { ctrlKey: true });
history.push('/pipelines', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/pipelines', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
});
describe('React SyntheticEvent handling', () => {
it('should handle React MouseEvent with metaKey', () => {
const nativeEvent = new MouseEvent('click', { metaKey: true });
const reactEvent = {
nativeEvent,
metaKey: true,
ctrlKey: false,
} as React.MouseEvent;
history.push('/dashboard', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent with ctrlKey', () => {
const nativeEvent = new MouseEvent('click', { ctrlKey: true });
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: true,
} as React.MouseEvent;
history.push('/logs', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent without modifier keys', () => {
const nativeEvent = new MouseEvent('click');
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: false,
} as React.MouseEvent;
history.push('/metrics', reactEvent);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/metrics', undefined);
});
});
describe('Location Object with Event handling', () => {
it('should open location object URL in new tab with metaKey', () => {
const location: LocationDescriptorObject = {
pathname: '/traces',
search: '?service=backend',
hash: '#span-details',
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/traces?service=backend#span-details',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should open location object URL in new tab with ctrlKey', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?status=firing',
};
const event = new MouseEvent('click', { ctrlKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/alerts?status=firing',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle location object with normal navigation', () => {
const location: LocationDescriptorObject = {
pathname: '/dashboard',
search: '?tab=overview',
};
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'home' };
history.push(location, event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith(location, state);
});
it('should handle complex location object with all properties in new tab', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/api/v1/traces',
search: '?limit=100&offset=0&service=auth',
hash: '#result-section',
state: { from: 'explorer' }, // State is ignored in new tab
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/api/v1/traces?limit=100&offset=0&service=auth#result-section',
'_blank',
);
});
});
});
describe('history.push() - Edge Cases and Error Scenarios', () => {
it('should handle undefined as second parameter', () => {
history.push('/dashboard', undefined);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle null as second parameter', () => {
history.push('/logs', null);
expect(originalPush).toHaveBeenCalledWith('/logs', null);
});
it('should handle empty string path', () => {
history.push('');
expect(originalPush).toHaveBeenCalledWith('', undefined);
});
it('should handle root path', () => {
history.push('/');
expect(originalPush).toHaveBeenCalledWith('/', undefined);
});
it('should handle relative paths', () => {
history.push('../parent');
expect(originalPush).toHaveBeenCalledWith('../parent', undefined);
});
it('should handle special characters in path', () => {
const specialPath = '/path/with spaces/and#special?chars=@$%';
history.push(specialPath);
expect(originalPush).toHaveBeenCalledWith(specialPath, undefined);
});
it('should handle location object with undefined values', () => {
const location: LocationDescriptorObject = {
pathname: undefined,
search: undefined,
hash: undefined,
state: undefined,
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle very long URLs', () => {
const longParam = 'x'.repeat(1000);
const longUrl = `/path?param=${longParam}`;
history.push(longUrl);
expect(originalPush).toHaveBeenCalledWith(longUrl, undefined);
});
it('should handle object that looks like an event but isnt', () => {
const fakeEvent = {
metaKey: 'not-a-boolean', // Invalid type but still truthy values
ctrlKey: 'not-a-boolean',
};
history.push('/dashboard', fakeEvent as any);
// The implementation checks if metaKey/ctrlKey exist and are truthy values
// Since these are truthy strings, it will be treated as an event
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle event-like object with falsy values', () => {
const fakeEventFalsy = {
metaKey: false,
ctrlKey: false,
};
history.push('/dashboard', fakeEventFalsy as any);
// The object is detected as an event (has metaKey/ctrlKey properties)
// but since both are false, it doesn't open in new tab
// When treated as event, third param (state) is undefined
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle partial event-like objects', () => {
const partialEvent = { metaKey: true }; // Has metaKey but not instanceof MouseEvent
history.push('/logs', partialEvent as any);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle object without event properties as state', () => {
const regularObject = {
someData: 'value',
anotherProp: 123,
// No metaKey or ctrlKey properties
};
history.push('/page', regularObject);
// Object without metaKey/ctrlKey is treated as state, not event
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/page', regularObject);
});
});
describe('history.push() - State Handling', () => {
it('should pass state with string path', () => {
const complexState: TestState = {
from: 'dashboard',
user: { id: 123, name: 'Test User', email: 'test@example.com' },
timestamp: Date.now(),
};
history.push('/profile', complexState);
expect(originalPush).toHaveBeenCalledWith('/profile', complexState);
});
it('should handle state with location object', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/settings',
state: { from: 'profile' },
};
const additionalState: TestState = { timestamp: Date.now() };
history.push(location, additionalState);
expect(originalPush).toHaveBeenCalledWith(location, additionalState);
});
it('should handle state with event and string path', () => {
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'nav' };
history.push('/dashboard', event, state);
expect(originalPush).toHaveBeenCalledWith('/dashboard', state);
});
it('should handle state with event and location object', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
};
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'sidebar' };
history.push(location, event, state);
expect(originalPush).toHaveBeenCalledWith(location, state);
});
});
describe('Other History Methods', () => {
it('should have working replace method', () => {
// replace should exist and be callable
expect(history.replace).toBeDefined();
expect(typeof history.replace).toBe('function');
history.replace('/new-path');
const mockReplace = (history as any).replace as jest.MockedFunction<
typeof history.replace
>;
expect(mockReplace).toHaveBeenCalledWith('/new-path');
});
it('should have working go method', () => {
expect(history.go).toBeDefined();
expect(typeof history.go).toBe('function');
history.go(-2);
const mockGo = (history as any).go as jest.MockedFunction<typeof history.go>;
expect(mockGo).toHaveBeenCalledWith(-2);
});
it('should have working goBack method', () => {
expect(history.goBack).toBeDefined();
expect(typeof history.goBack).toBe('function');
history.goBack();
const mockGoBack = (history as any).goBack as jest.MockedFunction<
typeof history.goBack
>;
expect(mockGoBack).toHaveBeenCalled();
});
it('should have working goForward method', () => {
expect(history.goForward).toBeDefined();
expect(typeof history.goForward).toBe('function');
history.goForward();
const mockGoForward = (history as any).goForward as jest.MockedFunction<
typeof history.goForward
>;
expect(mockGoForward).toHaveBeenCalled();
});
it('should have working block method', () => {
expect(history.block).toBeDefined();
expect(typeof history.block).toBe('function');
const unblock = history.block('Are you sure?');
expect(typeof unblock).toBe('function');
const mockBlock = (history as any).block as jest.MockedFunction<
typeof history.block
>;
expect(mockBlock).toHaveBeenCalledWith('Are you sure?');
});
it('should have working listen method', () => {
expect(history.listen).toBeDefined();
expect(typeof history.listen).toBe('function');
const listener = jest.fn();
const unlisten = history.listen(listener);
expect(typeof unlisten).toBe('function');
const mockListen = (history as any).listen as jest.MockedFunction<
typeof history.listen
>;
expect(mockListen).toHaveBeenCalledWith(listener);
});
it('should have working createHref method', () => {
expect(history.createHref).toBeDefined();
expect(typeof history.createHref).toBe('function');
const location: LocationDescriptorObject = {
pathname: '/test',
search: '?query=value',
};
const href = history.createHref(location);
expect(href).toBe('/test?query=value');
});
it('should have accessible location property', () => {
expect(history.location).toBeDefined();
expect(history.location.pathname).toBe('/current-path');
expect(history.location.search).toBe('?existing=param');
expect(history.location.hash).toBe('#section');
expect(history.location.state).toEqual({ existing: 'state' });
});
it('should have accessible length property', () => {
expect(history.length).toBeDefined();
expect(history.length).toBe(2);
});
it('should have accessible action property', () => {
expect(history.action).toBeDefined();
expect(history.action).toBe('PUSH');
});
});
});

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