Compare commits

...

59 Commits

Author SHA1 Message Date
Tushar Vats
509a1cfb85 fix: added constants to improve readability 2025-11-18 00:03:24 +05:30
Tushar Vats
fd118d386a fix: handle conflicting attributes 2025-11-17 20:54:50 +05:30
Abhi kumar
8752022cef fix: updated dashboard panel colors for better contrast ratio (#9500)
* fix: updated dashboard panel colors for better contrast ratio

* chore: preetier fix

* feat: added changes for the tooltip to follow cursor
2025-11-06 17:17:33 +05:30
Aditya Singh
c7e4a9c45d Fix: uplot dense points selection (#9469)
* feat: fix uplot focused series logic selection

* fix: stop propogation only if drilldown enabled

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-11-06 11:14:02 +00:00
primus-bot[bot]
bf92c92204 chore(release): bump to v0.100.1 (#9499)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-11-06 13:22:09 +05:30
Srikanth Chekuri
bd63633be7 fix: do not format for non aggregation columns (#9492) 2025-11-05 19:24:56 +05:30
Nikhil Mantri
1158e1199b Fix: filter with time in span scope condition builder (#9426) 2025-11-05 13:11:36 +05:30
primus-bot[bot]
0a60c49314 chore(release): bump to v0.100.0 (#9488)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2025-11-05 12:06:42 +05:30
Ekansh Gupta
c25e3beb81 feat: changed descirption of span percentile calculation (#9487) 2025-11-05 06:23:24 +00:00
SagarRajput-7
c9e0f2b9ca fix: removed cleanup variable url function to avoid url reseting (#9449) 2025-11-05 00:33:11 +05:30
Abhi kumar
6d831849c1 perf: optimize tooltip plugin with caching, memoization, and improved… (#9421)
* perf: optimize tooltip plugin with caching, memoization, and improved DOM operations

* perf(uplot): optimize tooltip with focused sorting and O(n²) to O(n) reduction

* perf(uplot): optimize threshold rendering with batched canvas operations

* chore: pr review changes

* chore: removed last index check for tooltip generation

* chore: shifted to rendering only one points when hovered

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-11-04 17:34:15 +00:00
aniketio-ctrl
83eeb46f99 feat(sqlstore): added sql formatter for json (#9420)
* chore: added sql formatter for json

* chore: updated json extract columns

* chore: added apend ident

* chore: resolved pr comments

* chore: resolved pr comments

* chore: resolved pr comments

* chore: resolved pr comments

* chore: minor changes

* chore: minor changes

* chore: minor changes

* chore: minor changes

* chore: resolve comments

* chore: added append value

* chore: added append value

* chore: added append value

* chore: added append value

* chore: added append value

* chore: added append value

* chore: added append value

* chore: added append value

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-11-04 22:05:23 +05:30
Shaheer Kochai
287558dc9d refactor: migrate External API's top 10 errors query_range request to v5 (#9476)
* feat: migrate top 10 errors query_range request to v5

* chore: remove unnecessary tests

* chore: improve the top error tests

* fix: send status_message EXISTS only if the toggle is on

* fix: get the count value and simplify the null check

* fix: send has_error = true

* chore: fall back to url.full if url.path doesn't exist

* refactor: address the PR review requested changes

* chore: add test to check if we're sending the correct filters

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-11-04 20:09:32 +05:30
Yunus M
83aad793c2 fix: alignment issues in home page (#9459) 2025-11-04 13:13:01 +05:30
Shaheer Kochai
3eff689c85 fix: fix the issue of save button incorrectly enabled when cold_storage_ttl_days is -1 (#9458)
* fix: logs retention save button enabled when S3 disabled

* test: add test for save button state when S3 is disabled
2025-11-04 12:10:17 +05:30
Yunus M
f5bcd65e2e feat: update styles for percentile value (#9477)
* feat: update styles for percentile value

* feat: reset data on span change, remove unnecessary useMemo
2025-11-03 23:40:02 +05:30
Yunus M
e7772d93af fix: flaky multi ingestion settings test (#9478) 2025-11-03 22:21:13 +05:30
swapnil-signoz
bbf987ebd7 fix: removing duplicate creation of user if user does not exist already (#9455)
* fix: removing duplicate creation of user if user does not exist already

* test: adding api test case

* fix: updated test cases

* fix: remove unnecessary logging and clean up connection params API

* feat: add gateway fixture and integrate with signoz for connection parameters

* feat: add cloudintegrations to the test job matrix in integrationci.yaml

* fix: remove outdated comments from make_http_mocks fixture

* fix: remove deprecated ZeusURL from build configurations
2025-11-03 16:45:08 +05:30
Nityananda Gohain
105c3a3b8c fix: return coldstorage -1 if not set for logs (#9471) 2025-11-03 08:10:53 +00:00
Aditya Singh
c1a4a5b8db Log Details minor ui fix (#9463)
* feat: fix copy btn styles

* feat: minor refactor
2025-11-03 11:59:06 +05:30
aniketio-ctrl
c9591f4341 fix: formatted threshold unit in description and summary (#9350) 2025-11-02 14:27:21 +00:00
Yunus M
fd216fdee1 feat(meter): add ability to query meter data across product modules (#9142)
* feat: enable users to query meter specific data in alerts

* feat: enable metrics / meter selection in alerts and dashboards

* feat: enable setting alerts for ingestion limits

* feat: set where clause when setting alert for ingestion key

* feat(meter): handle the where clause changes

* feat: remove add alert for infinite values

* feat: add unit test cases for set alert flow

* feat: handle inital and onchange state for meter source

* feat: pass thresholds array from ingestion settings

* feat: derive source from value change rather than local state

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-11-02 19:02:56 +05:30
Yunus M
f5bf4293a1 feat: span percentile - UI (#9397)
* feat: show span percentile in span details

* feat: resource attribute selection for span percentile

* feat: wait for 2 secs for the first fetch of span percentile

* feat: add unit test cases for span percentiles

* feat: use style tokens

* feat: remove redundant test assertion

* chore: resolve conflicts

* feat: reset initial wait state on span change

* feat: update payload , endpoint as per new backend changes

* feat: address review comments

* feat: fetch span percentile without specific resource attributes - first time
2025-11-01 22:57:36 +05:30
Shaheer Kochai
155a44a25d feat: add support for infra metrics in trace details (#8911)
* feat: add support for infra metrics in trace details v2

* fix: adjust the empty state if the data source is traces

* refactor: logLineTimestamp prop to timestamp

* chore: write tests for span infra metrics

* chore: return search from useLocation mock

* chore: address review changes to move inline options to useMemo

* refactor: simplify infrastructure metadata extraction logic in SpanRelatedSignals

* refactor: extract infrastructure metadata logic into utility function

* test(infraMetrics): club the similar tests

* fix: improve logs and infra tabs switching assertions

* feat: update Infra option icon to Metrics in SpanDetailsDrawer

* chore: change infra to metrics in span details drawer

* fix: fix the failing tests

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-11-01 21:26:05 +04:30
Vishal Sharma
4b21c9d5f9 feat: add result count to data source search analytics event (#9444) 2025-10-31 12:35:24 +00:00
Yunus M
5ef0a18867 Update CODEOWNERS for frontend code (#9456) 2025-10-31 12:52:37 +05:30
SagarRajput-7
c8266d1aec fix: upgraded the axios resolution to fix vulnerability (#9454) 2025-10-31 11:53:10 +05:30
SagarRajput-7
adfd16ce1b fix: adapt the scroll reset fix in alert and histogram panels (#9322) 2025-10-30 13:31:17 +00:00
SagarRajput-7
6db74a5585 feat: allow custom precision in dashboard panels (#9054) 2025-10-30 18:50:40 +05:30
Pandey
f8e0db0085 chore: bump golangci-lint to the latest version (#9445) 2025-10-30 11:21:35 +00:00
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
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
325 changed files with 13680 additions and 2560 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.7
image: signoz/signoz-schema-migrator:v0.129.8
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.7
image: signoz/signoz-schema-migrator:v0.129.8
container_name: schema-migrator-async
command:
- async

2
.github/CODEOWNERS vendored
View File

@@ -2,7 +2,7 @@
# Owners are automatically requested for review for PRs that changes code
# that they own.
/frontend/ @SigNoz/frontend @YounixM
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv

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 }}
@@ -107,10 +107,8 @@ jobs:
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true

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 }}
@@ -106,10 +106,8 @@ jobs:
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true
@@ -125,4 +123,4 @@ jobs:
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: "{\"deployment\": \"${{ needs.prepare.outputs.deployment }}\", \"signoz_version\": \"${{ needs.prepare.outputs.version }}\"}"
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'

View File

@@ -17,6 +17,7 @@ jobs:
- bootstrap
- passwordauthn
- callbackauthn
- cloudintegrations
- querier
- ttl
sqlstore-provider:

View File

@@ -1,39 +1,63 @@
version: "2"
linters:
default: standard
default: none
enable:
- bodyclose
- depguard
- errcheck
- forbidigo
- govet
- iface
- ineffassign
- misspell
- nilnil
- sloglint
- depguard
- iface
- unparam
- forbidigo
linters-settings:
sloglint:
no-mixed-args: true
kv-only: true
no-global: all
context: all
static-msg: true
msg-style: lowercased
key-naming-case: snake
depguard:
rules:
nozap:
deny:
- pkg: "go.uber.org/zap"
desc: "Do not use zap logger. Use slog instead."
noerrors:
deny:
- pkg: "errors"
desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead."
iface:
enable:
- identical
issues:
exclude-dirs:
- "pkg/query-service"
- "ee/query-service"
- "scripts/"
- unused
settings:
depguard:
rules:
noerrors:
deny:
- pkg: errors
desc: Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead.
nozap:
deny:
- pkg: go.uber.org/zap
desc: Do not use zap logger. Use slog instead.
forbidigo:
forbid:
- pattern: fmt.Errorf
- pattern: ^(fmt\.Print.*|print|println)$
iface:
enable:
- identical
sloglint:
no-mixed-args: true
kv-only: true
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- pkg/query-service
- ee/query-service
- scripts/
- tmp/
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -114,9 +114,9 @@ $(GO_BUILD_ARCHS_COMMUNITY): go-build-community-%: $(TARGET_DIR)
@mkdir -p $(TARGET_DIR)/$(OS)-$*
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)-community"
@if [ $* = "arm64" ]; then \
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
else \
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
fi
@@ -127,9 +127,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE): go-build-enterprise-%: $(TARGET_DIR)
@mkdir -p $(TARGET_DIR)/$(OS)-$*
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)"
@if [ $* = "arm64" ]; then \
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
else \
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
fi
.PHONY: go-build-enterprise-race $(GO_BUILD_ARCHS_ENTERPRISE_RACE)
@@ -139,9 +139,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE_RACE): go-build-enterprise-race-%: $(TARGET_DIR)
@mkdir -p $(TARGET_DIR)/$(OS)-$*
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)"
@if [ $* = "arm64" ]; then \
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
else \
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
fi
##############################################################

View File

@@ -12,12 +12,6 @@ builds:
- id: signoz
binary: bin/signoz
main: ./cmd/community
env:
- CGO_ENABLED=1
- >-
{{- if eq .Os "linux" }}
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }}
{{- end }}
goos:
- linux
- darwin
@@ -36,8 +30,6 @@ builds:
- -X github.com/SigNoz/signoz/pkg/version.time={{ .CommitTimestamp }}
- -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }}
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
- >-
{{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }}
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- timetzdata

View File

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

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.100.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.7
image: signoz/signoz-otel-collector:v0.129.8
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.7
image: signoz/signoz-schema-migrator:v0.129.8
deploy:
restart_policy:
condition: on-failure

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.100.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.7
image: signoz/signoz-otel-collector:v0.129.8
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.7
image: signoz/signoz-schema-migrator:v0.129.8
deploy:
restart_policy:
condition: on-failure

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.100.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
container_name: schema-migrator-async
command:
- async

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.100.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
container_name: schema-migrator-async
command:
- async

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

@@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -77,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
ingestionUrl, signozApiUrl, apiErr := getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url",
@@ -186,48 +185,37 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
return cloudIntegrationUser, nil
}
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, string, *basemodel.ApiError,
) {
url := fmt.Sprintf(
"%s%s",
strings.TrimSuffix(constants.ZeusURL, "/"),
"/v2/deployments/me",
)
// TODO: remove this struct from here
type deploymentResponse struct {
Status string `json:"status"`
Error string `json:"error"`
Data struct {
Name string `json:"name"`
ClusterInfo struct {
Region struct {
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
} `json:"data"`
Name string `json:"name"`
ClusterInfo struct {
Region struct {
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
}
resp, apiErr := requestAndParseResponse[deploymentResponse](
ctx, url, map[string]string{"X-Signoz-Cloud-Api-Key": licenseKey}, nil,
)
if apiErr != nil {
return "", "", basemodel.WrapApiError(
apiErr, "couldn't query for deployment info",
)
}
if resp.Status != "success" {
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
if err != nil {
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: status: %s, error: %s",
resp.Status, resp.Error,
"couldn't query for deployment info: error: %w", err,
))
}
regionDns := resp.Data.ClusterInfo.Region.DNS
deploymentName := resp.Data.Name
resp := new(deploymentResponse)
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
}
regionDns := resp.ClusterInfo.Region.DNS
deploymentName := resp.Name
if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge

View File

@@ -10,9 +10,6 @@ var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")
// this is set via build time variable
var ZeusURL = "https://api.signoz.cloud"
func GetOrDefaultEnv(key string, fallback string) string {
v := os.Getenv(key)
if len(v) == 0 {

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

@@ -0,0 +1,153 @@
package postgressqlstore
import (
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun/schema"
)
type formatter struct {
bunf schema.Formatter
}
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
return &formatter{bunf: schema.NewFormatter(dialect)}
}
func (f *formatter) JSONExtractString(column, path string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "jsonb_typeof("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ')')
return sql
}
func (f *formatter) JSONIsArray(column, path string) []byte {
var sql []byte
sql = append(sql, f.JSONType(column, path)...)
sql = append(sql, " = "...)
sql = schema.Append(f.bunf, sql, "array")
return sql
}
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_array_elements("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []byte(alias)
}
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_array_elements_text("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, append([]byte(alias), "::text"...)
}
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_each("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, append([]byte(alias), ".key"...)
}
func (f *formatter) JSONArrayAgg(expression string) []byte {
var sql []byte
sql = append(sql, "jsonb_agg("...)
sql = append(sql, expression...)
sql = append(sql, ')')
return sql
}
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
var sql []byte
sql = append(sql, "jsonb_build_array("...)
for idx, value := range values {
if idx > 0 {
sql = append(sql, ", "...)
}
sql = schema.Append(f.bunf, sql, value)
}
sql = append(sql, ')')
return sql
}
func (f *formatter) TextToJsonColumn(column string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, "::jsonb"...)
return sql
}
func (f *formatter) convertJSONPathToPostgres(jsonPath string) []byte {
return f.convertJSONPathToPostgresWithMode(jsonPath, true)
}
func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) []byte {
path := strings.TrimPrefix(strings.TrimPrefix(jsonPath, "$"), ".")
if path == "" {
return nil
}
parts := strings.Split(path, ".")
var validParts []string
for _, part := range parts {
if part != "" {
validParts = append(validParts, part)
}
}
if len(validParts) == 0 {
return nil
}
var result []byte
for idx, part := range validParts {
if idx == len(validParts)-1 {
if asText {
result = append(result, "->>"...)
} else {
result = append(result, "->"...)
}
result = schema.Append(f.bunf, result, part)
return result
}
result = append(result, "->"...)
result = schema.Append(f.bunf, result, part)
}
return result
}
func (f *formatter) LowerExpression(expression string) []byte {
var sql []byte
sql = append(sql, "lower("...)
sql = append(sql, expression...)
sql = append(sql, ')')
return sql
}

View File

@@ -0,0 +1,500 @@
package postgressqlstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uptrace/bun/dialect/pgdialect"
)
func TestJSONExtractString(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"->'level1'->'level2'->>'level3'`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `"json_col"`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `"data"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONExtractString(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.field",
expected: `jsonb_typeof("data"->'field')`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.age",
expected: `jsonb_typeof("metadata"->'user'->'age')`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `jsonb_typeof("json_col")`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `jsonb_typeof("data")`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONType(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONIsArray(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.items",
expected: `jsonb_typeof("data"->'items') = 'array'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.tags",
expected: `jsonb_typeof("metadata"->'user'->'tags') = 'array'`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `jsonb_typeof("json_col") = 'array'`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `jsonb_typeof("data") = 'array'`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONIsArray(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONArrayElements(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "elem",
expected: `jsonb_array_elements("data") AS "elem"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "elem",
expected: `jsonb_array_elements("data") AS "elem"`,
},
{
name: "nested path",
column: "metadata",
path: "$.items",
alias: "item",
expected: `jsonb_array_elements("metadata"->'items') AS "item"`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.user.tags",
alias: "tag",
expected: `jsonb_array_elements("json_col"->'user'->'tags') AS "tag"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONArrayOfStrings(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "str",
expected: `jsonb_array_elements_text("data") AS "str"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "str",
expected: `jsonb_array_elements_text("data") AS "str"`,
},
{
name: "nested path",
column: "metadata",
path: "$.strings",
alias: "s",
expected: `jsonb_array_elements_text("metadata"->'strings') AS "s"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONKeys(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "k",
expected: `jsonb_each("data") AS "k"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "k",
expected: `jsonb_each("data") AS "k"`,
},
{
name: "nested path",
column: "metadata",
path: "$.object",
alias: "key",
expected: `jsonb_each("metadata"->'object') AS "key"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONKeys(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONArrayAgg(t *testing.T) {
tests := []struct {
name string
expression string
expected string
}{
{
name: "simple column",
expression: "id",
expected: "jsonb_agg(id)",
},
{
name: "expression with function",
expression: "DISTINCT name",
expected: "jsonb_agg(DISTINCT name)",
},
{
name: "complex expression",
expression: "data->>'field'",
expected: "jsonb_agg(data->>'field')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONArrayAgg(tt.expression))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONArrayLiteral(t *testing.T) {
tests := []struct {
name string
values []string
expected string
}{
{
name: "empty array",
values: []string{},
expected: "jsonb_build_array()",
},
{
name: "single value",
values: []string{"value1"},
expected: "jsonb_build_array('value1')",
},
{
name: "multiple values",
values: []string{"value1", "value2", "value3"},
expected: "jsonb_build_array('value1', 'value2', 'value3')",
},
{
name: "values with special characters",
values: []string{"test", "with space", "with-dash"},
expected: "jsonb_build_array('test', 'with space', 'with-dash')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONArrayLiteral(tt.values...))
assert.Equal(t, tt.expected, got)
})
}
}
func TestConvertJSONPathToPostgresWithMode(t *testing.T) {
tests := []struct {
name string
jsonPath string
asText bool
expected string
}{
{
name: "simple path as text",
jsonPath: "$.field",
asText: true,
expected: "->>'field'",
},
{
name: "simple path as json",
jsonPath: "$.field",
asText: false,
expected: "->'field'",
},
{
name: "nested path as text",
jsonPath: "$.user.name",
asText: true,
expected: "->'user'->>'name'",
},
{
name: "nested path as json",
jsonPath: "$.user.name",
asText: false,
expected: "->'user'->'name'",
},
{
name: "deeply nested as text",
jsonPath: "$.a.b.c.d",
asText: true,
expected: "->'a'->'b'->'c'->>'d'",
},
{
name: "root path",
jsonPath: "$",
asText: true,
expected: "",
},
{
name: "empty path",
jsonPath: "",
asText: true,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New()).(*formatter)
got := string(f.convertJSONPathToPostgresWithMode(tt.jsonPath, tt.asText))
assert.Equal(t, tt.expected, got)
})
}
}
func TestTextToJsonColumn(t *testing.T) {
tests := []struct {
name string
column string
expected string
}{
{
name: "simple column name",
column: "data",
expected: `"data"::jsonb`,
},
{
name: "column with underscore",
column: "user_data",
expected: `"user_data"::jsonb`,
},
{
name: "column with special characters",
column: "json-col",
expected: `"json-col"::jsonb`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.TextToJsonColumn(tt.column))
assert.Equal(t, tt.expected, got)
})
}
}
func TestLowerExpression(t *testing.T) {
tests := []struct {
name string
expr string
expected string
}{
{
name: "simple column name",
expr: "name",
expected: "lower(name)",
},
{
name: "quoted column identifier",
expr: `"column_name"`,
expected: `lower("column_name")`,
},
{
name: "jsonb text extraction",
expr: "data->>'field'",
expected: "lower(data->>'field')",
},
{
name: "nested jsonb extraction",
expr: "metadata->'user'->>'name'",
expected: "lower(metadata->'user'->>'name')",
},
{
name: "jsonb_typeof expression",
expr: "jsonb_typeof(data->'field')",
expected: "lower(jsonb_typeof(data->'field'))",
},
{
name: "string concatenation",
expr: "first_name || ' ' || last_name",
expected: "lower(first_name || ' ' || last_name)",
},
{
name: "CAST expression",
expr: "CAST(value AS TEXT)",
expected: "lower(CAST(value AS TEXT))",
},
{
name: "COALESCE expression",
expr: "COALESCE(name, 'default')",
expected: "lower(COALESCE(name, 'default'))",
},
{
name: "subquery column",
expr: "users.email",
expected: "lower(users.email)",
},
{
name: "quoted identifier with special chars",
expr: `"user-name"`,
expected: `lower("user-name")`,
},
{
name: "jsonb to text cast",
expr: "data::text",
expected: "lower(data::text)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.LowerExpression(tt.expr))
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -15,10 +15,11 @@ import (
)
type provider struct {
settings factory.ScopedProviderSettings
sqldb *sql.DB
bundb *sqlstore.BunDB
dialect *dialect
settings factory.ScopedProviderSettings
sqldb *sql.DB
bundb *sqlstore.BunDB
dialect *dialect
formatter sqlstore.SQLFormatter
}
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@@ -55,11 +56,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb := stdlib.OpenDBFromPool(pool)
pgDialect := pgdialect.New()
bunDB := sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks)
return &provider{
settings: settings,
sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
dialect: new(dialect),
settings: settings,
sqldb: sqldb,
bundb: bunDB,
dialect: new(dialect),
formatter: newFormatter(bunDB.Dialect()),
}, nil
}
@@ -75,6 +79,10 @@ func (provider *provider) Dialect() sqlstore.SQLDialect {
return provider.dialect
}
func (provider *provider) Formatter() sqlstore.SQLFormatter {
return provider.formatter
}
func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
return provider.bundb.BunDBCtx(ctx)
}

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

@@ -69,7 +69,7 @@
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"antlr4": "4.13.2",
"axios": "1.8.2",
"axios": "1.12.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4",
"babel-loader": "9.1.3",
@@ -279,6 +279,7 @@
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.4",
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.2",
"on-headers": "^1.1.0"
}
}

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

@@ -0,0 +1,28 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
GetSpanPercentilesProps,
GetSpanPercentilesResponseDataProps,
} from 'types/api/trace/getSpanPercentiles';
const getSpanPercentiles = async (
props: GetSpanPercentilesProps,
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
try {
const response = await ApiBaseInstance.post('/span_percentile', {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getSpanPercentiles;

View File

@@ -11,7 +11,7 @@ import {
export const getQueryRangeV5 = async (
props: QueryRangePayloadV5,
version: string,
signal: AbortSignal,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {

View File

@@ -0,0 +1,371 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
const testFullPrecisionGetYAxisFormattedValue = (
value: string,
format: string,
): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL);
describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => {
test('large integers and decimals', () => {
expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe(
'250034',
);
expect(
testFullPrecisionGetYAxisFormattedValue('250034897.12345', 'none'),
).toBe('250034897.12345');
expect(
testFullPrecisionGetYAxisFormattedValue('250034897.02354', 'none'),
).toBe('250034897.02354');
expect(testFullPrecisionGetYAxisFormattedValue('9999999.9999', 'none')).toBe(
'9999999.9999',
);
});
test('preserves leading zeros after decimal until first non-zero', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe(
'1.0000234',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.00003', 'none')).toBe(
'0.00003',
);
});
test('trims to three significant decimals and removes trailing zeros', () => {
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'),
).toBe('0.000000250034');
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'none')).toBe(
'0.00000025',
);
// Big precision, limiting the javascript precision (~16 digits)
expect(
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'none'),
).toBe('1');
expect(
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'none'),
).toBe('1.005555555595958');
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
'0.000000001',
);
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250000', 'none'),
).toBe('0.00000025');
});
test('whole numbers normalize', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000');
expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe(
'99.5458',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.234567', 'none')).toBe(
'1.234567',
);
expect(testFullPrecisionGetYAxisFormattedValue('99.998', 'none')).toBe(
'99.998',
);
});
test('strip redundant decimal zeros', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe(
'1000',
);
expect(testFullPrecisionGetYAxisFormattedValue('99.500', 'none')).toBe(
'99.5',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1');
});
test('edge values', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0');
expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0');
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞');
expect(testFullPrecisionGetYAxisFormattedValue('-Infinity', 'none')).toBe(
'-∞',
);
expect(testFullPrecisionGetYAxisFormattedValue('invalid', 'none')).toBe(
'NaN',
);
expect(testFullPrecisionGetYAxisFormattedValue('', 'none')).toBe('NaN');
expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN');
});
test('small decimals keep precision as-is', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe(
'0.0001',
);
expect(testFullPrecisionGetYAxisFormattedValue('-0.0001', 'none')).toBe(
'-0.0001',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
'0.000000001',
);
});
test('simple decimals preserved', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1');
expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2');
expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3');
expect(testFullPrecisionGetYAxisFormattedValue('1.0000000001', 'none')).toBe(
'1.0000000001',
);
});
});
describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => {
test('ms', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s');
expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms');
expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min');
expect(testFullPrecisionGetYAxisFormattedValue('295.429', 'ms')).toBe(
'295.429 ms',
);
expect(testFullPrecisionGetYAxisFormattedValue('4353.81', 'ms')).toBe(
'4.35381 s',
);
});
test('s', () => {
expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins');
expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s');
expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour');
});
test('m', () => {
expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours');
expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min');
expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day');
});
test('bytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe(
'1 KiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'bytes')).toBe('512 B');
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'bytes')).toBe(
'1.5 KiB',
);
});
test('mbytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe(
'1 GiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'mbytes')).toBe(
'512 MiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'mbytes')).toBe(
'1.5 GiB',
);
});
test('kbytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe(
'1 MiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'kbytes')).toBe(
'512 KiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'kbytes')).toBe(
'1.5 MiB',
);
});
test('short', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K');
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe(
'1.5 K',
);
expect(testFullPrecisionGetYAxisFormattedValue('999', 'short')).toBe('999');
expect(testFullPrecisionGetYAxisFormattedValue('1000000', 'short')).toBe(
'1 Mil',
);
expect(testFullPrecisionGetYAxisFormattedValue('1555600', 'short')).toBe(
'1.5556 Mil',
);
expect(testFullPrecisionGetYAxisFormattedValue('999999', 'short')).toBe(
'999.999 K',
);
expect(testFullPrecisionGetYAxisFormattedValue('1000000000', 'short')).toBe(
'1 Bil',
);
expect(testFullPrecisionGetYAxisFormattedValue('1500000000', 'short')).toBe(
'1.5 Bil',
);
expect(testFullPrecisionGetYAxisFormattedValue('999999999', 'short')).toBe(
'999.999999 Mil',
);
});
test('percent', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe(
'0.15%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.1234', 'percent')).toBe(
'0.1234%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.123499', 'percent')).toBe(
'0.123499%',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.5', 'percent')).toBe(
'1.5%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'percent')).toBe(
'0.0001%',
);
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000001', 'percent'),
).toBe('1e-9%');
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'percent'),
).toBe('0.000000250034%');
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'percent')).toBe(
'0.00000025%',
);
// Big precision, limiting the javascript precision (~16 digits)
expect(
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'percent'),
).toBe('1%');
expect(
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'),
).toBe('1.005555555595958%');
});
test('ratio', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe(
'0.5 ratio',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.25', 'ratio')).toBe(
'1.25 ratio',
);
expect(testFullPrecisionGetYAxisFormattedValue('2.0', 'ratio')).toBe(
'2 ratio',
);
});
test('temperature units', () => {
expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe(
'25 °C',
);
expect(testFullPrecisionGetYAxisFormattedValue('0', 'celsius')).toBe('0 °C');
expect(testFullPrecisionGetYAxisFormattedValue('-10', 'celsius')).toBe(
'-10 °C',
);
expect(testFullPrecisionGetYAxisFormattedValue('77', 'fahrenheit')).toBe(
'77 °F',
);
expect(testFullPrecisionGetYAxisFormattedValue('32', 'fahrenheit')).toBe(
'32 °F',
);
expect(testFullPrecisionGetYAxisFormattedValue('14', 'fahrenheit')).toBe(
'14 °F',
);
});
test('ms edge cases', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms');
expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s');
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞');
});
test('bytes edge cases', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B');
expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe(
'-1 KiB',
);
});
});
describe('getYAxisFormattedValue - precision option tests', () => {
test('precision 0 drops decimal part', () => {
expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1');
expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345');
expect(getYAxisFormattedValue('0.0000123456', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('1000.000', 'none', 0)).toBe('1000');
expect(getYAxisFormattedValue('0.000000250034', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 0)).toBe('1');
// with unit
expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s');
});
test('precision 1,2,3,4 decimals', () => {
expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2');
expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23');
expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234');
expect(getYAxisFormattedValue('1.2345', 'none', 4)).toBe('1.2345');
expect(getYAxisFormattedValue('0.0000123456', 'none', 1)).toBe('0.00001');
expect(getYAxisFormattedValue('0.0000123456', 'none', 2)).toBe('0.000012');
expect(getYAxisFormattedValue('0.0000123456', 'none', 3)).toBe('0.0000123');
expect(getYAxisFormattedValue('0.0000123456', 'none', 4)).toBe('0.00001234');
expect(getYAxisFormattedValue('1000.000', 'none', 1)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 2)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 3)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 4)).toBe('1000');
expect(getYAxisFormattedValue('0.000000250034', 'none', 1)).toBe('0.0000002');
expect(getYAxisFormattedValue('0.000000250034', 'none', 2)).toBe(
'0.00000025',
); // leading zeros + 2 significant => same trimmed
expect(getYAxisFormattedValue('0.000000250034', 'none', 3)).toBe(
'0.00000025',
);
expect(getYAxisFormattedValue('0.000000250304', 'none', 4)).toBe(
'0.0000002503',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 1)).toBe(
'1.005',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 2)).toBe(
'1.0055',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 3)).toBe(
'1.00555',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 4)).toBe(
'1.005555',
);
// with unit
expect(getYAxisFormattedValue('4353.81', 'ms', 1)).toBe('4.4 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 2)).toBe('4.35 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 3)).toBe('4.354 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 4)).toBe('4.3538 s');
// Percentages
expect(getYAxisFormattedValue('0.123456', 'percent', 2)).toBe('0.12%');
expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation
});
test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
expect(
getYAxisFormattedValue(
'0.00002625429914148441',
'none',
PrecisionOptionsEnum.FULL,
),
).toBe('0.000026254299141');
expect(
getYAxisFormattedValue(
'0.000026254299141484417',
's',
PrecisionOptionsEnum.FULL,
),
).toBe('26254299141484417000000 µs');
expect(
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
).toBe('4.35381 s');
expect(getYAxisFormattedValue('500', 'ms', PrecisionOptionsEnum.FULL)).toBe(
'500 ms',
);
});
});

View File

@@ -1,58 +1,158 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { formattedValueToString, getValueFormat } from '@grafana/data';
import * as Sentry from '@sentry/react';
import { isNaN } from 'lodash-es';
const DEFAULT_SIGNIFICANT_DIGITS = 15;
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const MAX_DECIMALS = 15;
export enum PrecisionOptionsEnum {
ZERO = 0,
ONE = 1,
TWO = 2,
THREE = 3,
FOUR = 4,
FULL = 'full',
}
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
/**
* Formats a number for display, preserving leading zeros after the decimal point
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
* It avoids scientific notation and removes unnecessary trailing zeros.
*
* @example
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
* formatDecimalWithLeadingZeros(5.0); // "5"
*
* @param value The number to format.
* @returns The formatted string.
*/
const formatDecimalWithLeadingZeros = (
value: number,
precision: PrecisionOption,
): string => {
if (value === 0) {
return '0';
}
// Use toLocaleString to get a full decimal representation without scientific notation.
const numStr = value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.');
// If there's no decimal part, the integer part is the result.
if (!decimalPart) {
return integerPart;
}
// Find the index of the first non-zero digit in the decimal part.
const firstNonZeroIndex = decimalPart.search(/[^0]/);
// If the decimal part consists only of zeros, return just the integer part.
if (firstNonZeroIndex === -1) {
return integerPart;
}
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
const significantDigits =
precision === PrecisionOptionsEnum.FULL
? DEFAULT_SIGNIFICANT_DIGITS
: precision;
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
// If precision is 0, we drop the decimal part entirely.
if (precision === 0) {
return integerPart;
}
// Remove any trailing zeros from the result to keep it clean.
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
// Return the integer part, or the integer and decimal parts combined.
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
};
/**
* Formats a Y-axis value based on a given format string.
*
* @param value The string value from the axis.
* @param format The format identifier (e.g. 'none', 'ms', 'bytes', 'short').
* @returns A formatted string ready for display.
*/
export const getYAxisFormattedValue = (
value: string,
format: string,
precision: PrecisionOption = 2, // default precision requested
): string => {
let decimalPrecision: number | undefined;
const parsedValue = getValueFormat(format)(
parseFloat(value),
undefined,
undefined,
undefined,
);
try {
const decimalSplitted = parsedValue.text.split('.');
if (decimalSplitted.length === 1) {
decimalPrecision = 0;
} else {
const decimalDigits = decimalSplitted[1].split('');
decimalPrecision = decimalDigits.length;
let nonZeroCtr = 0;
for (let idx = 0; idx < decimalDigits.length; idx += 1) {
if (decimalDigits[idx] !== '0') {
nonZeroCtr += 1;
if (nonZeroCtr >= 2) {
decimalPrecision = idx + 1;
}
} else if (nonZeroCtr) {
decimalPrecision = idx;
break;
}
}
const numValue = parseFloat(value);
// Handle non-numeric or special values first.
if (isNaN(numValue)) return 'NaN';
if (numValue === Infinity) return '∞';
if (numValue === -Infinity) return '-∞';
const decimalPlaces = value.split('.')[1]?.length || undefined;
// Use custom formatter for the 'none' format honoring precision
if (format === 'none') {
return formatDecimalWithLeadingZeros(numValue, precision);
}
// For all other standard formats, delegate to grafana/data's built-in formatter.
const computeDecimals = (): number | undefined => {
if (precision === PrecisionOptionsEnum.FULL) {
return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
? decimalPlaces
: DEFAULT_SIGNIFICANT_DIGITS;
}
return precision;
};
return formattedValueToString(
getValueFormat(format)(
parseFloat(value),
decimalPrecision,
undefined,
undefined,
),
);
} catch (error) {
console.error(error);
}
return `${parseFloat(value)}`;
};
const fallbackFormat = (): string => {
if (precision === PrecisionOptionsEnum.FULL) return numValue.toString();
if (precision === 0) return Math.round(numValue).toString();
return precision !== undefined
? numValue
.toFixed(precision)
.replace(/(\.[0-9]*[1-9])0+$/, '$1') // trimming zeros
.replace(/\.$/, '')
: numValue.toString();
};
export const getToolTipValue = (value: string, format?: string): string => {
try {
return formattedValueToString(
getValueFormat(format)(parseFloat(value), undefined, undefined, undefined),
);
const formatter = getValueFormat(format);
const formattedValue = formatter(numValue, computeDecimals(), undefined);
if (formattedValue.text && formattedValue.text.includes('.')) {
formattedValue.text = formatDecimalWithLeadingZeros(
parseFloat(formattedValue.text),
precision,
);
}
return formattedValueToString(formattedValue);
} catch (error) {
console.error(error);
Sentry.captureEvent({
message: `Error applying formatter: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
level: 'error',
});
return fallbackFormat();
}
return `${value}`;
};
export const getToolTipValue = (
value: string | number,
format?: string,
precision?: PrecisionOption,
): string =>
getYAxisFormattedValue(value?.toString(), format || 'none', precision);

View File

@@ -10,6 +10,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
@@ -85,11 +89,15 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
dataType: 'string',
type: '',
name: 'body',
displayName: 'Body',
key: LOG_FIELD_BODY_KEY,
},
{
dataType: 'string',
type: '',
name: 'timestamp',
displayName: 'Timestamp',
key: LOG_FIELD_TIMESTAMP_KEY,
},
]}
/>

View File

@@ -60,6 +60,14 @@ function Metrics({
setElement,
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const queryPayloads = useMemo(
() =>
getHostQueryPayload(
@@ -147,6 +155,13 @@ function Metrics({
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
),
[

View File

@@ -132,9 +132,9 @@
justify-content: center;
}
.json-action-btn {
.log-detail-drawer__actions {
display: flex;
gap: 8px;
gap: 4px;
}
}

View File

@@ -319,31 +319,35 @@ function LogDetailInner({
</Radio.Button>
</Radio.Group>
{selectedView === VIEW_TYPES.JSON && (
<div className="json-action-btn">
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={handleJSONCopy}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</div>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
)}
<Tooltip title="Copy Log Link" placement="left" aria-label="Copy Log Link">
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={onLogCopy}
/>
</Tooltip>
</Tooltip>
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
@@ -383,7 +387,8 @@ function LogDetailInner({
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
logLineTimestamp={log.timestamp.toString()}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
</Drawer>

View File

@@ -13,6 +13,10 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData } from 'lib/logs/flatLogData';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useMemo, useState } from 'react';
// interfaces
@@ -42,7 +46,9 @@ interface LogFieldProps {
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
Pick<AddToQueryHOCProps, 'onAddToQuery'> & {
fieldKeyDisplay: string;
};
function LogGeneralField({
fieldKey,
@@ -74,6 +80,7 @@ function LogGeneralField({
function LogSelectedField({
fieldKey = '',
fieldValue = '',
fieldKeyDisplay = '',
onAddToQuery,
fontSize,
}: LogSelectedFieldProps): JSX.Element {
@@ -90,7 +97,7 @@ function LogSelectedField({
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
{fieldKeyDisplay}
</span>
</Typography.Text>
</AddToQueryHOC>
@@ -162,7 +169,7 @@ function ListLogView({
);
const updatedSelecedFields = useMemo(
() => selectedFields.filter((e) => e.name !== 'id'),
() => selectedFields.filter((e) => e.key !== 'id'),
[selectedFields],
);
@@ -170,16 +177,16 @@ function ListLogView({
const timestampValue = useMemo(
() =>
typeof flattenLogData.timestamp === 'string'
typeof flattenLogData[LOG_FIELD_TIMESTAMP_KEY] === 'string'
? formatTimezoneAdjustedTimestamp(
flattenLogData.timestamp,
flattenLogData[LOG_FIELD_TIMESTAMP_KEY],
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
flattenLogData.timestamp / 1e6,
flattenLogData[LOG_FIELD_TIMESTAMP_KEY] / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
),
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
[flattenLogData, formatTimezoneAdjustedTimestamp],
);
const logType = getLogIndicatorType(logData);
@@ -215,10 +222,12 @@ function ListLogView({
/>
<div>
<LogContainer fontSize={fontSize}>
{updatedSelecedFields.some((field) => field.name === 'body') && (
{updatedSelecedFields.some(
(field) => field.key === LOG_FIELD_BODY_KEY,
) && (
<LogGeneralField
fieldKey="Log"
fieldValue={flattenLogData.body}
fieldValue={flattenLogData[LOG_FIELD_BODY_KEY]}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -230,7 +239,9 @@ function ListLogView({
fontSize={fontSize}
/>
)}
{updatedSelecedFields.some((field) => field.name === 'timestamp') && (
{updatedSelecedFields.some(
(field) => field.key === LOG_FIELD_TIMESTAMP_KEY,
) && (
<LogGeneralField
fieldKey="Timestamp"
fieldValue={timestampValue}
@@ -239,13 +250,17 @@ function ListLogView({
)}
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.filter(
(field) =>
![LOG_FIELD_TIMESTAMP_KEY, LOG_FIELD_BODY_KEY].includes(field.key),
)
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
isValidLogField(flattenLogData[field.key] as never) ? (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={flattenLogData[field.name] as never}
key={field.key}
fieldKey={field.key}
fieldKeyDisplay={field.displayName}
fieldValue={flattenLogData[field.key] as never}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
/>

View File

@@ -73,16 +73,25 @@ function RawLogView({
);
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => flattenLogData[field.name])
.filter((attribute) => {
.filter(
(field) => !['log.timestamp:string', 'log.body:string'].includes(field.key),
)
.map((field) => {
const value = flattenLogData[field.key];
const label = field.displayName;
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute)) {
return true;
if (isNumber(value)) {
return `${label}: ${value}`;
}
return !isUndefined(attribute) && !isEmpty(attribute);
});
if (!isUndefined(value) && !isEmpty(value)) {
return `${label}: ${value}`;
}
return null;
})
.filter((attribute) => attribute !== null);
let attributesText = attributesValues.join(' | ');

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

@@ -6,7 +6,11 @@ import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import {
FlatLogData,
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
@@ -51,28 +55,33 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
.map(({ name }) => ({
title: name,
dataIndex: name,
accessorKey: name,
id: name.toLowerCase().replace(/\./g, '_'),
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
}),
.filter(
(e) => !['id', LOG_FIELD_BODY_KEY, LOG_FIELD_TIMESTAMP_KEY].includes(e.key),
)
.map((field) => ({
title: field.displayName,
dataIndex: field.key,
accessorKey: field.key,
id: field.key.toLowerCase().replace(/\./g, '_').replace(/:/g, '_'),
key: field.key,
render: (fieldValue, record): ColumnTypeRender<Record<string, unknown>> => {
const value = record[field.key] || fieldValue;
return {
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{value}
</Typography.Paragraph>
),
};
},
}));
if (isListViewPanel) {
@@ -100,26 +109,29 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
),
}),
},
...(fields.some((field) => field.name === 'timestamp')
...(fields.some((field) => field.key === LOG_FIELD_TIMESTAMP_KEY)
? [
{
title: 'timestamp',
dataIndex: 'timestamp',
dataIndex: LOG_FIELD_TIMESTAMP_KEY,
key: 'timestamp',
accessorKey: 'timestamp',
accessorKey: LOG_FIELD_TIMESTAMP_KEY,
id: 'timestamp',
// https://github.com/ant-design/ant-design/discussions/36886
render: (
field: string | number,
record: Record<string, unknown>,
): ColumnTypeRender<Record<string, unknown>> => {
const timestampValue =
(record[LOG_FIELD_TIMESTAMP_KEY] as string | number) || field;
const date =
typeof field === 'string'
typeof timestampValue === 'string'
? formatTimezoneAdjustedTimestamp(
field,
timestampValue,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
field / 1e6,
timestampValue / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return {
@@ -136,31 +148,37 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
]
: []),
...(appendTo === 'center' ? fieldColumns : []),
...(fields.some((field) => field.name === 'body')
...(fields.some((field) => field.key === LOG_FIELD_BODY_KEY)
? [
{
title: 'body',
dataIndex: 'body',
dataIndex: LOG_FIELD_BODY_KEY,
key: 'body',
accessorKey: 'body',
accessorKey: LOG_FIELD_BODY_KEY,
id: 'body',
render: (
field: string | number,
): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: bodyColumnStyle,
},
children: (
<TableBodyContent
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(field as string),
}}
fontSize={fontSize}
linesPerRow={linesPerRow}
isDarkMode={isDarkMode}
/>
),
}),
record: Record<string, unknown>,
): ColumnTypeRender<Record<string, unknown>> => {
const bodyValue = (record[LOG_FIELD_BODY_KEY] as string) || '';
return {
props: {
style: bodyColumnStyle,
},
children: (
<TableBodyContent
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(bodyValue, {
shouldEscapeHtml: true,
}),
}}
fontSize={fontSize}
linesPerRow={linesPerRow}
isDarkMode={isDarkMode}
/>
),
};
},
},
]
: []),

View File

@@ -416,18 +416,21 @@ function OptionsMenu({
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
{addColumn?.value?.map((column) => (
<div className="column-name" key={column.key}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
<Tooltip
placement="left"
title={column.displayName || column.name}
>
{column.displayName || column.name}
</Tooltip>
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
onClick={(): void => addColumn.onRemove(column.key)}
/>
)}
</div>

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

@@ -398,7 +398,7 @@
}
.qb-search-container {
.metrics-select-container {
.metrics-container {
margin-bottom: 12px;
}
}

View File

@@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
showOnlyWhereClause = false,
showTraceOperator = false,
version,
onSignalSourceChange,
signalSourceChangeEnabled = false,
}: QueryBuilderProps): JSX.Element {
const {
currentQuery,
@@ -175,6 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
/>
) : (
currentQuery.builder.queryData.map((query, index) => (
@@ -193,7 +197,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={config?.signalSource || ''}
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
/>
))
)}

View File

@@ -1,5 +1,14 @@
.metrics-select-container {
.metrics-source-select-container {
margin-bottom: 8px;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
.source-selector {
width: 120px;
}
.ant-select-selector {
width: 100%;
@@ -42,7 +51,7 @@
}
.lightMode {
.metrics-select-container {
.metrics-source-select-container {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100);

View File

@@ -1,34 +1,121 @@
import './MetricsSelect.styles.scss';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { memo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
export const SOURCE_OPTIONS: SelectOption<string, string>[] = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'meter', label: 'Meter' },
];
export const MetricsSelect = memo(function MetricsSelect({
query,
index,
version,
signalSource,
onSignalSourceChange,
signalSourceChangeEnabled = false,
}: {
query: IBuilderQuery;
index: number;
version: string;
signalSource: 'meter' | '';
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder();
const source = useMemo(
() => (signalSource === 'meter' ? 'meter' : 'metrics'),
[signalSource],
);
const defaultMeterQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueryMeterWithType,
PANEL_TYPES.BAR,
DataSource.METRICS,
'meter' as 'meter' | '',
),
[updateAllQueriesOperators],
);
const defaultMetricsQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap.metrics,
PANEL_TYPES.BAR,
DataSource.METRICS,
'',
),
[updateAllQueriesOperators],
);
const handleSignalSourceChange = (value: string): void => {
onSignalSourceChange(value);
handleSetQueryData(
index,
value === 'meter'
? {
...defaultMeterQuery.builder.queryData[0],
source: 'meter',
queryName: query.queryName,
}
: {
...defaultMetricsQuery.builder.queryData[0],
source: '',
queryName: query.queryName,
},
);
};
return (
<div className="metrics-select-container">
<div className="metrics-source-select-container">
{signalSourceChangeEnabled && (
<Select
className="source-selector"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}
defaultValue="metrics"
onChange={handleSignalSourceChange}
/>
)}
<AggregatorFilter
onChange={handleChangeAggregatorAttribute}
onChange={handleAggregatorAttributeChange}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

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

@@ -33,7 +33,13 @@ export const QueryV2 = memo(function QueryV2({
showOnlyWhereClause = false,
signalSource = '',
isMultiQueryAllowed = false,
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
onSignalSourceChange,
signalSourceChangeEnabled = false,
}: QueryProps & {
ref: React.RefObject<HTMLDivElement>;
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
}): JSX.Element {
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
@@ -207,12 +213,14 @@ export const QueryV2 = memo(function QueryV2({
<div className="qb-elements-container">
<div className="qb-search-container">
{dataSource === DataSource.METRICS && (
<div className="metrics-select-container">
<div className="metrics-container">
<MetricsSelect
query={query}
index={index}
version={ENTITY_VERSION_V5}
signalSource={signalSource as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange}
signalSourceChangeEnabled={signalSourceChangeEnabled}
/>
</div>
)}
@@ -258,7 +266,7 @@ export const QueryV2 = memo(function QueryV2({
panelType={panelType}
query={query}
index={index}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
version="v4"
signalSource={signalSource as 'meter' | ''}
/>

View File

@@ -45,6 +45,12 @@
flex-direction: column;
gap: 8px;
.filter-separator {
height: 1px;
background-color: var(--bg-slate-400);
margin: 4px 0;
}
.value {
display: flex;
align-items: center;
@@ -177,6 +183,12 @@
}
}
}
.values {
.filter-separator {
background-color: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,191 @@
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import { rest, server } from 'mocks-server/server';
import { UseQueryResult } from 'react-query';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SuccessResponse } from 'types/api';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import CheckboxFilter from './Checkbox';
// Mock the query builder hook
jest.mock('hooks/queryBuilder/useQueryBuilder');
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
// Mock the aggregate values hook
jest.mock('hooks/queryBuilder/useGetAggregateValues');
const mockUseGetAggregateValues = jest.mocked(useGetAggregateValues);
// Mock the key value suggestions hook
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
useGetQueryKeyValueSuggestions,
);
interface MockFilterConfig {
title: string;
attributeKey: {
key: string;
dataType: DataTypes;
type: string;
};
dataSource: DataSource;
defaultOpen: boolean;
type: FiltersType;
}
const createMockFilter = (
overrides: Partial<MockFilterConfig> = {},
): MockFilterConfig => ({
// eslint-disable-next-line sonarjs/no-duplicate-string
title: 'Service Name',
attributeKey: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.LOGS,
defaultOpen: false,
type: FiltersType.CHECKBOX,
...overrides,
});
const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: hasActiveFilters
? [
{
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['otel-demo', 'sample-flask'],
},
]
: [],
},
},
],
},
},
redirectWithQueryBuilderData: jest.fn(),
});
describe('CheckboxFilter - User Flows', () => {
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Default mock implementations using the same structure as existing tests
mockUseGetAggregateValues.mockReturnValue({
data: {
payload: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
},
},
isLoading: false,
} as UseQueryResult<SuccessResponse<IAttributeValuesResponse>>);
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
data: null,
isLoading: false,
} as any);
// Setup MSW server for API calls
server.use(
rest.get('*/api/v3/autocomplete/attribute_values', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
});
it('should auto-open filter and prioritize checked items with visual separator when user opens page with active filters', async () => {
// Mock query builder with active filters
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// User should see the filter is automatically opened (not collapsed)
expect(screen.getByText('Service Name')).toBeInTheDocument();
await waitFor(() => {
// eslint-disable-next-line sonarjs/no-duplicate-string
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// User should see visual separator between checked and unchecked items
expect(screen.getByTestId('filter-separator')).toBeInTheDocument();
// User should see checked items at the top
await waitFor(() => {
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(4); // Ensure we have exactly 4 checkboxes
expect(checkboxes[0]).toBeChecked(); // otel-demo should be first and checked
expect(checkboxes[1]).toBeChecked(); // sample-flask should be second and checked
expect(checkboxes[2]).not.toBeChecked(); // mq-kafka should be unchecked
expect(checkboxes[3]).not.toBeChecked(); // otlp-python should be unchecked
});
});
it('should respect user preference when user manually toggles filter over auto-open behavior', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock query builder with active filters
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Initially auto-opened due to active filters
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// User manually closes the filter
await user.click(screen.getByText('Service Name'));
// User should see filter is now closed (respecting user preference)
expect(
screen.queryByPlaceholderText('Filter values'),
).not.toBeInTheDocument();
// User manually opens the filter again
await user.click(screen.getByText('Service Name'));
// User should see filter is now open (respecting user preference)
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
});
});

View File

@@ -21,7 +21,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Fragment, useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -54,7 +54,8 @@ interface ICheckboxProps {
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const {
@@ -63,6 +64,33 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
// Check if this filter has active filters in the query
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
currentQuery.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, lastUsedQuery, filter.attributeKey.key],
);
// Derive isOpen from filter state + user action
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) return userToggleState;
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) return true;
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
@@ -128,8 +156,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
@@ -202,6 +228,23 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
(val) => currentFilterState[val],
);
const uncheckedValues = attributeValues.filter(
(val) => !currentFilterState[val],
);
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
}, [attributeValues, currentFilterState, visibleItemsCount]);
// Count of checked values in the currently visible items
const checkedValuesCount = useMemo(
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
[currentAttributeKeys, currentFilterState],
);
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
@@ -235,12 +278,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
}
};
const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
);
const onChange = (
value: string,
checked: boolean,
@@ -490,10 +527,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
className="filter-header-checkbox"
onClick={(): void => {
if (isOpen) {
setIsOpen(false);
setUserToggleState(false);
setVisibleItemsCount(10);
} else {
setIsOpen(true);
setUserToggleState(true);
}
}}
>
@@ -540,50 +577,59 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
)}
{attributeValues.length > 0 ? (
<section className="values">
{currentAttributeKeys.map((value: string) => (
<div key={value} className="value">
<Checkbox
onChange={(e): void => onChange(value, e.target.checked, false)}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
rootClassName="check-box"
/>
{currentAttributeKeys.map((value: string, index: number) => (
<Fragment key={value}>
{index === checkedValuesCount && checkedValuesCount > 0 && (
<div
key="separator"
className="filter-separator"
data-testid="filter-separator"
/>
)}
<div className="value">
<Checkbox
onChange={(e): void => onChange(value, e.target.checked, false)}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
rootClassName="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
</div>
</Fragment>
))}
</section>
) : isEmptyStateWithDocsEnabled ? (

View File

@@ -18,11 +18,6 @@ import UPlot from 'uplot';
import { dataMatch, optionsUpdateState } from './utils';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
}
export interface UplotProps {
options: uPlot.Options;
data: uPlot.AlignedData;
@@ -71,12 +66,6 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
const destroy = useCallback((chart: uPlot | null) => {
if (chart) {
// Clean up legend scroll event listener
const extendedChart = chart as ExtendedUPlot;
if (extendedChart._legendScrollCleanup) {
extendedChart._legendScrollCleanup();
}
onDeleteRef.current?.(chart);
chart.destroy();
chartRef.current = null;

View File

@@ -12,6 +12,7 @@ function YAxisUnitSelector({
onChange,
placeholder = 'Please select a unit',
loading = false,
'data-testid': dataTestId,
}: YAxisUnitSelectorProps): JSX.Element {
const universalUnit = mapMetricUnitToUniversalUnit(value);
@@ -45,6 +46,7 @@ function YAxisUnitSelector({
placeholder={placeholder}
filterOption={(input, option): boolean => handleSearch(input, option)}
loading={loading}
data-testid={dataTestId}
>
{Y_AXIS_CATEGORIES.map((category) => (
<Select.OptGroup key={category.name} label={category.name}>

View File

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

View File

@@ -24,6 +24,7 @@ export const DATE_TIME_FORMATS = {
TIME_SECONDS: 'HH:mm:ss',
TIME_UTC: 'HH:mm:ss (UTC Z)',
TIME_UTC_MS: 'HH:mm:ss.SSS (UTC Z)',
TIME_SPAN_PERCENTILE: 'HH:mm:ss MMM DD',
// Short date formats
DATE_SHORT: 'MM/DD',

View File

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

View File

@@ -86,7 +86,11 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
// Span Percentiles Query Keys
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
} as const;

View File

@@ -3,4 +3,5 @@ export const USER_PREFERENCES = {
NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
};

View File

@@ -1,8 +1,10 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
@@ -11,13 +13,12 @@ import {
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { Info } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { SuccessResponse } from 'types/api';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -46,7 +47,7 @@ function TopErrors({
true,
);
const queryPayloads = useMemo(
const queryPayload = useMemo(
() =>
getTopErrorsQueryPayload(
domainName,
@@ -82,37 +83,34 @@ function TopErrors({
],
);
const topErrorsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
payload,
DEFAULT_ENTITY_VERSION,
showStatusCodeErrors,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, DEFAULT_ENTITY_VERSION),
enabled: !!payload,
staleTime: 0,
cacheTime: 0,
})),
);
const topErrorsDataQuery = topErrorsDataQueries[0];
const {
data: topErrorsData,
isLoading,
isRefetching,
isError,
refetch,
} = topErrorsDataQuery;
} = useQuery({
queryKey: [
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
queryPayload,
ENTITY_VERSION_V5,
showStatusCodeErrors,
],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<SuccessResponseV2<MetricRangePayloadV5>> =>
getQueryRangeV5(queryPayload, ENTITY_VERSION_V5, signal),
enabled: !!queryPayload,
staleTime: 0,
cacheTime: 0,
});
const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []);
const formattedTopErrorsData = useMemo(
() =>
formatTopErrorsDataForTable(
topErrorsData?.payload?.data?.result as TopErrorsResponseRow[],
topErrorsData?.data?.data?.data?.results[0] as ScalarData,
),
[topErrorsData],
);

View File

@@ -69,6 +69,13 @@ function StatusCodeBarCharts({
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { startTime: minTime, endTime: maxTime } = timeRange;
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -207,6 +214,13 @@ function StatusCodeBarCharts({
onDragSelect,
colorMapping,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
minTime,

View File

@@ -8,7 +8,6 @@ import {
endPointStatusCodeColumns,
extractPortAndEndpoint,
formatDataForTable,
formatTopErrorsDataForTable,
getAllEndpointsWidgetData,
getCustomFiltersForBarChart,
getEndPointDetailsQueryPayload,
@@ -23,8 +22,6 @@ import {
getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
@@ -344,49 +341,6 @@ describe('API Monitoring Utils', () => {
});
});
describe('formatTopErrorsDataForTable', () => {
it('should format top errors data correctly', () => {
// Arrange
const inputData = [
{
metric: {
[SPAN_ATTRIBUTES.URL_PATH]: '/api/test',
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: '500',
status_message: 'Internal Server Error',
},
values: [[1000000100, '10']],
queryName: 'A',
legend: 'Test Legend',
},
];
// Act
const result = formatTopErrorsDataForTable(
inputData as TopErrorsResponseRow[],
);
// Assert
expect(result).toBeDefined();
expect(result.length).toBe(1);
// Check first item is formatted correctly
expect(result[0].endpointName).toBe('/api/test');
expect(result[0].statusCode).toBe('500');
expect(result[0].statusMessage).toBe('Internal Server Error');
expect(result[0].count).toBe('10');
expect(result[0].key).toBeDefined();
});
it('should handle empty input', () => {
// Act
const result = formatTopErrorsDataForTable(undefined);
// Assert
expect(result).toBeDefined();
expect(result).toEqual([]);
});
});
describe('getTopErrorsColumnsConfig', () => {
it('should return column configuration with expected fields', () => {
// Act
@@ -453,72 +407,6 @@ describe('API Monitoring Utils', () => {
});
});
describe('getTopErrorsQueryPayload', () => {
it('should create correct query payload with filters', () => {
// Arrange
const domainName = 'test-domain';
const start = 1000000000;
const end = 1000010000;
const filters = {
items: [
{
id: 'test-filter',
key: {
dataType: DataTypes.String,
key: 'test-key',
type: '',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
// Act
const result = getTopErrorsQueryPayload(
domainName,
start,
end,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
// Verify query params
expect(result[0].start).toBe(start);
expect(result[0].end).toBe(end);
// Verify correct structure
expect(result[0].graphType).toBeDefined();
expect(result[0].query).toBeDefined();
expect(result[0].query.builder).toBeDefined();
expect(result[0].query.builder.queryData).toBeDefined();
// Verify domain filter is included
const queryData = result[0].query.builder.queryData[0];
expect(queryData.filters).toBeDefined();
// Check for domain filter
const domainFilter = queryData.filters?.items?.find(
// eslint-disable-next-line sonarjs/no-identical-functions
(item) =>
item.key &&
item.key.key === SPAN_ATTRIBUTES.SERVER_NAME &&
item.value === domainName,
);
expect(domainFilter).toBeDefined();
// Check that custom filters were included
const testFilter = queryData.filters?.items?.find(
(item) => item.id === 'test-filter',
);
expect(testFilter).toBeDefined();
});
});
// Add new tests for EndPointDetails utility functions
describe('extractPortAndEndpoint', () => {
it('should extract port and endpoint from a valid URL', () => {

View File

@@ -1,14 +1,6 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { useQueries } from 'react-query';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
@@ -35,28 +27,15 @@ jest.mock(
}),
);
// Mock dependencies
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
useNavigateToExplorer: jest.fn(),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: ['key1', 'key2', 'key3', 'key4', 'key5'],
formatTopErrorsDataForTable: jest.fn(),
getEndPointDetailsQueryPayload: jest.fn(),
getTopErrorsColumnsConfig: jest.fn(),
getTopErrorsCoRelationQueryFilters: jest.fn(),
getTopErrorsQueryPayload: jest.fn(),
}));
describe('TopErrors', () => {
const TABLE_BODY_SELECTOR = '.ant-table-tbody';
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
const mockProps = {
// eslint-disable-next-line sonarjs/no-duplicate-string
domainName: 'test-domain',
timeRange: {
startTime: 1000000000,
@@ -68,75 +47,72 @@ describe('TopErrors', () => {
},
};
// Setup basic mocks
// Helper function to wait for table data to load
const waitForTableDataToLoad = async (
container: HTMLElement,
): Promise<void> => {
await waitFor(() => {
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
expect(tableBody).not.toBeNull();
if (tableBody) {
expect(
within(tableBody as HTMLElement).queryByText('/api/test'),
).toBeInTheDocument();
}
});
};
beforeEach(() => {
jest.clearAllMocks();
// Mock getTopErrorsColumnsConfig
(getTopErrorsColumnsConfig as jest.Mock).mockReturnValue([
{
title: 'Endpoint',
dataIndex: 'endpointName',
key: 'endpointName',
},
{
title: 'Status Code',
dataIndex: 'statusCode',
key: 'statusCode',
},
{
title: 'Status Message',
dataIndex: 'statusMessage',
key: 'statusMessage',
},
{
title: 'Count',
dataIndex: 'count',
key: 'count',
},
]);
// Mock useNavigateToExplorer
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
// Mock useQueries
(useQueries as jest.Mock).mockImplementation((queryConfigs) => {
// For topErrorsDataQueries
if (
queryConfigs.length === 1 &&
queryConfigs[0].queryKey &&
queryConfigs[0].queryKey[0] === REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN
) {
return [
{
// Mock V5 API endpoint for top errors
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
payload: {
data: {
result: [
{
metric: {
'http.url': '/api/test',
status_code: '500',
// eslint-disable-next-line sonarjs/no-duplicate-string
status_message: 'Internal Server Error',
data: {
results: [
{
columns: [
{
name: 'http.url',
fieldDataType: 'string',
fieldContext: 'attribute',
},
values: [[1000000100, '10']],
queryName: 'A',
legend: 'Test Legend',
},
],
},
{
name: 'response_status_code',
fieldDataType: 'string',
fieldContext: 'span',
},
{
name: 'status_message',
fieldDataType: 'string',
fieldContext: 'span',
},
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
],
// eslint-disable-next-line sonarjs/no-duplicate-string
data: [['/api/test', '500', 'Internal Server Error', 10]],
},
],
},
},
isLoading: false,
isRefetching: false,
isError: false,
refetch: jest.fn(),
},
];
}
}),
),
),
);
// For endPointDropDownDataQueries
return [
{
data: {
// Mock V4 API endpoint for dropdown data
server.use(
rest.post('*/api/v1/query_range', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
payload: {
data: {
result: [
@@ -153,62 +129,13 @@ describe('TopErrors', () => {
],
},
},
},
isLoading: false,
isRefetching: false,
isError: false,
},
];
});
// Mock formatTopErrorsDataForTable
(formatTopErrorsDataForTable as jest.Mock).mockReturnValue([
{
key: '1',
endpointName: '/api/test',
statusCode: '500',
statusMessage: 'Internal Server Error',
count: 10,
},
]);
// Mock getTopErrorsQueryPayload
(getTopErrorsQueryPayload as jest.Mock).mockReturnValue([
{
queryName: 'TopErrorsQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock getEndPointDetailsQueryPayload
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
{},
{},
{
queryName: 'EndpointDropdownQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock useNavigateToExplorer
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
// Mock getTopErrorsCoRelationQueryFilters
(getTopErrorsCoRelationQueryFilters as jest.Mock).mockReturnValue({
items: [
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
],
op: 'AND',
});
}),
),
),
);
});
it('renders component correctly', () => {
it('renders component correctly', async () => {
// eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />);
@@ -216,10 +143,11 @@ describe('TopErrors', () => {
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
expect(screen.getByText('Status Message Exists')).toBeInTheDocument();
// Find the table row and verify content
const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).not.toBeNull();
// Wait for data to load
await waitForTableDataToLoad(container);
// Find the table row and verify content
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
if (tableBody) {
const row = within(tableBody as HTMLElement).getByRole('row');
expect(within(row).getByText('/api/test')).toBeInTheDocument();
@@ -228,35 +156,40 @@ describe('TopErrors', () => {
}
});
it('renders error state when isError is true', () => {
// Mock useQueries to return isError: true
(useQueries as jest.Mock).mockImplementationOnce(() => [
{
isError: true,
refetch: jest.fn(),
},
]);
it('renders error state when API fails', async () => {
// Mock API to return error
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
),
);
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Error state should be shown with the actual text displayed in the UI
expect(
screen.getByText('Uh-oh :/ We ran into an error.'),
).toBeInTheDocument();
// Wait for error state
await waitFor(() => {
expect(
screen.getByText('Uh-oh :/ We ran into an error.'),
).toBeInTheDocument();
});
expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument();
expect(screen.getByText('Refresh this panel')).toBeInTheDocument();
});
it('handles row click correctly', () => {
it('handles row click correctly', async () => {
const navigateMock = jest.fn();
(useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock);
// eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />);
// Wait for data to load
await waitForTableDataToLoad(container);
// Find and click on the table cell containing the endpoint
const tableBody = container.querySelector('.ant-table-tbody');
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
expect(tableBody).not.toBeNull();
if (tableBody) {
@@ -267,11 +200,28 @@ describe('TopErrors', () => {
// Check if navigateToExplorer was called with correct params
expect(navigateMock).toHaveBeenCalledWith({
filters: [
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
],
filters: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.url' }),
op: '=',
value: '/api/test',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'has_error' }),
op: '=',
value: 'true',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'net.peer.name' }),
op: '=',
value: 'test-domain',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'response_status_code' }),
op: '=',
value: '500',
}),
]),
dataSource: DataSource.TRACES,
startTime: mockProps.timeRange.startTime,
endTime: mockProps.timeRange.endTime,
@@ -279,24 +229,34 @@ describe('TopErrors', () => {
});
});
it('updates endpoint filter when dropdown value changes', () => {
it('updates endpoint filter when dropdown value changes', async () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Find the dropdown
const dropdown = screen.getByRole('combobox');
// Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Check if getTopErrorsQueryPayload was called with updated parameters
expect(getTopErrorsQueryPayload).toHaveBeenCalled();
// Component should re-render with new filter
expect(dropdown).toBeInTheDocument();
});
it('handles status message toggle correctly', () => {
it('handles status message toggle correctly', async () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});
// Find the toggle switch
const toggle = screen.getByRole('switch');
expect(toggle).toBeInTheDocument();
@@ -307,69 +267,104 @@ describe('TopErrors', () => {
// Click the toggle to turn it off
fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=false
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
false,
);
// Title should change
expect(screen.getByText('All Errors')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('All Errors')).toBeInTheDocument();
});
// Click the toggle to turn it back on
fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=true
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
true,
);
// Title should change back
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
});
});
it('includes toggle state in query key for cache busting', () => {
it('includes toggle state in query key for cache busting', async () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
const toggle = screen.getByRole('switch');
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});
// Initial query should include showStatusCodeErrors=true
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect.any(Object),
expect.any(String),
true,
]),
}),
]),
);
const toggle = screen.getByRole('switch');
// Click toggle
fireEvent.click(toggle);
// Query should be called with showStatusCodeErrors=false in key
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect.any(Object),
expect.any(String),
false,
]),
}),
]),
// Wait for title to change, indicating query was refetched with new key
await waitFor(() => {
expect(screen.getByText('All Errors')).toBeInTheDocument();
});
// The fact that data refetches when toggle changes proves the query key includes the toggle state
expect(toggle).toBeInTheDocument();
});
it('sends query_range v5 API call with required filters including has_error', async () => {
let capturedRequest: any;
// Override the v5 API mock to capture the request
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
capturedRequest = await req.json();
return res(
ctx.status(200),
ctx.json({
data: {
data: {
results: [
{
columns: [
{
name: 'http.url',
fieldDataType: 'string',
fieldContext: 'attribute',
},
{
name: 'response_status_code',
fieldDataType: 'string',
fieldContext: 'span',
},
{
name: 'status_message',
fieldDataType: 'string',
fieldContext: 'span',
},
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
],
data: [['/api/test', '500', 'Internal Server Error', 10]],
},
],
},
},
}),
);
}),
);
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Wait for the API call to be made
await waitFor(() => {
expect(capturedRequest).toBeDefined();
});
// Extract the filter expression from the captured request
const filterExpression =
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
// Verify all required filters are present
expect(filterExpression).toContain(`kind_string = 'Client'`);
expect(filterExpression).toContain(`(http.url EXISTS OR url.full EXISTS)`);
expect(filterExpression).toContain(
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
);
expect(filterExpression).toContain(`has_error = true`);
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
});
});

View File

@@ -2,6 +2,7 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Tag, Tooltip } from 'antd';
import { ColumnType } from 'antd/es/table';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
IQuickFiltersConfig,
@@ -27,6 +28,11 @@ import {
OrderByPayload,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
ColumnDescriptor,
QueryRangePayloadV5,
ScalarData,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -40,6 +46,9 @@ import {
EndPointsResponseRow,
} from './types';
export const isEmptyFilterValue = (value: unknown): boolean =>
value === '' || value === null || value === undefined || value === 'n/a';
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
type: FiltersType.CHECKBOX,
@@ -816,153 +825,100 @@ export const getEndPointsQueryPayload = (
];
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function buildFilterExpression(
domainName: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors: boolean,
): string {
const baseFilterParts = [
`kind_string = 'Client'`,
`(http.url EXISTS OR url.full EXISTS)`,
`(net.peer.name = '${domainName}' OR server.address = '${domainName}')`,
`has_error = true`,
];
if (showStatusCodeErrors) {
baseFilterParts.push('status_message EXISTS');
}
const filterExpression = baseFilterParts.join(' AND ');
if (!filters) {
return filterExpression;
}
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
filterExpression,
);
return filter.expression;
}
export const getTopErrorsQueryPayload = (
domainName: string,
start: number,
end: number,
filters: IBuilderQuery['filters'],
showStatusCodeErrors = true,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
op: 'AND',
items: [
{
id: '04da97bd',
key: {
key: 'kind_string',
dataType: DataTypes.String,
type: '',
},
op: '=',
value: 'Client',
},
{
id: 'b1af6bdb',
key: {
key: SPAN_ATTRIBUTES.URL_PATH,
dataType: DataTypes.String,
type: 'tag',
},
op: 'exists',
value: '',
},
...(showStatusCodeErrors
? [
{
id: '75d65388',
key: {
key: 'status_message',
dataType: DataTypes.String,
type: '',
},
op: 'exists',
value: '',
},
]
: []),
{
id: '4872bf91',
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: domainName,
},
{
id: 'ab4c885d',
key: {
key: 'has_error',
dataType: DataTypes.bool,
type: '',
},
op: '=',
value: true,
},
...(filters?.items || []),
],
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: 10,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [
{
key: SPAN_ATTRIBUTES.URL_PATH,
dataType: DataTypes.String,
type: 'tag',
},
{
dataType: DataTypes.String,
key: 'response_status_code',
type: '',
id: 'response_status_code--string----true',
},
{
key: 'status_message',
dataType: DataTypes.String,
type: '',
},
],
legend: '',
reduceTo: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
): QueryRangePayloadV5 => {
const filterExpression = buildFilterExpression(
domainName,
filters,
showStatusCodeErrors,
);
return {
schemaVersion: 'v1',
start,
end,
step: 240,
},
];
requestType: 'scalar',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'traces',
stepInterval: 60,
disabled: false,
aggregations: [{ expression: 'count()' }],
filter: { expression: filterExpression },
groupBy: [
{
name: 'http.url',
fieldDataType: 'string',
fieldContext: 'attribute',
},
{
name: 'url.full',
fieldDataType: 'string',
fieldContext: 'attribute',
},
{
name: 'response_status_code',
fieldDataType: 'string',
fieldContext: 'span',
},
{
name: 'status_message',
fieldDataType: 'string',
fieldContext: 'span',
},
],
limit: 10,
order: [
{
key: {
name: 'count()',
},
direction: 'desc',
},
],
},
},
],
},
formatOptions: { formatTableResultForUI: true, fillGaps: false },
variables: {},
};
};
export interface EndPointsTableRowData {
key: string;
@@ -1242,63 +1198,55 @@ export const formatEndPointsDataForTable = (
return formattedData;
};
export interface TopErrorsResponseRow {
metric: {
[SPAN_ATTRIBUTES.URL_PATH]: string;
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: string;
status_message: string;
};
values: [number, string][];
queryName: string;
legend: string;
}
export type TopErrorsResponseRow = ScalarData;
export interface TopErrorsTableRowData {
key: string;
endpointName: string;
statusCode: string;
statusMessage: string;
count: number | string;
count: string;
}
/**
* Returns '-' if value is empty, otherwise returns value as string
*/
export function getDisplayValue(value: unknown): string {
return isEmptyFilterValue(value) ? '-' : String(value);
}
export const formatTopErrorsDataForTable = (
data: TopErrorsResponseRow[] | undefined,
scalarResult: TopErrorsResponseRow | undefined,
): TopErrorsTableRowData[] => {
if (!data) return [];
if (!scalarResult?.data) return [];
return data.map((row) => ({
key: v4(),
endpointName:
row.metric[SPAN_ATTRIBUTES.URL_PATH] === 'n/a' ||
row.metric[SPAN_ATTRIBUTES.URL_PATH] === undefined
? '-'
: row.metric[SPAN_ATTRIBUTES.URL_PATH],
statusCode:
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === 'n/a' ||
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === undefined
? '-'
: row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE],
statusMessage:
row.metric.status_message === 'n/a' ||
row.metric.status_message === undefined
? '-'
: row.metric.status_message,
count:
row.values &&
row.values[0] &&
row.values[0][1] !== undefined &&
row.values[0][1] !== 'n/a'
? row.values[0][1]
: '-',
}));
const columns = scalarResult.columns || [];
const rows = scalarResult.data || [];
return rows.map((rowData: unknown[]) => {
const rowObj: Record<string, unknown> = {};
columns.forEach((col: ColumnDescriptor, index: number) => {
rowObj[col.name] = rowData[index];
});
return {
key: v4(),
endpointName: getDisplayValue(
rowObj[SPAN_ATTRIBUTES.URL_PATH] || rowObj['url.full'],
),
statusCode: getDisplayValue(rowObj[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]),
statusMessage: getDisplayValue(rowObj.status_message),
count: getDisplayValue(rowObj.__result_0),
};
});
};
export const getTopErrorsCoRelationQueryFilters = (
domainName: string,
endPointName: string,
statusCode: string,
): IBuilderQuery['filters'] => ({
items: [
): IBuilderQuery['filters'] => {
const items: TagFilterItem[] = [
{
id: 'ea16470b',
key: {
@@ -1330,7 +1278,10 @@ export const getTopErrorsCoRelationQueryFilters = (
op: '=',
value: domainName,
},
{
];
if (statusCode !== '-') {
items.push({
id: 'f6891e27',
key: {
key: 'response_status_code',
@@ -1340,10 +1291,14 @@ export const getTopErrorsCoRelationQueryFilters = (
},
op: '=',
value: statusCode,
},
],
op: 'AND',
});
});
}
return {
items,
op: 'AND',
};
};
export const getTopErrorsColumnsConfig = (): ColumnType<TopErrorsTableRowData>[] => [
{

View File

@@ -11,12 +11,14 @@ import { v4 } from 'uuid';
import { useCreateAlertState } from '../context';
import {
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD,
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import { AlertThresholdMatchType } from '../context/types';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
@@ -38,12 +40,12 @@ function AlertThreshold({
alertState,
thresholdState,
setThresholdState,
setEvaluationWindow,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
useEffect(() => {
@@ -160,6 +162,54 @@ function AlertThreshold({
}),
);
const handleSetEvaluationDetailsForMeter = (): void => {
setEvaluationWindow({
type: 'SET_INITIAL_STATE_FOR_METER',
});
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
});
};
const handleSelectedQueryChange = (value: string): void => {
// loop through currenttQuery and find the query that matches the selected query
const query = currentQuery?.builder?.queryData.find(
(query) => query.queryName === value,
);
const currentSelectedQuery = currentQuery?.builder?.queryData.find(
(query) => query.queryName === thresholdState.selectedQuery,
);
const newSelectedQuerySource = query?.source || '';
const currentSelectedQuerySource = currentSelectedQuery?.source || '';
if (newSelectedQuerySource === currentSelectedQuerySource) {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
return;
}
if (newSelectedQuerySource === 'meter') {
handleSetEvaluationDetailsForMeter();
} else {
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: INITIAL_EVALUATION_WINDOW_STATE,
});
}
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
};
return (
<div
className={classNames(
@@ -175,14 +225,10 @@ function AlertThreshold({
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
onChange={handleSelectedQueryChange}
style={{ width: 80 }}
options={queryNames}
data-testid="alert-threshold-query-select"
/>
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
@@ -195,6 +241,7 @@ function AlertThreshold({
}}
style={{ width: 180 }}
options={THRESHOLD_OPERATOR_OPTIONS}
data-testid="alert-threshold-operator-select"
/>
<Typography.Text className="sentence-text">
the threshold(s)
@@ -209,6 +256,7 @@ function AlertThreshold({
}}
style={{ width: 180 }}
options={matchTypeOptionsWithTooltips}
data-testid="alert-threshold-match-type-select"
/>
<Typography.Text className="sentence-text">
during the <EvaluationSettings />
@@ -236,6 +284,7 @@ function AlertThreshold({
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
data-testid="add-threshold-button"
>
Add Threshold
</Button>

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

@@ -10,6 +10,7 @@ import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
@@ -30,7 +31,7 @@ function EvaluationSettings(): JSX.Element {
trigger="click"
showArrow={false}
>
<Button>
<Button data-testid="evaluation-settings-button">
<div className="evaluate-alert-conditions-button-left">
{getTimeframeText(evaluationWindow)}
</div>

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

@@ -24,7 +24,11 @@ import {
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import {
AlertThresholdMatchType,
ICreateAlertContextProps,
ICreateAlertProviderProps,
} from './types';
import {
advancedOptionsReducer,
alertCreationReducer,
@@ -67,6 +71,7 @@ export function CreateAlertProvider(
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
if (isEditMode) {
@@ -122,7 +127,28 @@ export function CreateAlertProvider(
setThresholdState({
type: 'RESET',
});
}, [alertType]);
if (thresholdsFromURL) {
try {
const thresholds = JSON.parse(thresholdsFromURL);
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholds,
});
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
}
setEvaluationWindow({
type: 'SET_INITIAL_STATE_FOR_METER',
});
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
});
}
}, [alertType, thresholdsFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -237,6 +237,7 @@ export type EvaluationWindowAction =
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
| { type: 'SET_INITIAL_STATE_FOR_METER' }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';

View File

@@ -1,3 +1,5 @@
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { QueryParams } from 'constants/query';
import {
alertDefaults,
@@ -11,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { CumulativeWindowTimeframes } from '../EvaluationSettings/types';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
@@ -210,6 +213,18 @@ export const evaluationWindowReducer = (
return INITIAL_EVALUATION_WINDOW_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
case 'SET_INITIAL_STATE_FOR_METER':
return {
...state,
windowType: 'cumulative',
timeframe: CumulativeWindowTimeframes.CURRENT_DAY,
startingAt: {
time: '00:00:00',
number: '0',
timezone: UTC_TIMEZONE.value,
unit: UniversalYAxisUnit.MINUTES,
},
};
default:
return state;
}

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

@@ -108,6 +108,13 @@ function ChartPreview({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { currentQuery } = useQueryBuilder();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
@@ -296,6 +303,13 @@ function ChartPreview({
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
yAxisUnit,

View File

@@ -36,6 +36,7 @@ function QuerySection({
// init namespace for translations
const { t } = useTranslation('alerts');
const [currentTab, setCurrentTab] = useState(queryCategory);
const [signalSource, setSignalSource] = useState<string>('metrics');
const handleQueryCategoryChange = (queryType: string): void => {
setQueryCategory(queryType as EQueryType);
@@ -48,12 +49,17 @@ function QuerySection({
const isDarkMode = useIsDarkMode();
const handleSignalSourceChange = (value: string): void => {
setSignalSource(value);
};
const renderMetricUI = (): JSX.Element => (
<QueryBuilderV2
panelType={panelType}
config={{
queryVariant: 'static',
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
signalSource: signalSource === 'meter' ? 'meter' : '',
}}
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
showFunctions={
@@ -62,6 +68,8 @@ function QuerySection({
alertType === AlertTypes.LOGS_BASED_ALERT
}
version={alertDef.version || 'v3'}
onSignalSourceChange={handleSignalSourceChange}
signalSourceChangeEnabled
/>
);

View File

@@ -137,8 +137,8 @@ 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 * 24
: null,
);
}
@@ -198,7 +198,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 +294,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 +307,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 +354,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 +418,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 +537,7 @@ function GeneralSettings({
value: logsS3RetentionPeriod,
setValue: setLogsS3RetentionPeriod,
hide: !s3Enabled,
isS3Field: true,
},
],
save: {
@@ -577,6 +591,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,390 @@
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', () => {
const BUTTON_SELECTOR = 'button[type="button"]';
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
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_SELECTOR);
// The primary button should be the save button
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
) 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_SELECTOR);
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
) 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');
});
});
describe('Test 4: Save Button State with S3 Disabled', () => {
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
tracesTtlValuesPayload={mockTracesRetention}
logsTtlValuesPayload={mockLogsRetentionWithoutS3}
getAvailableDiskPayload={mockDisksWithS3}
metricsTtlValuesRefetch={jest.fn()}
tracesTtlValuesRefetch={jest.fn()}
logsTtlValuesRefetch={jest.fn()}
/>,
);
// Find the Logs card
const logsCard = screen.getByText('Logs').closest('.ant-card');
expect(logsCard).toBeInTheDocument();
// Find the save button
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
) as HTMLButtonElement;
expect(saveButton).toBeInTheDocument();
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
expect(saveButton).toBeDisabled();
// Find the total retention input
const inputs = logsCard?.querySelectorAll('input[type="text"]');
const totalInput = inputs?.[0] as HTMLInputElement;
// Change total retention value to trigger button enable
await user.clear(totalInput);
await user.type(totalInput, '60');
// Button should now be enabled after change
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
// Revert to original value (30 days displays as 1 Month)
await user.clear(totalInput);
await user.type(totalInput, '1');
// Button should be disabled again (back to original state)
await waitFor(() => {
expect(saveButton).toBeDisabled();
});
});
});
});

View File

@@ -34,12 +34,21 @@ 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 >= 0) {
for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) {
const timeUnit = unitsToConsider[idx];
const convertedValue = timeUnit.multiplier * value;
if (
@@ -49,7 +58,10 @@ export const convertHoursValueToRelevantUnit = (
return { value: convertedValue, timeUnitValue: timeUnit.value };
}
}
return { value, timeUnitValue: TimeUnits[0].value };
}
// Fallback to the first available unit
return { value: -1, 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[]
@@ -327,6 +324,7 @@ function FullView({
panelType={selectedPanelType}
version={selectedDashboard?.data?.version || 'v3'}
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}
// queryComponents={queryComponents}
/>

View File

@@ -17,12 +17,6 @@ export const Card = styled(CardComponent)<CardProps>`
overflow: hidden;
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
background: linear-gradient(
0deg,
rgba(171, 189, 255, 0) 0%,
rgba(171, 189, 255, 0) 100%
),
#0b0c0e;
${({ isDarkMode }): StyledCSS =>
!isDarkMode &&

View File

@@ -48,6 +48,7 @@ function GridTableComponent({
widgetId,
panelType,
queryRangeRequest,
decimalPrecision,
...props
}: GridTableComponentProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
@@ -87,10 +88,15 @@ function GridTableComponent({
const newValue = { ...val };
Object.keys(val).forEach((k) => {
const unit = getColumnUnit(k, columnUnits);
if (unit) {
// the check below takes care of not adding units for rows that have n/a or null values
if (val[k] !== 'n/a' && val[k] !== null) {
newValue[k] = getYAxisFormattedValue(String(val[k]), unit);
newValue[k] = getYAxisFormattedValue(
String(val[k]),
unit,
decimalPrecision,
);
} else if (val[k] === null) {
newValue[k] = 'n/a';
}
@@ -103,7 +109,7 @@ function GridTableComponent({
return mutateDataSource;
},
[columnUnits],
[columnUnits, decimalPrecision],
);
const dataSource = useMemo(() => applyColumnUnits(originalDataSource), [

View File

@@ -1,4 +1,5 @@
import { TableProps } from 'antd';
import { PrecisionOption } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
import {
@@ -15,6 +16,7 @@ export type GridTableComponentProps = {
query: Query;
thresholds?: ThresholdProps[];
columnUnits?: ColumnUnit;
decimalPrecision?: PrecisionOption;
tableProcessedDataRef?: React.MutableRefObject<RowData[]>;
sticky?: TableProps<RowData>['sticky'];
searchTerm?: string;

View File

@@ -99,7 +99,11 @@ function GridValueComponent({
rawValue={value}
value={
yAxisUnit
? getYAxisFormattedValue(String(value), yAxisUnit)
? getYAxisFormattedValue(
String(value),
yAxisUnit,
widget?.decimalPrecision,
)
: value.toString()
}
/>

View File

@@ -423,6 +423,7 @@
display: flex;
flex-direction: row;
gap: 14px;
align-items: flex-start;
.section-icon {
display: flex;
@@ -461,7 +462,6 @@
flex-direction: column;
gap: 14px;
width: 150px;
justify-content: flex-end;
.ant-btn {

View File

@@ -11,6 +11,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
@@ -75,11 +79,15 @@ function EntityLogs({
dataType: 'string',
type: '',
name: 'body',
displayName: 'Body',
key: LOG_FIELD_BODY_KEY,
},
{
dataType: 'string',
type: '',
name: 'timestamp',
displayName: 'Timestamp',
key: LOG_FIELD_TIMESTAMP_KEY,
},
]}
/>

View File

@@ -115,6 +115,13 @@ function EntityMetrics<T>({
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const chartData = useMemo(
() =>
@@ -184,6 +191,13 @@ function EntityMetrics<T>({
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
});
}),
[

View File

@@ -418,6 +418,11 @@
font-size: 12px;
font-weight: 600;
}
.set-alert-btn {
cursor: pointer;
margin-left: 24px;
}
}
}

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