Compare commits

...

58 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
Yunus M
c919102fee chore: update on headers package version (#9376) 2025-10-20 17:04:37 +05:30
Vishal Sharma
765370752c Add Mastra doc and update onboarding configuration (#9372) 2025-10-19 23:59:10 +05:30
Shaheer Kochai
db5c102f14 fix: make trace details attributes search visible by default (#9247)
* fix: make the trace details attributes search visible by default

* fix: make the attributes search focusable when the search button is toggled, not by default

* fix: don't display search in events tab of span details side drawer in empty state

* chore: write tests

* chore: mock signozhq/button

* chore: minor state rename
2025-10-19 15:35:23 +00:00
Shaheer Kochai
8a0c5bc3c8 fix: trace details bugfixes and improvements (#9155)
* fix: change the date format for span hover card

* fix: don't display search in events tab of span details side drawer in empty state

* fix: unify the handle span click, to set spanId on clicking span overview column as well

* Revert "fix: don't display search in events tab of span details side drawer in empty state"

This reverts commit c8f56aadd0.

* chore: add 0.5 seconds delay before showing span popover in trace details

* chore: write tests

* chore: update tests

* fix: fix the failing test

* chore: remove the mocks for utility functions
2025-10-19 14:42:13 +00:00
Pandey
a28ccffd01 fix(instrumentation): initialize noop if metrics is not enabled (#9365) 2025-10-18 16:44:01 +05:30
Priyanshu Shrivastava
84adb3e163 chore(codeowners): update username (#9359) 2025-10-17 13:00:47 +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
212 changed files with 7721 additions and 1204 deletions

16
.github/CODEOWNERS vendored
View File

@@ -48,13 +48,13 @@
.github @SigNoz/devops
# Scaffold Owners
/pkg/config/ @grandwizard28
/pkg/errors/ @grandwizard28
/pkg/factory/ @grandwizard28
/pkg/types/ @grandwizard28
/pkg/valuer/ @grandwizard28
/cmd/ @grandwizard28
.golangci.yml @grandwizard28
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
# Zeus Owners
/pkg/zeus/ @vikrantgupta25
@@ -84,4 +84,4 @@
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25 @grandwizard28
/pkg/authz/ @vikrantgupta25 @therealpandey

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

@@ -236,7 +236,7 @@ Not sure how to get started? Just ping us on `#contributing` in our [slack commu
#### DevOps
- [Prashant Shahi](https://github.com/prashant-shahi)
- [Vibhu Pandey](https://github.com/grandwizard28)
- [Vibhu Pandey](https://github.com/therealpandey)
<br /><br />

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

@@ -279,6 +279,7 @@
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.4",
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.2",
"on-headers": "^1.1.0"
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
<path d="M0 0 C1.01790619 0.00215515 2.03581238 0.0043103 3.08456421 0.00653076 C17.69363523 0.05620146 32.02575853 0.30709809 46.375 3.3125 C47.34985352 3.51117676 48.32470703 3.70985352 49.32910156 3.91455078 C97.7109774 14.03517194 141.69928269 35.9516877 177.375 70.3125 C178.3640332 71.26068604 178.3640332 71.26068604 179.37304688 72.22802734 C188.86130305 81.37247479 197.6012044 90.66984009 205.375 101.3125 C206.23306152 102.46355017 207.09113451 103.61459177 207.94921875 104.765625 C213.98736829 112.95680614 219.34595521 121.47054105 224.375 130.3125 C224.73948242 130.94430176 225.10396484 131.57610352 225.47949219 132.22705078 C247.08534998 169.97532572 256.85198095 212.74189997 256.6875 255.9375 C256.68534485 256.95540619 256.6831897 257.97331238 256.68096924 259.02206421 C256.63129854 273.63113523 256.38040191 287.96325853 253.375 302.3125 C253.07698486 303.77478027 253.07698486 303.77478027 252.77294922 305.26660156 C242.65232806 353.6484774 220.7358123 397.63678269 186.375 433.3125 C185.74287598 433.97185547 185.11075195 434.63121094 184.45947266 435.31054688 C175.31502521 444.79880305 166.01765991 453.5387044 155.375 461.3125 C154.22394983 462.17056152 153.07290823 463.02863451 151.921875 463.88671875 C143.73224019 469.92427396 135.22961724 475.30707302 126.375 480.3125 C125.66778809 480.7146875 124.96057617 481.116875 124.23193359 481.53125 C101.09654164 494.5566741 75.92537591 503.30551461 49.9375 508.625 C49.21860596 508.77235596 48.49971191 508.91971191 47.7590332 509.0715332 C33.08339502 511.86090125 18.55831162 512.66142619 3.64770508 512.62817383 C0.70811513 512.6250221 -2.2304558 512.64854926 -5.16992188 512.67382812 C-18.54306192 512.71848737 -31.43916711 511.51301542 -44.625 509.3125 C-46.33055054 509.03031616 -46.33055054 509.03031616 -48.07055664 508.74243164 C-91.33802759 500.97044532 -132.38793216 480.98345309 -165.625 452.3125 C-166.37007813 451.67828125 -167.11515625 451.0440625 -167.8828125 450.390625 C-181.38779833 438.57948485 -194.05918743 425.82889772 -204.625 411.3125 C-205.48149066 410.16255169 -206.33826433 409.01281411 -207.1953125 407.86328125 C-213.23534209 399.67315742 -218.61867614 391.16870382 -223.625 382.3125 C-224.0271875 381.60528809 -224.429375 380.89807617 -224.84375 380.16943359 C-237.8691741 357.03404164 -246.61801461 331.86287591 -251.9375 305.875 C-252.08485596 305.15610596 -252.23221191 304.43721191 -252.3840332 303.6965332 C-255.35789088 288.05024391 -255.99800362 272.57681954 -255.9375 256.6875 C-255.93534485 255.66959381 -255.9331897 254.65168762 -255.93096924 253.60293579 C-255.88129854 238.99386477 -255.63040191 224.66174147 -252.625 210.3125 C-252.42632324 209.33764648 -252.22764648 208.36279297 -252.02294922 207.35839844 C-241.90232806 158.9765226 -219.9858123 114.98821731 -185.625 79.3125 C-184.99287598 78.65314453 -184.36075195 77.99378906 -183.70947266 77.31445312 C-174.56502521 67.82619695 -165.26765991 59.0862956 -154.625 51.3125 C-153.47394983 50.45443848 -152.32290823 49.59636549 -151.171875 48.73828125 C-142.98224019 42.70072604 -134.47961724 37.31792698 -125.625 32.3125 C-124.91778809 31.9103125 -124.21057617 31.508125 -123.48193359 31.09375 C-100.34654164 18.0683259 -75.17537591 9.31948539 -49.1875 4 C-48.46860596 3.85264404 -47.74971191 3.70528809 -47.0090332 3.5534668 C-31.36274391 0.57960912 -15.88931954 -0.06050362 0 0 Z " fill="#FAFAFA" transform="translate(255.625,-0.3125)"/>
<path d="M0 0 C1.01790619 0.00215515 2.03581238 0.0043103 3.08456421 0.00653076 C17.69363523 0.05620146 32.02575853 0.30709809 46.375 3.3125 C47.34985352 3.51117676 48.32470703 3.70985352 49.32910156 3.91455078 C97.7109774 14.03517194 141.69928269 35.9516877 177.375 70.3125 C178.3640332 71.26068604 178.3640332 71.26068604 179.37304688 72.22802734 C188.86130305 81.37247479 197.6012044 90.66984009 205.375 101.3125 C206.23306152 102.46355017 207.09113451 103.61459177 207.94921875 104.765625 C213.98736829 112.95680614 219.34595521 121.47054105 224.375 130.3125 C224.73948242 130.94430176 225.10396484 131.57610352 225.47949219 132.22705078 C247.08534998 169.97532572 256.85198095 212.74189997 256.6875 255.9375 C256.68534485 256.95540619 256.6831897 257.97331238 256.68096924 259.02206421 C256.63129854 273.63113523 256.38040191 287.96325853 253.375 302.3125 C253.07698486 303.77478027 253.07698486 303.77478027 252.77294922 305.26660156 C242.65232806 353.6484774 220.7358123 397.63678269 186.375 433.3125 C185.74287598 433.97185547 185.11075195 434.63121094 184.45947266 435.31054688 C175.31502521 444.79880305 166.01765991 453.5387044 155.375 461.3125 C154.22394983 462.17056152 153.07290823 463.02863451 151.921875 463.88671875 C143.73224019 469.92427396 135.22961724 475.30707302 126.375 480.3125 C125.66778809 480.7146875 124.96057617 481.116875 124.23193359 481.53125 C101.09654164 494.5566741 75.92537591 503.30551461 49.9375 508.625 C49.21860596 508.77235596 48.49971191 508.91971191 47.7590332 509.0715332 C33.08339502 511.86090125 18.55831162 512.66142619 3.64770508 512.62817383 C0.70811513 512.6250221 -2.2304558 512.64854926 -5.16992188 512.67382812 C-18.54306192 512.71848737 -31.43916711 511.51301542 -44.625 509.3125 C-46.33055054 509.03031616 -46.33055054 509.03031616 -48.07055664 508.74243164 C-91.33802759 500.97044532 -132.38793216 480.98345309 -165.625 452.3125 C-166.37007813 451.67828125 -167.11515625 451.0440625 -167.8828125 450.390625 C-181.38779833 438.57948485 -194.05918743 425.82889772 -204.625 411.3125 C-205.48149066 410.16255169 -206.33826433 409.01281411 -207.1953125 407.86328125 C-213.23534209 399.67315742 -218.61867614 391.16870382 -223.625 382.3125 C-224.0271875 381.60528809 -224.429375 380.89807617 -224.84375 380.16943359 C-237.8691741 357.03404164 -246.61801461 331.86287591 -251.9375 305.875 C-252.08485596 305.15610596 -252.23221191 304.43721191 -252.3840332 303.6965332 C-255.35789088 288.05024391 -255.99800362 272.57681954 -255.9375 256.6875 C-255.93534485 255.66959381 -255.9331897 254.65168762 -255.93096924 253.60293579 C-255.88129854 238.99386477 -255.63040191 224.66174147 -252.625 210.3125 C-252.42632324 209.33764648 -252.22764648 208.36279297 -252.02294922 207.35839844 C-241.90232806 158.9765226 -219.9858123 114.98821731 -185.625 79.3125 C-184.99287598 78.65314453 -184.36075195 77.99378906 -183.70947266 77.31445312 C-174.56502521 67.82619695 -165.26765991 59.0862956 -154.625 51.3125 C-153.47394983 50.45443848 -152.32290823 49.59636549 -151.171875 48.73828125 C-142.98224019 42.70072604 -134.47961724 37.31792698 -125.625 32.3125 C-124.91778809 31.9103125 -124.21057617 31.508125 -123.48193359 31.09375 C-100.34654164 18.0683259 -75.17537591 9.31948539 -49.1875 4 C-48.46860596 3.85264404 -47.74971191 3.70528809 -47.0090332 3.5534668 C-31.36274391 0.57960912 -15.88931954 -0.06050362 0 0 Z M-132.625 100.3125 C-133.86636719 101.38757813 -133.86636719 101.38757813 -135.1328125 102.484375 C-165.63578284 129.44938326 -187.86448496 164.37287727 -198.01196289 203.92382812 C-198.4561255 205.654488 -198.91264545 207.38199177 -199.37670898 209.10742188 C-206.39011482 235.81273152 -206.57174532 264.26106754 -201.625 291.3125 C-201.5022168 292.01439453 -201.37943359 292.71628906 -201.25292969 293.43945312 C-191.91867325 346.46214396 -160.92344717 393.42130233 -117.23217773 424.5078125 C-89.1520157 444.03658082 -57.46503366 455.81316968 -23.625 460.3125 C-22.6246875 460.4465625 -21.624375 460.580625 -20.59375 460.71875 C33.68154914 466.46554638 87.79464708 449.50556098 130.05859375 415.375 C172.73532881 380.04170204 197.99757236 331.33192114 204.83984375 276.59375 C205.14411827 273.15919925 205.29014212 269.75985138 205.375 266.3125 C205.40078125 265.47734863 205.4265625 264.64219727 205.453125 263.78173828 C207.08252124 205.19475973 185.21292028 153.59303729 145.375 111.3125 C144.28251953 110.13880859 144.28251953 110.13880859 143.16796875 108.94140625 C136.81651093 102.33070526 129.68927931 96.80369409 122.375 91.3125 C121.54226563 90.68472656 120.70953125 90.05695313 119.8515625 89.41015625 C44.39149597 34.6822923 -62.3071717 39.41419387 -132.625 100.3125 Z " fill="#000000" transform="translate(255.625,-0.3125)"/>
<path d="M0 0 C0.71623535 0.07524902 1.4324707 0.15049805 2.17041016 0.22802734 C11.41582457 1.31558193 17.53905875 5.69555803 24.37939453 11.60302734 C25.9717539 12.97565167 27.58779464 14.31330925 29.21484375 15.64453125 C39.45353655 24.07849539 49.89302189 32.86357336 58.390625 43.0859375 C59.52771819 44.43830561 60.70999313 45.7532854 61.921875 47.0390625 C78.86561041 65.03405157 92.54732646 86.67485183 103 109 C103.45761719 109.94875 103.91523438 110.8975 104.38671875 111.875 C105.65363083 114.56069375 106.84247298 117.26538924 108 120 C108.70576172 121.65902344 108.70576172 121.65902344 109.42578125 123.3515625 C115.29775985 137.75748334 119.44802749 153.51162791 121 169 C121.1393396 170.13598633 121.1393396 170.13598633 121.28149414 171.29492188 C123.04224228 187.01734848 123.04224228 187.01734848 118.6640625 192.58203125 C115.46027156 195.86702306 111.77976561 198.42626256 108 201 C106.70961638 201.94695663 105.4218661 202.89751165 104.13671875 203.8515625 C54.19558639 240.19218217 -10.15836211 250.82403206 -70.52783203 244.24707031 C-72.70701376 244.02928126 -74.87191381 243.88318722 -77.05859375 243.7734375 C-87.15317474 242.98567756 -93.49567628 238.26267641 -101 232 C-101.5775 231.5270752 -102.155 231.05415039 -102.75 230.56689453 C-113.80888326 221.47972622 -125.20747742 211.94987788 -134.3828125 200.92578125 C-136.24364652 198.70986054 -138.21467717 196.61674763 -140.1875 194.5 C-161.40351648 170.90663702 -177.09305866 142.66632254 -188 113 C-188.40734375 111.93007812 -188.8146875 110.86015625 -189.234375 109.7578125 C-194.48827127 95.16814672 -202.71297728 67.59681209 -195.89453125 52.5625 C-194.07223789 50.69071225 -192.19999029 49.41220499 -190 48 C-188.52096885 46.81314803 -187.04236305 45.62573122 -185.57055664 44.42993164 C-163.09739012 26.47222869 -136.79512656 14.12849151 -109 7 C-107.47898826 6.58659334 -105.95816766 6.17248301 -104.4375 5.7578125 C-70.37699256 -3.34276097 -34.91038892 -3.88896444 0 0 Z " fill="#010101" transform="translate(294,134)"/>
<path d="M0 0 C0.86409988 -0.0033284 1.72819977 -0.0066568 2.6184845 -0.01008606 C4.43616205 -0.0151155 6.2538487 -0.01749563 8.0715332 -0.01733398 C10.80761482 -0.01951182 13.54328676 -0.03768671 16.27929688 -0.05664062 C26.27760684 -0.08904371 35.78646484 0.35879187 45.64453125 2.16796875 C46.42820068 2.2980835 47.21187012 2.42819824 48.01928711 2.56225586 C67.48091134 5.91708002 86.4354731 13.58908312 103.64453125 23.16796875 C104.27649414 23.51827148 104.90845703 23.86857422 105.55957031 24.22949219 C119.56180482 32.08853758 131.62289424 41.70235966 143.20019531 52.78662109 C144.48254743 54.01304914 145.77750786 55.22626362 147.07421875 56.4375 C160.46125565 69.27666884 171.07943638 85.21359379 179.83203125 101.48046875 C180.23115723 102.21628174 180.6302832 102.95209473 181.04150391 103.71020508 C181.97584067 105.49613991 182.81943883 107.32900956 183.64453125 109.16796875 C183.31453125 109.82796875 182.98453125 110.48796875 182.64453125 111.16796875 C181.77828125 110.23984375 180.91203125 109.31171875 180.01953125 108.35546875 C151.77719444 80.22655498 111.24847041 62.18954649 72.55981445 54.76806641 C66.66500207 53.55440703 62.13035714 50.73600667 57.08203125 47.54296875 C55.29699984 46.44819083 53.51053116 45.35575303 51.72265625 44.265625 C50.85318359 43.73340332 49.98371094 43.20118164 49.08789062 42.65283203 C16.63913044 22.93327173 -19.64835388 13.71677996 -57.35546875 13.16796875 C-51.06422023 6.87672023 -37.42242063 5.35193024 -28.98046875 3.41796875 C-28.06620117 3.20744873 -27.15193359 2.99692871 -26.20996094 2.7800293 C-17.46378901 0.84110933 -8.95226967 0.0123532 0 0 Z " fill="#030303" transform="translate(244.35546875,67.83203125)"/>
<path d="M0 0 C1.3921875 1.2065625 1.3921875 1.2065625 2.8125 2.4375 C17.70081221 15.15211863 32.63519217 25.94043322 50 35 C50.65210449 35.34772461 51.30420898 35.69544922 51.97607422 36.05371094 C68.61826436 44.84435504 87.93410811 51.6622374 106.5 54.8125 C117.0495917 56.69262525 125.60341083 62.82215102 134.53027344 68.45361328 C166.82331519 88.67347552 203.35131846 97.02260308 241 98 C234.72437292 104.27562708 221.11199545 105.81088155 212.6875 107.75 C211.78161133 107.96052002 210.87572266 108.17104004 209.94238281 108.38793945 C198.42674064 110.9501174 187.15547289 111.18641307 175.40698242 111.18530273 C172.83560399 111.18748043 170.2646618 111.20566475 167.69335938 111.22460938 C157.60706378 111.25933256 147.96699734 110.68684053 138 109 C137.17717529 108.86456787 136.35435059 108.72913574 135.5065918 108.58959961 C99.40933725 102.18890888 66.71597085 83.54843429 40.46044922 58.39746094 C39.17158965 57.16418909 37.86994193 55.94431172 36.56640625 54.7265625 C21.17388217 39.96510447 9.14707063 21.15582183 0 2 C0 1.34 0 0.68 0 0 Z " fill="#030303" transform="translate(84,333)"/>
<path d="M0 0 C2.99943905 1.34572887 5.08076179 2.83537101 7.39160156 5.16455078 C8.0319635 5.80506378 8.67232544 6.44557678 9.33209229 7.10549927 C10.01140808 7.79513763 10.69072388 8.484776 11.390625 9.1953125 C12.09523865 9.90275604 12.79985229 10.61019958 13.52581787 11.33908081 C15.7719539 13.59639862 18.01103009 15.86057633 20.25 18.125 C21.77297717 19.65800953 23.29640939 21.19056715 24.8203125 22.72265625 C28.55216815 26.47654573 32.27825351 30.23608914 36 34 C40.8277897 29.9845347 45.3437641 25.77173317 49.73828125 21.2890625 C50.98229394 20.03242542 52.22643824 18.77591862 53.47070312 17.51953125 C55.40594863 15.55930686 57.33953935 13.59749882 59.27075195 11.63330078 C61.15145313 9.72257458 63.03818159 7.81797036 64.92578125 5.9140625 C65.50596512 5.32041901 66.08614899 4.72677551 66.68391418 4.11514282 C68.08510986 2.70514466 69.53810801 1.34696546 71 0 C73 0 73 0 74.62109375 1.609375 C75.22050781 2.31578125 75.81992187 3.0221875 76.4375 3.75 C77.03433594 4.44609375 77.63117188 5.1421875 78.24609375 5.859375 C79.88667532 7.86168836 81.44684965 9.92913287 83 12 C74.42 20.58 65.84 29.16 57 38 C86.7 38.495 86.7 38.495 117 39 C117 44.28 117 49.56 117 55 C97.53 55.33 78.06 55.66 58 56 C66.25 64.25 74.5 72.5 83 81 C81.16614077 84.66771845 79.74937171 86.46834996 76.875 89.25 C76.15054688 89.95640625 75.42609375 90.6628125 74.6796875 91.390625 C74.12539063 91.92171875 73.57109375 92.4528125 73 93 C70.00056095 91.65427113 67.91923821 90.16462899 65.60839844 87.83544922 C64.64785553 86.87467972 64.64785553 86.87467972 63.66790771 85.89450073 C62.98859192 85.20486237 62.30927612 84.515224 61.609375 83.8046875 C60.90476135 83.09724396 60.20014771 82.38980042 59.47418213 81.66091919 C57.2280461 79.40360138 54.98896991 77.13942367 52.75 74.875 C51.22702283 73.34199047 49.70359061 71.80943285 48.1796875 70.27734375 C44.44783185 66.52345427 40.72174649 62.76391086 37 59 C32.16701194 63.02748053 27.61889996 67.24379413 23.19140625 71.7109375 C22.57982162 72.32393707 21.968237 72.93693665 21.33811951 73.56851196 C19.41052441 75.50164564 17.48646072 77.43824987 15.5625 79.375 C14.24567596 80.69684699 12.92862074 82.01846372 11.61132812 83.33984375 C8.40487627 86.55724299 5.20158371 89.77775681 2 93 C-1.41059403 91.39464713 -3.61792484 89.59481099 -6.25 86.875 C-6.95640625 86.15054688 -7.6628125 85.42609375 -8.390625 84.6796875 C-8.92171875 84.12539063 -9.4528125 83.57109375 -10 83 C-8.41570736 79.09154201 -5.73402218 76.47642804 -2.7734375 73.578125 C-1.84450684 72.65418945 -0.91557617 71.73025391 0.04150391 70.77832031 C1.22405762 69.61397461 2.40661133 68.44962891 3.625 67.25 C7.37875 63.5375 11.1325 59.825 15 56 C-14.205 55.505 -14.205 55.505 -44 55 C-44 49.72 -44 44.44 -44 39 C-24.2 38.67 -4.4 38.34 16 38 C7.42 29.42 -1.16 20.84 -10 12 C-8.02 9.69 -6.04 7.38 -4 5 C-2.65807132 3.34024611 -1.32090525 1.67653359 0 0 Z " fill="#FBFBFB" transform="translate(220,210)"/>
<path d="M0 0 C0.66 0 1.32 0 2 0 C2.10320557 0.85489014 2.10320557 0.85489014 2.20849609 1.72705078 C7.84197412 45.87779718 28.1939551 86.6195367 56 121 C56.42941895 121.53383301 56.85883789 122.06766602 57.30126953 122.61767578 C66.40690991 133.83981661 76.68709229 143.89775823 87 154 C85 155 85 155 83.08325195 154.41430664 C82.30941162 154.08293701 81.53557129 153.75156738 80.73828125 153.41015625 C79.44216919 152.85835693 79.44216919 152.85835693 78.11987305 152.29541016 C77.19343994 151.8885498 76.26700684 151.48168945 75.3125 151.0625 C74.36221924 150.64790527 73.41193848 150.23331055 72.43286133 149.80615234 C39.14146569 135.09423719 7.08477782 112.00144189 -6.8203125 77.16015625 C-7.20960937 76.11730469 -7.59890625 75.07445312 -8 74 C-8.32742187 73.13246094 -8.65484375 72.26492188 -8.9921875 71.37109375 C-16.36268399 50.62841076 -12.86150198 26.97666813 -3.75 7.5625 C-2.52169606 5.02980009 -1.26750366 2.51347207 0 0 Z " fill="#040404" transform="translate(80,210)"/>
<path d="M0 0 C1.35764345 4.07293036 -0.06910614 6.67744348 -1.5625 10.5 C-1.97749756 11.57531982 -1.97749756 11.57531982 -2.40087891 12.67236328 C-6.47155806 22.9772079 -11.68698526 31.65423127 -19 40 C-19.59296875 40.68835938 -20.1859375 41.37671875 -20.796875 42.0859375 C-35.31857853 57.78675371 -58.56153437 66.83592642 -79.57324219 68.23852539 C-83.59327999 68.37727956 -87.60297433 68.43877365 -91.625 68.4375 C-92.70432297 68.43910126 -92.70432297 68.43910126 -93.80545044 68.44073486 C-120.19044633 68.3846361 -144.22754501 60.94698881 -168 50 C-168 49.67 -168 49.34 -168 49 C-167.23671387 48.98018066 -166.47342773 48.96036133 -165.68701172 48.93994141 C-135.41473945 48.1185831 -105.01412697 46.60137763 -75.89160156 37.58056641 C-74.17234947 37.05289714 -72.4420738 36.56148633 -70.7109375 36.07421875 C-45.39043035 28.79857935 -20.43492118 16.73681044 0 0 Z " fill="#040404" transform="translate(411,347)"/>
<path d="M0 0 C36.3336157 11.02984762 71.78280942 35.12814008 90.01855469 69.06640625 C96.50040978 81.83662823 99.34867561 93.65420512 99.25 107.9375 C99.26160156 109.61102539 99.26160156 109.61102539 99.2734375 111.31835938 C99.25018103 121.24305766 97.70881388 129.9522873 94.125 139.1875 C93.88136719 139.84685547 93.63773437 140.50621094 93.38671875 141.18554688 C91.65503117 145.7390777 89.50393006 149.82136348 87 154 C86.34 153.67 85.68 153.34 85 153 C84.50097656 151.18432617 84.50097656 151.18432617 84.140625 148.79296875 C83.99786133 147.90649658 83.85509766 147.02002441 83.70800781 146.10668945 C83.55686523 145.14335693 83.40572266 144.18002441 83.25 143.1875 C79.16249798 119.24447032 70.31517306 97.3963538 59 76 C58.48832275 75.02450195 58.48832275 75.02450195 57.96630859 74.02929688 C47.301887 53.8627614 33.39273041 35.47640491 17.7265625 18.9375 C15.86858655 16.97059803 14.11773128 14.95405655 12.41015625 12.85546875 C10.3273051 10.38777963 8.28787438 8.36653406 5.8125 6.3125 C3.31942756 4.23891203 1.82972289 2.74458434 0 0 Z " fill="#040404" transform="translate(345,147)"/>
<path d="M0 0 C1.26231445 0.00491455 2.52462891 0.0098291 3.82519531 0.01489258 C24.5153613 0.27226578 42.86797287 5.22314233 62.125 12.4375 C63.02718262 12.77418701 63.92936523 13.11087402 64.85888672 13.45776367 C66.12949951 13.94660034 66.12949951 13.94660034 67.42578125 14.4453125 C68.55173706 14.87674561 68.55173706 14.87674561 69.70043945 15.31689453 C71.99402845 16.3980708 73.83827422 17.73010316 75.8125 19.3125 C74.90935059 19.3218457 74.00620117 19.33119141 73.07568359 19.34082031 C46.66373358 19.68118909 20.60587941 21.22322233 -5.11425781 27.79150391 C-7.27539495 28.33458759 -9.44421442 28.83079049 -11.6171875 29.32421875 C-39.00925704 35.7635841 -65.52353508 48.25154961 -88.08203125 65.01171875 C-91.1875 67.3125 -91.1875 67.3125 -93.1875 68.3125 C-92.18113049 53.55241391 -82.67422534 39.13895373 -73.1875 28.3125 C-72.59453125 27.62414062 -72.0015625 26.93578125 -71.390625 26.2265625 C-53.12979089 6.48301109 -25.93856094 -0.12102791 0 0 Z " fill="#030303" transform="translate(193.1875,96.6875)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.34 1.66 1.68 2.32 1 3 C0.67 2.01 0.34 1.02 0 0 Z M-9 10 C-9 13 -9 13 -9 13 Z " fill="#E8E8E8" transform="translate(220,210)"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

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

@@ -87,6 +87,7 @@ function SpanHoverCard({
</Typography.Text>
</div>
}
mouseEnterDelay={0.5}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"

View File

@@ -0,0 +1,258 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock dayjs completely for testing
jest.mock('dayjs', () => {
const mockDayjs = jest.fn(() => ({
format: jest.fn((formatString: string) => {
if (formatString === 'D/M/YY - HH:mm:ss') {
return '15/3/24 - 14:23:45';
}
return 'mock-date';
}),
}));
Object.assign(mockDayjs, {
extend: jest.fn(),
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.5 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(500);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(200); // Only 0.2 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays new date format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the new date format is displayed
expect(screen.getByText('15/3/24 - 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

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

@@ -29,7 +29,7 @@ export const DATE_TIME_FORMATS = {
DATE_SHORT: 'MM/DD',
YEAR_SHORT: 'YY',
YEAR_MONTH: 'YY-MM',
SPAN_POPOVER_DATE: 'M/D/YY - HH:mm',
SPAN_POPOVER_DATE: 'D/M/YY - HH:mm:ss',
// Month name formats
MONTH_DATE_FULL: 'MMMM DD, YYYY',

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

@@ -97,7 +97,10 @@
"opentelemetry",
"migration guide",
"migrate",
"migration"
"migration",
"otel collector",
"opentelemetry collector",
"otlp"
],
"imgUrl": "/Logos/opentelemetry.svg",
"link": "https://signoz.io/docs/migration/migrate-from-opentelemetry-to-signoz/"
@@ -108,7 +111,7 @@
"label": "Java",
"id": "java",
"imgUrl": "/Logos/java.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -300,7 +303,7 @@
"dataSource": "python",
"label": "Python",
"imgUrl": "/Logos/python.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -465,7 +468,7 @@
{
"dataSource": "javascript",
"label": "JavaScript",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -712,7 +715,7 @@
"dataSource": "golang",
"label": "Golang",
"imgUrl": "/Logos/go.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -776,7 +779,7 @@
"dataSource": "php",
"label": "PHP",
"imgUrl": "/Logos/php.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -799,7 +802,6 @@
"php zipkin tracing",
"otel php exporter",
"php http instrumentation",
"php prometheus exporter",
"php-fpm monitoring",
"php on kubernetes",
"php in containers",
@@ -844,7 +846,7 @@
"dataSource": "dotnet",
"label": ".NET",
"imgUrl": "/Logos/dotnet.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -876,7 +878,6 @@
".net kubernetes",
"dotnet in containers",
"opentelemetry exporter .net",
".net prometheus exporter",
"c# opentelemetry",
"monitor c# app",
"entity framework telemetry",
@@ -917,7 +918,7 @@
"dataSource": "ruby-on-rails",
"label": "Ruby on Rails",
"imgUrl": "/Logos/ruby-on-rails.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -982,7 +983,7 @@
"dataSource": "elixir",
"label": "Elixir",
"imgUrl": "/Logos/elixir.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1041,7 +1042,7 @@
"dataSource": "rust",
"label": "Rust",
"imgUrl": "/Logos/rust.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1094,7 +1095,7 @@
"dataSource": "swift",
"label": "Swift",
"imgUrl": "/Logos/swift.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1125,7 +1126,6 @@
"urlsession instrumentation",
"ios sdk opentelemetry",
"apple ecosystem observability",
"swift prometheus exporter",
"ios otlp exporter",
"ios telemetry sdk"
],
@@ -1161,7 +1161,7 @@
"dataSource": "opentelemetry-cpp",
"label": "C++",
"imgUrl": "/Logos/cpp.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1203,7 +1203,7 @@
"dataSource": "nginx-tracing",
"label": "Nginx - Tracing",
"imgUrl": "/Logos/nginx.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": ["tracing", "nginx server", "nginx proxy", "nginx"],
"id": "nginx-tracing",
@@ -1213,7 +1213,7 @@
"dataSource": "opentelemetry-wordpress",
"label": "WordPress",
"imgUrl": "/Logos/wordpress.svg",
"tags": ["apm"],
"tags": ["apm/traces"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1239,7 +1239,7 @@
"dataSource": "opentelemetry-cloudflare",
"label": "Cloudflare",
"imgUrl": "/Logos/cloudflare.svg",
"tags": ["apm"],
"tags": ["apm/traces", "logs"],
"module": "apm",
"relatedSearchKeywords": [
"apm",
@@ -1556,7 +1556,7 @@
},
{
"dataSource": "from-log-file",
"label": "From Log File",
"label": "Collect Logs From Log File",
"imgUrl": "/Logos/from-log-file.svg",
"tags": ["logs"],
"module": "logs",
@@ -2821,6 +2821,20 @@
],
"link": "https://signoz.io/docs/vercel-ai-sdk-monitoring/"
},
{
"dataSource": "mastra-monitoring",
"label": "Mastra",
"imgUrl": "/Logos/mastra.svg",
"tags": ["LLM Monitoring"],
"module": "apm",
"relatedSearchKeywords": [
"mastra monitoring",
"mastra observability",
"mastra agent",
"traces"
],
"link": "https://signoz.io/docs/mastra-monitoring/"
},
{
"dataSource": "http-endpoints-monitoring",
"label": "HTTP Endpoints Monitoring",

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

@@ -20,10 +20,11 @@ interface AttributeRecord {
interface IAttributesProps {
span: Span;
isSearchVisible: boolean;
shouldFocusOnToggle?: boolean;
}
function Attributes(props: IAttributesProps): JSX.Element {
const { span, isSearchVisible } = props;
const { span, isSearchVisible, shouldFocusOnToggle } = props;
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const flattenSpanData: Record<string, string> = useMemo(
@@ -69,7 +70,7 @@ function Attributes(props: IAttributesProps): JSX.Element {
{isSearchVisible &&
(datasource.length > 0 || fieldSearchInput.length > 0) && (
<Input
autoFocus
autoFocus={shouldFocusOnToggle}
placeholder="Search for attribute..."
className="search-input"
value={fieldSearchInput}
@@ -118,4 +119,8 @@ function Attributes(props: IAttributesProps): JSX.Element {
);
}
Attributes.defaultProps = {
shouldFocusOnToggle: false,
};
export default Attributes;

View File

@@ -41,7 +41,7 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
</div>
)}
<div className="events-container">
{isSearchVisible && (
{isSearchVisible && events.length > 0 && (
<Input
autoFocus
placeholder="Search for events..."

View File

@@ -35,7 +35,10 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
traceEndTime,
} = props;
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
const [shouldAutoFocusSearch, setShouldAutoFocusSearch] = useState<boolean>(
false,
);
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
false,
);
@@ -73,7 +76,13 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Button>
),
key: 'attributes',
children: <Attributes span={span} isSearchVisible={isSearchVisible} />,
children: (
<Attributes
span={span}
isSearchVisible={isSearchVisible}
shouldFocusOnToggle={shouldAutoFocusSearch}
/>
),
},
{
label: (
@@ -246,7 +255,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
size={14}
className="search-icon"
cursor="pointer"
onClick={(): void => setIsSearchVisible((prev) => !prev)}
onClick={(): void => {
setIsSearchVisible((prev) => {
const newValue = !prev;
// Only set toggle flag when search becomes visible
if (newValue) {
setShouldAutoFocusSearch(true);
}
return newValue;
});
}}
/>
}
/>

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,
},
],

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