Compare commits

...

45 Commits

Author SHA1 Message Date
primus-bot[bot]
c9568be5d8 chore(release): bump to v0.95.0 (#9112)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-09-17 12:55:46 +05:30
Srikanth Chekuri
1c257f3e14 chore: populate default zero queries for metrics (#9103) 2025-09-17 07:05:38 +00:00
Amlan Kumar Nandy
ff8ac96d37 chore: fix edit alerts page crashing (#9025) 2025-09-17 06:14:25 +00:00
Tushar Vats
e8035b7dd2 Added Download Button to leverage exportRawData API (#9050)
This pull request introduces a new, customizable logs export feature in the Logs Explorer view, replacing the previous hardcoded download functionality. Users can now select export format, row limit, and which columns to include via a dedicated options menu. The implementation includes a new API integration for downloading export data, UI components for export options, and associated styling.
2025-09-17 03:37:44 +00:00
Srikanth Chekuri
cc77b829af chore: recognize dashboard view page for log_comment (#9084) 2025-09-16 19:43:54 +00:00
Nityananda Gohain
49306cbe3d fix: add trace perf improvement only when there is any filter (#9099) 2025-09-16 17:08:58 +00:00
Srikanth Chekuri
233a8e4cc3 chore: use wss usage for workloads listing (#9104) 2025-09-16 16:42:15 +00:00
Srikanth Chekuri
629378bbec chore: do not fail on non-existent signoz_ dbs (#9092) 2025-09-16 16:15:11 +00:00
Shaheer Kochai
d96073f478 feat: highlight the searched spans and dim other spans in trace details v2 (#9032) 2025-09-16 14:29:48 +00:00
Tushar Vats
ba8a49929a Converted format options menu to popover (#9094)
This pull request refactors the LogsFormatOptionsMenu component to use the Ant Design Popover for displaying format options, replacing the previous custom dropdown implementation. It also updates the related styles and removes now-unnecessary state and logic from parent components. The changes improve code maintainability, UI consistency, and simplify event handling.
2025-09-16 13:12:15 +00:00
Shaheer Kochai
a90904951e feat: make the trace details v2 sidebar resizable (#9034)
* feat: make the trace details v2 sidebar resizable

* chore: fix the failing test

* chore: improve the UI while resizing the sidebar
2025-09-16 17:20:32 +04:30
Shaheer Kochai
6c57735a81 feat: add support for span logs (#8857) 2025-09-16 11:22:15 +00:00
Shaheer Kochai
4851527840 fix: fix the incorrect matching spans count (#9033) 2025-09-16 08:56:47 +00:00
Niladri Adhikary
c5051128fa feat: warn when LIKE/ILIKE is used without any %/_ (#9098)
* feat: warn when LIKE/ILIKE is used without any %/_

Signed-off-by: “niladrix719” <niladrix719@gmail.com>
2025-09-16 06:23:26 +00:00
Yunus M
2acdd101d8 feat: clean up tooltip elements and add dependencies (#8815) 2025-09-16 11:08:51 +05:30
Amlan Kumar Nandy
39c2738ef9 feat: add alert condition component (#9008) 2025-09-15 21:22:07 +05:30
manika-signoz
d075ceecba chore: copy changes and minor fixes, onboarding hint (#9095) 2025-09-15 18:54:37 +05:30
aniketio-ctrl
ac81eab7bb chore: added cumulative window support (#8828)
* feat(multi-threshold): added multi threshold

* Update pkg/types/ruletypes/api_params.go

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

* feat(multiple-threshold): added multiple thresholds

* Update pkg/types/ruletypes/alerting.go

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

* feat(multiple-threshold): added multiple thresholds

* feat(cumulative-window): added cumulative window

* feat(multi-threshold): added recovery min points

* Update pkg/query-service/rules/threshold_rule.go

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

* feat(multi-threshold): fixed log lines

* feat(multi-threshold): added severity as threshold name

* feat(cumulative-window): added cumulative window for alerts v2

* feat(multi-threshold): removed break to send multi threshold alerts

* feat(multi-threshold): removed break to send multi threshold alerts

* feat(cumulative-window): segregated json marshalling with evaluation logic

* feat(multi-threshold): corrected the test cases

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threhsold): added error wrapper

* feat(multi-threhsold): added error wrapper

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threhsold): added error wrapper

* Update pkg/types/ruletypes/threshold.go

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

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added validation and error propagation

* feat(multi-notification): removed pre defined labels from links of log and traces

* feat(multi-notification): removed pre defined labels from links of log and traces

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added umnarshaller for postable rule

* feat(multi-threshold): added umnarshaller for postable rule

* feat(cumulative-window): added validation check

* Update pkg/types/ruletypes/evaluation.go

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

* feat(multi-threhsold): removed yaml support for alerts

* Update pkg/types/ruletypes/evaluation.go

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* Update pkg/types/ruletypes/evaluation.go

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* chore(cumulative-window): renamed funcitons

* chore(cumulative-window): removed naked errors

* chore(cumulative-window): added reset boundary condition tests

* chore(cumulative-window): added reset boundary condition tests

* chore(cumulative-window): sorted imports

* chore(cumulative-window): sorted imports

* chore(cumulative-window): sorted imports

* chore(cumulative-window): removed error from next window for

* chore(cumulative-window): removed error from next window for

* chore(cumulative-window): added case for timezone

* chore(cumulative-window): added validation for eval window

* chore(cumulative-window): updated api structure for cumulative window

* chore(cumulative-window): updated schedule enum

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-09-15 15:00:12 +05:30
Srikanth Chekuri
c982b1e76d chore: allow number segment, #, @, {} in key (#9082) 2025-09-14 22:14:42 +05:30
Srikanth Chekuri
252786deb6 chore: make qb v5 default (#9085) 2025-09-14 20:41:13 +05:30
Nityananda Gohain
38ca467d13 fix: trace perf - scan only required traces (#9072) 2025-09-14 13:12:48 +00:00
Nityananda Gohain
a686941880 fix: exception on resource filters with numeric values (#9028) 2025-09-14 13:00:16 +00:00
Nityananda Gohain
ae58915020 chore: support for json column in resources (#8376) 2025-09-14 18:18:39 +05:30
Vibhu Pandey
e9222ab3e0 docs(integration): add docs for writing integration tests (#9070) 2025-09-12 12:16:04 +00:00
aniketio-ctrl
d801fcee76 chore: add multiple thresholds support (#8816) 2025-09-12 13:11:54 +05:30
Vibhu Pandey
61acd946cc fix: enable dot metrics by default (#9061) 2025-09-11 17:59:55 +00:00
Abhi kumar
c477ec65da feat: added support for hasToken function in QB (#9058)
* feat: updated grammer to add hasToken function

* feat: added function constant
2025-09-11 12:55:13 +05:30
SagarRajput-7
9d999feabb feat: change Bar color opacity and make stacking as default (#9026) 2025-09-10 14:56:40 +00:00
Amlan Kumar Nandy
0658c561b9 chore: add query section to create alerts (#8991) 2025-09-10 14:43:54 +00:00
Abhi kumar
b1ea7eab70 chore: automatically show query addon when the value is present even after refresh (#9024)
* chore: automatically show query addon when the value is present even after refresh

* chore: minor cleanup

* test: added tests for queryAddon

* test: removed inputwithlabel mock
2025-09-10 19:53:06 +05:30
Vibhu Pandey
31e042adf7 feat(alertmanager): deprecate legacy alertmanager (#9046)
- Deprecate legacy alertmanager. Are the new alert improvements compatible with legacy? I don't think they are. More importantly, I don't think they should be. It will be a pain to manage it at both places.

- Improve msteamsv2 experience. I have taken alertmanager's upstream and merged it with our custom implementation. Note the use of `titleLink` field to propagate the url of the alert.

- Delete the private http server needed for alertmanager. It's cleanup as part of 1.
2025-09-10 08:28:13 +00:00
SagarRajput-7
f23000831c feat: update yarn lock file (#9055) 2025-09-10 05:52:57 +00:00
SagarRajput-7
f82e9b55f8 fix: logs explorer chart severity text bugfixes (#8731)
* fix: fixed severity color getting incorrectly assigned due to the response changed in v5 API

* fix: implement consistent severity variant colors across logs chart and indicator component

* chore: fix the failing tests

* chore: fix the failing check

---------

Co-authored-by: ahmadshaheer <ashaheerki@gmail.com>
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-09-10 05:21:06 +00:00
Shaheer Kochai
f91115948a feat: add support for span hover card in trace details v2 (#8930)
* feat: add support for span hover card in trace details v2

* chore: remove the unnecessary tooltip

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-09-10 09:01:57 +04:30
SagarRajput-7
011b769d4d chore: added resolution brace-expansion to 2.0.2 to fix the vulnerability (#9049) 2025-09-09 14:53:36 +00:00
Abhi kumar
0129326a0b Fix: changelog modal spacing issue (#9048)
* fix: added fix for changelog modal styling issue

* chore: minor code cleanup

* chore: pr review changes

* chore: minor preetier fix
2025-09-09 18:44:33 +05:30
Tushar Vats
6c7275d355 Feat: API for exporting raw data (#8936)
This introduces a new Raw Data Export module to the codebase, enabling users to export raw log data via a dedicated API endpoint. The changes include the implementation of the module and handler, integration with existing infrastructure, configuration updates, and adjustments to tests and module wiring.
2025-09-09 17:04:40 +05:30
Vibhu Pandey
c83eaf3d50 chore: enable forbidigo and noerrors in depguard (#9047)
* chore: enable forbidgo

* chore: enable forbidgo
2025-09-09 15:44:27 +05:30
primus-bot[bot]
57013e1c4f chore(release): bump to v0.94.1 (#9045)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-09-09 08:11:33 +00:00
Amlan Kumar Nandy
717efaf167 feat: base setup for new create alerts page (#8957) 2025-09-09 07:56:29 +00:00
Vikrant Gupta
6709b09646 fix(user): populate correct error message on client (#9043)
* fix(user): populate correct error message on client

* fix(user): populate correct error message on client
2025-09-09 13:05:07 +05:30
primus-bot[bot]
144e866afc chore(release): bump to v0.94.0 (#9040)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-09-08 22:26:06 +05:30
Nageshbansal
3f2763251a chore(clickhouse-upgrade): updates docker and docker swarm compose for clickhouse-25.5.6 (#9031) 2025-09-08 19:54:58 +05:30
Srikanth Chekuri
e67a576c07 chore: update CODEOWNERS (#9030) 2025-09-08 10:37:51 +00:00
aniketio-ctrl
c737a7e070 chore: enable alertmanager metrics collection with instrumentation::metrics (#9020)
* feat(alerts-v2): exposed alertmanager metrics for signozalertmanager

* feat(alerts-v2): exposed alertmanager metrics for signozalertmanager

* feat(alerts-v2): exposed alertmanager metrics for signozalertmanager

* feat(notification-routing): added custom meter provider

* feat(notification-routing): added custom meter provider

* feat(notification-routing): added custom meter provider

* feat(notification-routing): added custom meter provider

* feat(notification-routing): added org id label
2025-09-08 14:41:18 +05:30
288 changed files with 15794 additions and 3118 deletions

View File

@@ -1,6 +1,6 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
container_name: clickhouse
volumes:
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
@@ -23,6 +23,8 @@ services:
retries: 3
depends_on:
- zookeeper
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
zookeeper:
image: signoz/zookeeper:3.7.1
container_name: zookeeper
@@ -40,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.2
image: signoz/signoz-schema-migrator:v0.129.5
container_name: schema-migrator-sync
command:
- sync
@@ -53,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.2
image: signoz/signoz-schema-migrator:v0.129.5
container_name: schema-migrator-async
command:
- async

39
.github/CODEOWNERS vendored
View File

@@ -5,6 +5,45 @@
/frontend/ @SigNoz/frontend @YounixM
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -8,6 +8,7 @@ linters:
- depguard
- iface
- unparam
- forbidigo
linters-settings:
sloglint:
@@ -24,6 +25,10 @@ linters-settings:
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

View File

@@ -78,4 +78,5 @@ Need assistance? Join our Slack community:
- Set up your [development environment](docs/contributing/development.md)
- Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo-docs.md)
- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future.
- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future.
- Write [integration tests](docs/contributing/go/integration.md)

View File

@@ -32,7 +32,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}

View File

@@ -2,7 +2,6 @@ package cmd
import (
"context"
"fmt"
"log/slog"
"os"
@@ -12,9 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
)
func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz.Config, error) {
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) {
config, err := signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
@@ -31,14 +31,10 @@ func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz.
return config, nil
}
func NewJWTSecret(_ context.Context, _ *slog.Logger) string {
func NewJWTSecret(ctx context.Context, logger *slog.Logger) string {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 {
fmt.Println("🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!")
fmt.Println("SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application.")
fmt.Println("Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access.")
fmt.Println("Please set the SIGNOZ_JWT_SECRET environment variable immediately.")
fmt.Println("For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
}
return jwtSecret

View File

@@ -35,7 +35,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}

View File

@@ -137,10 +137,7 @@ prometheus:
##################### Alertmanager #####################
alertmanager:
# Specifies the alertmanager provider to use.
provider: legacy
legacy:
# The API URL (with prefix) of the legacy Alertmanager instance.
api_url: http://localhost:9093/api
provider: signoz
signoz:
# The poll interval for periodically syncing the alertmanager with the config in the store.
poll_interval: 1m

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
tty: true
deploy:
labels:
@@ -37,6 +37,8 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -63,7 +65,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
command:
- bash
- -c
@@ -174,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.93.0
image: signoz/signoz:v0.95.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -207,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.2
image: signoz/signoz-otel-collector:v0.129.5
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -231,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.2
image: signoz/signoz-schema-migrator:v0.129.5
deploy:
restart_policy:
condition: on-failure

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
tty: true
deploy:
labels:
@@ -36,6 +36,8 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -60,7 +62,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
command:
- bash
- -c
@@ -115,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.93.0
image: signoz/signoz:v0.95.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -148,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.2
image: signoz/signoz-otel-collector:v0.129.5
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -174,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.2
image: signoz/signoz-schema-migrator:v0.129.5
deploy:
restart_policy:
condition: on-failure

View File

@@ -10,7 +10,7 @@ x-common: &common
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
# addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
tty: true
labels:
signoz.io/scrape: "true"
@@ -40,6 +40,8 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -65,7 +67,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
container_name: signoz-init-clickhouse
command:
- bash
@@ -177,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.93.0}
image: signoz/signoz:${VERSION:-v0.95.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -211,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.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -237,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-sync
command:
- sync
@@ -248,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-async
command:
- async

View File

@@ -9,8 +9,7 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
# addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
tty: true
labels:
signoz.io/scrape: "true"
@@ -36,6 +35,8 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -61,7 +62,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:24.1.2-alpine
image: clickhouse/clickhouse-server:25.5.6
container_name: signoz-init-clickhouse
command:
- bash
@@ -110,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.93.0}
image: signoz/signoz:${VERSION:-v0.95.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -143,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -165,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-sync
command:
- sync
@@ -177,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-async
command:
- async

View File

@@ -0,0 +1,213 @@
# Integration Tests
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality.
## How to set up the integration test environment?
### Prerequisites
Before running integration tests, ensure you have the following installed:
- Python 3.13+
- Poetry (for dependency management)
- Docker (for containerized services)
### Initial Setup
1. Navigate to the integration tests directory:
```bash
cd tests/integration
```
2. Install dependencies using Poetry:
```bash
poetry install --no-root
```
### Starting the Test Environment
To spin up all the containers necessary for writing integration tests and keep them running:
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup
```
This command will:
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.)
- Keep containers running due to the `--reuse` flag
- Verify that the setup is working correctly
### Stopping the Test Environment
When you're done writing integration tests, clean up the environment:
```bash
poetry run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown
```
This will destroy the running integration test setup and clean up resources.
## Understanding the Integration Test Framework
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services.
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
- **Why wiremock?** Well maintained, documented and extensible.
```
.
├── conftest.py
├── fixtures
│ ├── __init__.py
│ ├── auth.py
│ ├── clickhouse.py
│ ├── fs.py
│ ├── http.py
│ ├── migrator.py
│ ├── network.py
│ ├── postgres.py
│ ├── signoz.py
│ ├── sql.py
│ ├── sqlite.py
│ ├── types.py
│ └── zookeeper.py
├── poetry.lock
├── pyproject.toml
└── src
└── bootstrap
├── __init__.py
├── a_database.py
├── b_register.py
└── c_license.py
```
Each test suite follows some important principles:
1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`.
2. **Execution Order**: Files are prefixed with `a_`, `b_`, `c_` to ensure sequential execution.
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
### Test Suite Design
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
- **Functional Cohesion**: Group tests around a specific capability or service boundary
- **Data Flow**: Follow the path of data through related components
- **Change Patterns**: Components frequently modified together should be tested together
The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system.
Eg: The **bootstrap** integration test suite validates core system functionality:
- Database initialization
- Version check
Other test suites can be **pipelines, auth, querier.**
## How to write an integration test?
Now start writing an integration test. Create a new file `src/bootstrap/e_version.py` and paste the following:
```python
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_version(signoz: types.SigNoz) -> None:
response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2)
logger.info(response)
```
We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:**
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/e_version.py::test_version
```
> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test.
Here's another example of how to write a more comprehensive integration test:
```python
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_user_registration(signoz: types.SigNoz) -> None:
"""Test user registration functionality."""
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "testuser",
"orgId": "",
"orgName": "test.org",
"email": "test@example.com",
"password": "password123Z$",
},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["setupCompleted"] is True
```
## How to run integration tests?
### Running All Tests
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/
```
### Running Specific Test Categories
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/<suite>
# Run querier tests
poetry run pytest --basetemp=./tmp/ -vv --reuse src/querier/
# Run auth tests
poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/
```
### Running Individual Tests
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/<suite>/<file>.py::test_name
# Run test_register in file a_register.py in auth suite
poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/a_register.py::test_register
```
## How to configure different options for integration tests?
Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
Example:
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
```
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
- **Use the `--teardown` flag** when cleaning up to avoid resource leaks
- **Follow the naming convention** with alphabetical prefixes for test execution order
- **Use proper timeouts** in HTTP requests to avoid hanging tests
- **Clean up test data** between tests to avoid interference
- **Use descriptive test names** that clearly indicate what is being tested
- **Leverage fixtures** for common setup and authentication
- **Test both success and failure scenarios** to ensure robust functionality

View File

@@ -44,19 +44,6 @@ import (
"go.uber.org/zap"
)
type ServerOptions struct {
Config signoz.Config
SigNoz *signoz.SigNoz
HTTPHostPort string
PrivateHostPort string
PreferSpanMetrics bool
FluxInterval string
FluxIntervalForTraceDetail string
Cluster string
GatewayUrl string
Jwt *authtypes.JWT
}
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
@@ -69,11 +56,6 @@ type Server struct {
httpServer *http.Server
httpHostPort string
// private http
privateConn net.Listener
privateHTTP *http.Server
privateHostPort string
opampServer *opamp.Server
// Usage manager
@@ -183,7 +165,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
jwt: jwt,
ruleManager: rm,
httpHostPort: baseconst.HTTPHostPort,
privateHostPort: baseconst.PrivateHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
@@ -196,13 +177,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
s.httpServer = httpServer
privateServer, err := s.createPrivateServer(apiHandler)
if err != nil {
return nil, err
}
s.privateHTTP = privateServer
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
@@ -215,36 +189,6 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, error) {
r := baseapp.NewRouter()
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterPrivateRoutes(r)
c := cors.New(cors.Options{
//todo(amol): find out a way to add exact domain or
// ip here for alert manager
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
})
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
return &http.Server{
Handler: handler,
}, nil
}
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
@@ -310,19 +254,6 @@ func (s *Server) initListeners() error {
zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
// listen on private port to support internal services
privateHostPort := s.privateHostPort
if privateHostPort == "" {
return fmt.Errorf("baseconst.PrivateHostPort is required")
}
s.privateConn, err = net.Listen("tcp", privateHostPort)
if err != nil {
return err
}
zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.privateHostPort))
return nil
}
@@ -361,26 +292,6 @@ func (s *Server) Start(ctx context.Context) error {
}
}()
var privatePort int
if port, err := utils.GetPort(s.privateConn.Addr()); err == nil {
privatePort = port
}
go func() {
zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.privateHostPort))
switch err := s.privateHTTP.Serve(s.privateConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
zap.L().Info("private http server closed")
default:
zap.L().Error("Could not start private HTTP server", zap.Error(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
zap.L().Info("Starting OpAmp Websocket server", zap.String("addr", baseconst.OpAmpWsEndpoint))
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
@@ -400,12 +311,6 @@ func (s *Server) Stop(ctx context.Context) error {
}
}
if s.privateHTTP != nil {
if err := s.privateHTTP.Shutdown(ctx); err != nil {
return err
}
}
s.opampServer.Stop()
if s.ruleManager != nil {

View File

@@ -40,7 +40,7 @@ var IsDotMetricsEnabled = false
var IsPreferSpanMetrics = false
func init() {
if GetOrDefaultEnv(DotMetricsEnabled, "false") == "true" {
if GetOrDefaultEnv(DotMetricsEnabled, "true") == "true" {
IsDotMetricsEnabled = true
}

View File

@@ -35,7 +35,6 @@ import (
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
yaml "gopkg.in/yaml.v2"
)
const (
@@ -167,16 +166,9 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli()
end := ts.UnixMilli()
if r.EvalDelay() > 0 {
start = start - int64(r.EvalDelay().Milliseconds())
end = end - int64(r.EvalDelay().Milliseconds())
}
// round to minute otherwise we could potentially miss data
start = start - (start % (60 * 1000))
end = end - (end % (60 * 1000))
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
compositeQuery := r.Condition().CompositeQuery
@@ -253,10 +245,17 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
smpl, shouldAlert := r.ShouldAlert(*series)
if shouldAlert {
resultVector = append(resultVector, smpl)
if r.Condition() != nil && r.Condition().RequireMinPoints {
if len(series.Points) < r.Condition().RequiredNumPoints {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
@@ -296,10 +295,17 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
smpl, shouldAlert := r.ShouldAlert(*series)
if shouldAlert {
resultVector = append(resultVector, smpl)
if r.Condition().RequireMinPoints {
if len(series.Points) < r.Condition().RequiredNumPoints {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
@@ -499,7 +505,7 @@ func (r *AnomalyRule) String() string {
PreferredChannels: r.PreferredChannels(),
}
byt, err := yaml.Marshal(ar)
byt, err := json.Marshal(ar)
if err != nil {
return fmt.Sprintf("error marshaling alerting rule: %s", err.Error())
}

View File

@@ -3,8 +3,10 @@ package rules
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
@@ -20,6 +22,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
@@ -40,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -62,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evalution
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -84,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)

View File

@@ -44,11 +44,13 @@
"@sentry/react": "8.41.0",
"@sentry/webpack-plugin": "2.22.6",
"@signozhq/badge": "0.0.2",
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
@@ -275,6 +277,7 @@
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.4"
"form-data": "4.0.4",
"brace-expansion": "^2.0.2"
}
}

View File

@@ -0,0 +1,64 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const queryParams = new URLSearchParams();
queryParams.append('start', String(props.start));
queryParams.append('end', String(props.end));
queryParams.append('filter', props.filter);
props.columns.forEach((col) => {
queryParams.append('columns', col);
});
queryParams.append('order_by', props.orderBy);
queryParams.append('limit', String(props.limit));
queryParams.append('format', props.format);
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
responseType: 'blob', // Important: tell axios to handle response as blob
decompress: true, // Enable automatic decompression
headers: {
Accept: 'application/octet-stream', // Tell server we expect binary data
},
timeout: 0,
});
// Only proceed if the response status is 200
if (response.status !== 200) {
throw new Error(
`Failed to download data: server returned status ${response.status}`,
);
}
// Create blob URL from response data
const blob = new Blob([response.data], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// Create and configure download link
const link = document.createElement('a');
link.href = url;
// Get filename from Content-Disposition header or generate timestamped default
const filename =
response.headers['content-disposition']
?.split('filename=')[1]
?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`;
link.setAttribute('download', filename);
// Trigger download
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default downloadExportData;

View File

@@ -2,7 +2,7 @@ import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/loginPrecheck';
import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck';
const loginPrecheck = async (
props: Props,

View File

@@ -1,25 +1,21 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/loginPrecheck';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Signup } from 'types/api/user/loginPrecheck';
import { Props } from 'types/api/user/signup';
const signup = async (
props: Props,
): Promise<SuccessResponse<null | PayloadProps> | ErrorResponse> => {
const signup = async (props: Props): Promise<SuccessResponseV2<Signup>> => {
try {
const response = await axios.post(`/register`, {
const response = await axios.post<PayloadProps>(`/register`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data?.data,
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -2,10 +2,28 @@
position: relative;
padding-left: 20px;
& :is(h1, h2, h3, h4, h5, h6, p, &-section-title) {
margin-bottom: 12px;
}
&-content {
display: flex;
flex-direction: column;
gap: 32px;
}
&-section-title {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
}
.changelog-release-date {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
display: block;
margin-bottom: 12px;
}
&-list {
@@ -81,12 +99,7 @@
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
& :is(h1, h2, h3, h4, h5, h6, p, &-section-title) {
font-weight: 600;
color: var(--text-vanilla-100, #fff);
}
@@ -96,7 +109,8 @@
line-height: 32px;
}
h2 {
h2,
&-section-title {
font-size: 20px;
line-height: 28px;
}
@@ -108,6 +122,7 @@
overflow: hidden;
border-radius: 4px;
border: 1px solid var(--bg-slate-400, #1d212d);
margin-bottom: 28px;
}
.changelog-media-video {
@@ -124,17 +139,8 @@
&-line {
background-color: var(--bg-vanilla-300);
}
li,
p {
color: var(--text-ink-500);
}
h1,
h2,
h3,
h4,
h5,
h6 {
& :is(h1, h2, h3, h4, h5, h6, p, li, &-section-title) {
color: var(--text-ink-500);
}

View File

@@ -55,33 +55,35 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="inner-ball" />
</div>
<span className="changelog-release-date">{formattedReleaseDate}</span>
{changelog.features && changelog.features.length > 0 && (
<div className="changelog-renderer-list">
{changelog.features.map((feature) => (
<div key={feature.id}>
<h2>{feature.title}</h2>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
)}
{changelog.bug_fixes && changelog.bug_fixes.length > 0 && (
<div>
<h2>Bug Fixes</h2>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
{changelog.maintenance && changelog.maintenance.length > 0 && (
<div>
<h2>Maintenance</h2>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}
<div className="changelog-renderer-content">
{changelog.features && changelog.features.length > 0 && (
<div className="changelog-renderer-list">
{changelog.features.map((feature) => (
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
)}
{changelog.bug_fixes && changelog.bug_fixes.length > 0 && (
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
{changelog.maintenance && changelog.maintenance.length > 0 && (
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -49,6 +49,7 @@ function InputWithLabel({
value={inputValue}
onChange={handleChange}
name={label.toLowerCase()}
data-testid={`input-${label}`}
/>
{labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
{onClose && (

View File

@@ -208,7 +208,11 @@ function ListLogView({
fontSize={fontSize}
>
<div className="log-line">
<LogStateIndicator type={logType} fontSize={fontSize} />
<LogStateIndicator
fontSize={fontSize}
severityText={logData.severity_text}
severityNumber={logData.severity_number}
/>
<div>
<LogContainer fontSize={fontSize}>
{updatedSelecedFields.some((field) => field.name === 'body') && (

View File

@@ -7,7 +7,6 @@
height: 100%;
width: 3px;
border-radius: 50px;
background-color: transparent;
&.small {
min-height: 16px;
@@ -21,24 +20,107 @@
min-height: 24px;
}
&.INFO {
background-color: var(--bg-robin-500);
// Severity variant CSS classes using design tokens
// Trace variants -
&.severity-trace-0 {
background-color: var(--bg-forest-600);
}
&.WARNING,
&.WARN {
background-color: var(--bg-amber-500);
&.severity-trace-1 {
background-color: var(--bg-forest-500);
}
&.ERROR {
background-color: var(--bg-cherry-500);
}
&.TRACE {
&.severity-trace-2 {
background-color: var(--bg-forest-400);
}
&.DEBUG {
&.severity-trace-3 {
background-color: var(--bg-forest-300);
}
&.severity-trace-4 {
background-color: var(--bg-forest-200);
}
// Debug variants
&.severity-debug-0 {
background-color: var(--bg-aqua-600);
}
&.severity-debug-1 {
background-color: var(--bg-aqua-500);
}
&.FATAL {
&.severity-debug-2 {
background-color: var(--bg-aqua-400);
}
&.severity-debug-3 {
background-color: var(--bg-aqua-300);
}
&.severity-debug-4 {
background-color: var(--bg-aqua-200);
}
// Info variants
&.severity-info-0 {
background-color: var(--bg-robin-600);
}
&.severity-info-1 {
background-color: var(--bg-robin-500);
}
&.severity-info-2 {
background-color: var(--bg-robin-400);
}
&.severity-info-3 {
background-color: var(--bg-robin-300);
}
&.severity-info-4 {
background-color: var(--bg-robin-200);
}
// Warn variants
&.severity-warn-0 {
background-color: var(--bg-amber-600);
}
&.severity-warn-1 {
background-color: var(--bg-amber-500);
}
&.severity-warn-2 {
background-color: var(--bg-amber-400);
}
&.severity-warn-3 {
background-color: var(--bg-amber-300);
}
&.severity-warn-4 {
background-color: var(--bg-amber-200);
}
// Error variants
&.severity-error-0 {
background-color: var(--bg-cherry-600);
}
&.severity-error-1 {
background-color: var(--bg-cherry-500);
}
&.severity-error-2 {
background-color: var(--bg-cherry-400);
}
&.severity-error-3 {
background-color: var(--bg-cherry-300);
}
&.severity-error-4 {
background-color: var(--bg-cherry-200);
}
// Fatal variants
&.severity-fatal-0 {
background-color: var(--bg-sakura-600);
}
&.severity-fatal-1 {
background-color: var(--bg-sakura-500);
}
&.severity-fatal-2 {
background-color: var(--bg-sakura-400);
}
&.severity-fatal-3 {
background-color: var(--bg-sakura-300);
}
&.severity-fatal-4 {
background-color: var(--bg-sakura-200);
}
}
}

View File

@@ -6,37 +6,41 @@ import LogStateIndicator from './LogStateIndicator';
describe('LogStateIndicator', () => {
it('renders correctly with default props', () => {
const { container } = render(
<LogStateIndicator type="INFO" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator severityText="INFO" fontSize={FontSize.MEDIUM} />,
);
const indicator = container.firstChild as HTMLElement;
expect(indicator.classList.contains('log-state-indicator')).toBe(true);
expect(indicator.classList.contains('isActive')).toBe(false);
expect(container.querySelector('.line')).toBeTruthy();
expect(container.querySelector('.line')?.classList.contains('INFO')).toBe(
true,
);
expect(
container.querySelector('.line')?.classList.contains('severity-info-0'),
).toBe(true);
});
it('renders correctly with different types', () => {
const { container: containerInfo } = render(
<LogStateIndicator type="INFO" fontSize={FontSize.MEDIUM} />,
);
expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe(
true,
);
const { container: containerWarning } = render(
<LogStateIndicator type="WARNING" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator severityText="INFO" fontSize={FontSize.MEDIUM} />,
);
expect(
containerWarning.querySelector('.line')?.classList.contains('WARNING'),
containerInfo.querySelector('.line')?.classList.contains('severity-info-0'),
).toBe(true);
const { container: containerWarning } = render(
<LogStateIndicator severityText="WARNING" fontSize={FontSize.MEDIUM} />,
);
expect(
containerWarning
.querySelector('.line')
?.classList.contains('severity-warn-0'),
).toBe(true);
const { container: containerError } = render(
<LogStateIndicator type="ERROR" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator severityText="ERROR" fontSize={FontSize.MEDIUM} />,
);
expect(
containerError.querySelector('.line')?.classList.contains('ERROR'),
containerError
.querySelector('.line')
?.classList.contains('severity-error-0'),
).toBe(true);
});
});

View File

@@ -3,6 +3,8 @@ import './LogStateIndicator.styles.scss';
import cx from 'classnames';
import { FontSize } from 'container/OptionsMenu/types';
import { getLogTypeBySeverityNumber } from './utils';
export const SEVERITY_TEXT_TYPE = {
TRACE: 'TRACE',
TRACE2: 'TRACE2',
@@ -42,18 +44,112 @@ export const LogType = {
UNKNOWN: 'UNKNOWN',
} as const;
// Severity variant mapping to CSS classes
const SEVERITY_VARIANT_CLASSES: Record<string, string> = {
// Trace variants - forest-600 to forest-200
TRACE: 'severity-trace-0',
Trace: 'severity-trace-1',
trace: 'severity-trace-2',
trc: 'severity-trace-3',
Trc: 'severity-trace-4',
// Debug variants - aqua-600 to aqua-200
DEBUG: 'severity-debug-0',
Debug: 'severity-debug-1',
debug: 'severity-debug-2',
dbg: 'severity-debug-3',
Dbg: 'severity-debug-4',
// Info variants - robin-600 to robin-200
INFO: 'severity-info-0',
Info: 'severity-info-1',
info: 'severity-info-2',
Information: 'severity-info-3',
information: 'severity-info-4',
// Warn variants - amber-600 to amber-200
WARN: 'severity-warn-0',
WARNING: 'severity-warn-0',
Warn: 'severity-warn-1',
warn: 'severity-warn-2',
warning: 'severity-warn-3',
Warning: 'severity-warn-4',
wrn: 'severity-warn-3',
Wrn: 'severity-warn-4',
// Error variants - cherry-600 to cherry-200
// eslint-disable-next-line sonarjs/no-duplicate-string
ERROR: 'severity-error-0',
Error: 'severity-error-1',
error: 'severity-error-2',
err: 'severity-error-3',
Err: 'severity-error-4',
ERR: 'severity-error-0',
fail: 'severity-error-2',
Fail: 'severity-error-3',
FAIL: 'severity-error-0',
// Fatal variants - sakura-600 to sakura-200
// eslint-disable-next-line sonarjs/no-duplicate-string
FATAL: 'severity-fatal-0',
Fatal: 'severity-fatal-1',
fatal: 'severity-fatal-2',
// eslint-disable-next-line sonarjs/no-duplicate-string
critical: 'severity-fatal-3',
Critical: 'severity-fatal-4',
CRITICAL: 'severity-fatal-0',
crit: 'severity-fatal-3',
Crit: 'severity-fatal-4',
CRIT: 'severity-fatal-0',
panic: 'severity-fatal-2',
Panic: 'severity-fatal-3',
PANIC: 'severity-fatal-0',
};
function getSeverityClass(
severityText?: string,
severityNumber?: number,
): string {
// Priority 1: Use severityText for exact variant mapping
if (severityText) {
const variantClass = SEVERITY_VARIANT_CLASSES[severityText.trim()];
if (variantClass) {
return variantClass;
}
}
// Priority 2: Use severityNumber for base color (use middle shade as default)
if (severityNumber) {
const logType = getLogTypeBySeverityNumber(severityNumber);
if (logType !== LogType.UNKNOWN) {
return `severity-${logType.toLowerCase()}-0`; // Use middle shade (index 2)
}
}
return 'severity-info-0'; // Fallback to CSS classes based on type
}
function LogStateIndicator({
type,
fontSize,
severityText,
severityNumber,
}: {
type: string;
fontSize: FontSize;
severityText?: string;
severityNumber?: number;
}): JSX.Element {
const severityClass = getSeverityClass(severityText, severityNumber);
return (
<div className="log-state-indicator">
<div className={cx('line', type, fontSize)}> </div>
<div className={cx('line', fontSize, severityClass)} />
</div>
);
}
LogStateIndicator.defaultProps = {
severityText: '',
severityNumber: 0,
};
export default LogStateIndicator;

View File

@@ -41,7 +41,7 @@ const getLogTypeBySeverityText = (severityText: string): string => {
};
// https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
const getLogTypeBySeverityNumber = (severityNumber: number): string => {
export const getLogTypeBySeverityNumber = (severityNumber: number): string => {
if (severityNumber < 1) {
return LogType.UNKNOWN;
}

View File

@@ -1,6 +1,5 @@
import './RawLogView.styles.scss';
import { DrawerProps } from 'antd';
import { Color } from '@signozhq/design-tokens';
import { DrawerProps, Tooltip } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -26,7 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import { RawLogContent, RawLogViewContainer } from './styles';
import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles';
import { RawLogViewProps } from './types';
function RawLogView({
@@ -35,12 +34,17 @@ function RawLogView({
data,
linesPerRow,
isTextOverflowEllipsisDisabled,
isHighlighted,
helpTooltip,
selectedFields = [],
fontSize,
onLogClick,
}: RawLogViewProps): JSX.Element {
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
data.id,
);
const {
isHighlighted: isUrlHighlighted,
isLogsExplorerPage,
onLogCopy,
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
@@ -126,12 +130,20 @@ function RawLogView({
formatTimezoneAdjustedTimestamp,
]);
const handleClickExpand = useCallback(() => {
if (activeContextLog || isReadOnly) return;
const handleClickExpand = useCallback(
(event: MouseEvent) => {
if (activeContextLog || isReadOnly) return;
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
onLogClick(data, event);
} else {
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}
},
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
(
@@ -183,16 +195,30 @@ function RawLogView({
align="middle"
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isHighlighted}
$isHightlightedLog={isUrlHighlighted}
$isActiveLog={
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
}
$isCustomHighlighted={isHighlighted}
$logType={logType}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
fontSize={fontSize}
>
<LogStateIndicator type={logType} fontSize={fontSize} />
<LogStateIndicator
fontSize={fontSize}
severityText={data.severity_text}
severityNumber={data.severity_number}
/>
{helpTooltip && (
<Tooltip title={helpTooltip} placement="top" mouseEnterDelay={0.5}>
<InfoIconWrapper
size={14}
className="help-tooltip-icon"
color={Color.BG_VANILLA_400}
/>
</Tooltip>
)}
<RawLogContent
className="raw-log-content"
@@ -236,6 +262,7 @@ RawLogView.defaultProps = {
isActiveLog: false,
isReadOnly: false,
isTextOverflowEllipsisDisabled: false,
isHighlighted: false,
};
export default RawLogView;

View File

@@ -3,8 +3,13 @@ import { blue } from '@ant-design/colors';
import { Color } from '@signozhq/design-tokens';
import { Col, Row, Space } from 'antd';
import { FontSize } from 'container/OptionsMenu/types';
import { Info } from 'lucide-react';
import styled from 'styled-components';
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
import {
getActiveLogBackground,
getCustomHighlightBackground,
getDefaultLogBackground,
} from 'utils/logs';
import { RawLogContentProps } from './types';
@@ -13,6 +18,7 @@ export const RawLogViewContainer = styled(Row)<{
$isReadOnly?: boolean;
$isActiveLog?: boolean;
$isHightlightedLog: boolean;
$isCustomHighlighted?: boolean;
$logType: string;
fontSize: FontSize;
}>`
@@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{
};
transition: background-color 2s ease-in;`
: ''}
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
`;
export const InfoIconWrapper = styled(Info)`
display: flex;
align-items: center;
margin-right: 4px;
cursor: help;
flex-shrink: 0;
height: auto;
`;
export const ExpandIconWrapper = styled(Col)`

View File

@@ -1,4 +1,5 @@
import { FontSize } from 'container/OptionsMenu/types';
import { MouseEvent } from 'react';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -6,10 +7,13 @@ export interface RawLogViewProps {
isActiveLog?: boolean;
isReadOnly?: boolean;
isTextOverflowEllipsisDisabled?: boolean;
isHighlighted?: boolean;
helpTooltip?: string;
data: ILog;
linesPerRow: number;
fontSize: FontSize;
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
}
export interface RawLogContentProps {

View File

@@ -11,7 +11,6 @@ import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorTypeForTable } from '../LogStateIndicator/utils';
import {
defaultListViewPanelStyle,
defaultTableStyle,
@@ -93,8 +92,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
children: (
<div className={cx('state-indicator', fontSize)}>
<LogStateIndicator
type={getLogIndicatorTypeForTable(item)}
fontSize={fontSize}
severityText={item.severity_text as string}
severityNumber={item.severity_number as number}
/>
</div>
),

View File

@@ -0,0 +1,86 @@
.logs-download-popover {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
var(--bg-ink-400) 0%,
var(--bg-ink-500) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0 8px 12px 8px;
margin: 6px 0;
}
.export-options-container {
width: 240px;
border-radius: 4px;
.title {
display: flex;
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format,
.row-limit,
.columns-scope {
padding: 12px 4px;
display: flex;
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 13px;
}
}
.horizontal-line {
height: 1px;
background: var(--bg-slate-400);
}
.export-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.lightMode {
.logs-download-popover {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
var(--bg-vanilla-100) 0%,
var(--bg-vanilla-300) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
}
.export-options-container {
.title {
color: var(--bg-ink-200);
}
:global(.ant-radio-wrapper) {
color: var(--bg-ink-400);
}
.horizontal-line {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,341 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DownloadFormats, DownloadRowCounts } from './constants';
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
// Mock antd message
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const TEST_IDS = {
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
} as const;
interface TestProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
const createTestProps = (): TestProps => ({
startTime: 1631234567890,
endTime: 1631234567999,
filter: 'status = 200',
columns: [
{
name: 'http.status',
fieldContext: 'attribute',
fieldDataType: 'int64',
} as TelemetryFieldKey,
],
orderBy: 'timestamp:desc',
});
const testRenderContent = (props: TestProps): void => {
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
};
const testSuccessResponse = (res: any, ctx: any): any =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
ctx.body('id,value\n1,2\n'),
);
describe('LogsDownloadOptionsMenu', () => {
const BASE_URL = ENVIRONMENT.baseURL;
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
let requestSpy: jest.Mock<any, any>;
const setupDefaultServer = (): void => {
server.use(
rest.get(EXPORT_URL, (req, res, ctx) => {
const params = req.url.searchParams;
const payload = {
start: Number(params.get('start')),
end: Number(params.get('end')),
filter: params.get('filter'),
columns: params.getAll('columns'),
order_by: params.get('order_by'),
limit: Number(params.get('limit')),
format: params.get('format'),
};
requestSpy(payload);
return testSuccessResponse(res, ctx);
}),
);
};
// Mock URL.createObjectURL used by download logic
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
requestSpy = jest.fn();
setupDefaultServer();
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
// jsdom doesn't implement it by default
((URL as unknown) as {
createObjectURL: (b: Blob) => string;
}).createObjectURL = jest.fn(() => 'blob:mock');
((URL as unknown) as {
revokeObjectURL: (u: string) => void;
}).revokeObjectURL = jest.fn();
});
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
// restore
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders download button', () => {
const props = createTestProps();
testRenderContent(props);
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
const props = createTestProps();
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: ['attribute.http.status:int64'],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: [],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('handles successful export with success message', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
// Override handler to return 500 for this test
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
'Failed to export logs. Please try again.',
);
});
});
it('handles UI state correctly during export process', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
);
const props = createTestProps();
testRenderContent(props);
// Open popover
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Start export
fireEvent.click(screen.getByText('Export'));
// Check button is disabled during export
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
// Check popover is closed immediately after export starts
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for export to complete and verify button is enabled again
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
});
});
it('uses filename from Content-Disposition and triggers download click', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
ctx.body('row\n'),
),
),
);
const originalCreateElement = document.createElement.bind(document);
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
const clickSpy = jest.spyOn(anchorEl, 'click');
const removeSpy = jest.spyOn(anchorEl, 'remove');
const createElSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: any): any =>
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
);
const appendSpy = jest.spyOn(document.body, 'appendChild');
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
createElSpy.mockRestore();
appendSpy.mockRestore();
});
});

View File

@@ -0,0 +1,170 @@
import './LogsDownloadOptionsMenu.styles.scss';
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DownloadColumnsScopes,
DownloadFormats,
DownloadRowCounts,
} from './constants';
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
return `${prefix}${key.name}${suffix}`;
}
interface LogsDownloadOptionsMenuProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
export default function LogsDownloadOptionsMenu({
startTime,
endTime,
filter,
columns,
orderBy,
}: LogsDownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
const [columnsScope, setColumnsScope] = useState<string>(
DownloadColumnsScopes.ALL,
);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handleExportRawData = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
try {
setIsDownloading(true);
const downloadOptions = {
source: 'logs',
start: startTime,
end: endTime,
columns:
columnsScope === DownloadColumnsScopes.SELECTED
? columns.map((col) => convertTelemetryFieldKeyToText(col))
: [],
filter,
orderBy,
format: exportFormat,
limit: rowLimit,
};
await downloadExportData(downloadOptions);
message.success('Export completed successfully');
} catch (error) {
console.error('Error exporting logs:', error);
message.error('Failed to export logs. Please try again.');
} finally {
setIsDownloading(false);
}
}, [
startTime,
endTime,
columnsScope,
columns,
filter,
orderBy,
exportFormat,
rowLimit,
setIsDownloading,
setIsPopoverOpen,
]);
const popoverContent = useMemo(
() => (
<div
className="export-options-container"
role="dialog"
aria-label="Export options"
aria-modal="true"
>
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<Radio.Group
value={exportFormat}
onChange={(e): void => setExportFormat(e.target.value)}
>
<Radio value={DownloadFormats.CSV}>csv</Radio>
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="row-limit">
<Typography.Text className="title">Number of Rows</Typography.Text>
<Radio.Group
value={rowLimit}
onChange={(e): void => setRowLimit(e.target.value)}
>
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExportRawData}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
>
Export
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="logs-download-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<DownloadIcon size={15} />
)
}
data-testid="periscope-btn-download-options"
disabled={isDownloading}
/>
</Tooltip>
</Popover>
);
}

View File

@@ -0,0 +1,15 @@
export const DownloadFormats = {
CSV: 'csv',
JSONL: 'jsonl',
};
export const DownloadColumnsScopes = {
ALL: 'all',
SELECTED: 'selected',
};
export const DownloadRowCounts = {
TEN_K: 10_000,
THIRTY_K: 30_000,
FIFTY_K: 50_000,
};

View File

@@ -3,24 +3,30 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, ChevronLeft, ChevronRight, Minus, Plus, X } from 'lucide-react';
import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
Sliders,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface LogsFormatOptionsMenuProps {
title: string;
items: any;
selectedOptionFormat: any;
config: OptionsMenuConfig;
}
export default function LogsFormatOptionsMenu({
title,
items,
selectedOptionFormat,
config,
@@ -43,6 +49,7 @@ export default function LogsFormatOptionsMenu({
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
@@ -202,7 +209,7 @@ export default function LogsFormatOptionsMenu({
};
}, [selectedValue]);
return (
const popoverContent = (
<div
className={cx(
'nested-menu-container',
@@ -344,7 +351,7 @@ export default function LogsFormatOptionsMenu({
</div>
<div className="horizontal-line" />
<div className="menu-container">
<div className="title"> {title} </div>
<div className="title">FORMAT</div>
<div className="menu-items">
{items.map(
@@ -440,4 +447,21 @@ export default function LogsFormatOptionsMenu({
)}
</div>
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="format-options-popover"
>
<Button
className="periscope-btn ghost"
icon={<Sliders size={14} />}
data-testid="periscope-btn-format-options"
/>
</Popover>
);
}

View File

@@ -9,7 +9,7 @@ import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/Orde
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { isEmpty } from 'lodash-es';
import { get, isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -34,6 +34,14 @@ const ADD_ONS_KEYS = {
LEGEND_FORMAT: 'legend_format',
};
const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
[ADD_ONS_KEYS.HAVING]: 'having.expression',
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
};
const ADD_ONS = [
{
icon: <BarChart2 size={14} />,
@@ -91,6 +99,9 @@ const REDUCE_TO = {
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
// Custom tooltip content component
function TooltipContent({
label,
@@ -195,21 +206,29 @@ function QueryAddOns({
}
}
// add reduce to if showReduceTo is true
if (showReduceTo) {
filteredAddOns = [...filteredAddOns, REDUCE_TO];
}
setAddOns(filteredAddOns);
// Filter selectedViews to only include add-ons present in filteredAddOns
setSelectedViews((prevSelectedViews) =>
prevSelectedViews.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
ADD_ONS.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelType, isListViewPanel, query.dataSource]);
}, [panelType, isListViewPanel, query]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {
@@ -285,7 +304,7 @@ function QueryAddOns({
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === 'group_by') && (
<div className="add-on-content">
<div className="add-on-content" data-testid="group-by-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -321,7 +340,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'having') && (
<div className="add-on-content">
<div className="add-on-content" data-testid="having-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -353,7 +372,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'limit') && (
<div className="add-on-content">
<div className="add-on-content" data-testid="limit-content">
<InputWithLabel
label="Limit"
onChange={handleChangeLimit}
@@ -367,7 +386,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'order_by') && (
<div className="add-on-content">
<div className="add-on-content" data-testid="order-by-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -405,7 +424,7 @@ function QueryAddOns({
)}
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
<div className="add-on-content">
<div className="add-on-content" data-testid="reduce-to-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -436,7 +455,7 @@ function QueryAddOns({
)}
{selectedViews.find((view) => view.key === 'legend_format') && (
<div className="add-on-content">
<div className="add-on-content" data-testid="legend-format-content">
<InputWithLabel
label="Legend format"
placeholder="Write legend format"

View File

@@ -23,6 +23,7 @@ import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
QUERY_BUILDER_KEY_TYPES,
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
@@ -1076,11 +1077,11 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = [
{ label: 'HAS', type: 'function' },
{ label: 'HASANY', type: 'function' },
{ label: 'HASALL', type: 'function' },
];
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);

View File

@@ -0,0 +1,186 @@
/* eslint-disable */
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: () => ({
handleChangeQueryData: mockHandleChangeQueryData,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: () => ({
handleSetQueryData: mockHandleSetQueryData,
}),
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: ({ onChange }: any) => (
<button data-testid="groupby" onClick={() => onChange(['service.name'])}>
GroupByFilter
</button>
),
}));
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
OrderByFilter: ({ onChange }: any) => (
<button
data-testid="orderby"
onClick={() => onChange([{ columnName: 'duration', order: 'desc' }])}
>
OrderByFilter
</button>
),
}));
jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
__esModule: true,
default: ({ onChange, onClose }: any) => (
<div>
<button data-testid="having-change" onClick={() => onChange('p99 > 500')}>
HavingFilter
</button>
<button data-testid="having-close" onClick={onClose}>
close
</button>
</div>
),
}));
jest.mock(
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
() => ({
ReduceToFilter: ({ onChange }: any) => (
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
ReduceToFilter
</button>
),
}),
);
function baseQuery(overrides: Partial<any> = {}): any {
return {
dataSource: DataSource.TRACES,
aggregations: [{ id: 'a', operator: 'count' }],
groupBy: [],
orderBy: [],
legend: '',
limit: null,
having: { expression: '' },
...overrides,
};
}
describe('QueryAddOns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('VALUE panel: no sections auto-open when query has no active add-ons', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.VALUE}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.queryByTestId('legend-format-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('reduce-to-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('order-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('limit-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('having-content')).not.toBeInTheDocument();
});
it('hides group-by section for METRICS even if groupBy is set in query', () => {
render(
<QueryAddOns
query={baseQuery({
dataSource: DataSource.METRICS,
groupBy: ['service.name'],
})}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
});
it('defaults to Order By open in list view panel', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel
showReduceTo={false}
panelType={PANEL_TYPES.LIST}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
});
it('limit input auto-opens when limit is set and changing it calls handler', () => {
render(
<QueryAddOns
query={baseQuery({ limit: 5 })}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const input = screen.getByTestId('input-Limit') as HTMLInputElement;
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],
limit: 7,
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
const limitInput = screen.getByTestId('input-Limit') as HTMLInputElement;
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(limitInput.value).toBe('7');
});
});

View File

@@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
}

View File

@@ -0,0 +1,108 @@
.span-hover-card {
width: 206px;
.ant-popover-inner {
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.32) 0%,
rgba(18, 19, 23, 0.36) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--bg-slate-500);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.32) 0%,
rgba(18, 19, 23, 0.36) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--bg-vanilla-400);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--bg-cherry-500);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 174px;
margin-top: 8px;
}
&__label {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
rgba(229, 72, 77, 0) 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-cherry-500);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -0,0 +1,101 @@
import './SpanHoverCard.styles.scss';
import { Popover, Typography } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { ReactNode } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
duration,
);
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const {
time: relativeTime,
timeUnitName: relativeTimeUnit,
} = convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp).format(
DATE_TIME_FORMATS.SPAN_POPOVER_DATE,
);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -62,7 +62,7 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
useEffect(() => {
onCreateRef.current = onCreate;
onDeleteRef.current = onDelete;
});
}, [onCreate, onDelete]);
const destroy = useCallback((chart: uPlot | null) => {
if (chart) {
@@ -71,12 +71,25 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
chartRef.current = null;
}
// remove chart tooltip on cleanup
// Clean up tooltip overlay that might be detached
const overlay = document.getElementById('overlay');
if (overlay) {
// Remove all child elements from overlay
while (overlay.firstChild) {
overlay.removeChild(overlay.firstChild);
}
overlay.style.display = 'none';
}
// Clean up any remaining tooltips that might be detached
const tooltips = document.querySelectorAll(
'.uplot-tooltip, .tooltip-container',
);
tooltips.forEach((tooltip) => {
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
});
}, []);
const create = useCallback(() => {

View File

@@ -42,6 +42,7 @@ export const QUERY_BUILDER_FUNCTIONS = {
HAS: 'has',
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
};
export function negateOperator(operatorOrFunction: string): string {

View File

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

View File

@@ -83,4 +83,7 @@ export const REACT_QUERY_KEY = {
// Quick Filters Query Keys
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
} as const;

View File

@@ -3168,7 +3168,6 @@ export const getStatusCodeBarChartWidgetData = (
},
description: '',
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
isStacked: false,
panelTypes: PANEL_TYPES.BAR,
title: '',
opacity: '',

View File

@@ -0,0 +1,82 @@
import './styles.scss';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';
import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
label: 'Threshold',
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
if (!showMultipleTabs) {
return;
}
setAlertType(value);
};
const getTabTooltip = (tab: { value: AlertTypes }): string => {
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
return ANOMALY_TAB_TOOLTIP;
}
return THRESHOLD_TAB_TOOLTIP;
};
return (
<div className="alert-condition-container">
<Stepper stepNumber={2} label="Set alert conditions" />
<div className="alert-condition">
<div className="alert-condition-tabs">
{tabs.map((tab) => (
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
<Button
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,
})}
onClick={(): void => {
if (alertType !== tab.value) {
handleAlertTypeChange(tab.value as AlertTypes);
}
}}
>
{tab.icon}
{tab.label}
</Button>
</Tooltip>
))}
</div>
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
</div>
);
}
export default AlertCondition;

View File

@@ -0,0 +1,162 @@
import './styles.scss';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import {
INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD,
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
getQueryNames,
} from './utils';
function AlertThreshold(): JSX.Element {
const {
alertState,
thresholdState,
setThresholdState,
} = useCreateAlertState();
const { data, isLoading: isLoadingChannels } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory || '',
);
const addThreshold = (): void => {
let newThreshold;
if (thresholdState.thresholds.length === 1) {
newThreshold = INITIAL_WARNING_THRESHOLD;
} else if (thresholdState.thresholds.length === 2) {
newThreshold = INITIAL_INFO_THRESHOLD;
} else {
newThreshold = INITIAL_RANDOM_THRESHOLD;
}
setThresholdState({
type: 'SET_THRESHOLDS',
payload: [...thresholdState.thresholds, newThreshold],
});
};
const removeThreshold = (id: string): void => {
if (thresholdState.thresholds.length > 1) {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.filter((t) => t.id !== id),
});
}
};
const updateThreshold: UpdateThreshold = (id, field, value) => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
),
});
};
return (
<div className="alert-threshold-container">
{/* Main condition sentence */}
<div className="alert-condition-sentences">
<div className="alert-condition-sentence">
<Typography.Text className="sentence-text">
Send a notification when
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
</div>
<div className="alert-condition-sentence">
<Select
value={thresholdState.operator}
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
payload: value,
});
}}
style={{ width: 120 }}
options={THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text className="sentence-text">
the threshold(s)
</Typography.Text>
<Select
value={thresholdState.matchType}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: value,
});
}}
style={{ width: 140 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text className="sentence-text">
during the <strong>Evaluation Window.</strong>
</Typography.Text>
</div>
</div>
<div className="thresholds-section">
{thresholdState.thresholds.map((threshold, index) => (
<ThresholdItem
key={threshold.id}
threshold={threshold}
updateThreshold={updateThreshold}
removeThreshold={removeThreshold}
showRemoveButton={index !== 0 && thresholdState.thresholds.length > 1}
channels={channels}
isLoadingChannels={isLoadingChannels}
units={categorySelectOptions}
/>
))}
<Button
type="dashed"
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
>
Add Threshold
</Button>
</div>
</div>
);
}
export default AlertThreshold;

View File

@@ -0,0 +1,174 @@
import { Select, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
import {
ANOMALY_ALGORITHM_OPTIONS,
ANOMALY_SEASONALITY_OPTIONS,
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import { getQueryNames } from './utils';
function AnomalyThreshold(): JSX.Element {
const { thresholdState, setThresholdState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
const deviationOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 7; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const updateThreshold = (id: string, field: string, value: string): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
),
});
};
return (
<div className="anomaly-threshold-container">
<div className="alert-condition-sentences">
{/* Sentence 1 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="notification-text" className="sentence-text">
Send notification when the observed value for
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
data-testid="query-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
<Typography.Text
data-testid="evaluation-window-text"
className="sentence-text"
>
during the last
</Typography.Text>
<Select
value={thresholdState.evaluationWindow}
data-testid="evaluation-window-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_EVALUATION_WINDOW',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_TIME_DURATION_OPTIONS}
/>
</div>
{/* Sentence 2 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="threshold-text" className="sentence-text">
is
</Typography.Text>
<Select
value={thresholdState.thresholds[0].thresholdValue}
data-testid="threshold-value-select"
onChange={(value): void => {
updateThreshold(
thresholdState.thresholds[0].id,
'thresholdValue',
value.toString(),
);
}}
style={{ width: 80 }}
options={deviationOptions}
/>
<Typography.Text data-testid="deviations-text" className="sentence-text">
deviations
</Typography.Text>
<Select
value={thresholdState.operator}
data-testid="operator-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text
data-testid="predicted-data-text"
className="sentence-text"
>
the predicted data
</Typography.Text>
<Select
value={thresholdState.matchType}
data-testid="match-type-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/>
</div>
{/* Sentence 3 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="using-the-text" className="sentence-text">
using the
</Typography.Text>
<Select
value={thresholdState.algorithm}
data-testid="algorithm-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_ALGORITHM',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_ALGORITHM_OPTIONS}
/>
<Typography.Text
data-testid="algorithm-with-text"
className="sentence-text"
>
algorithm with
</Typography.Text>
<Select
value={thresholdState.seasonality}
data-testid="seasonality-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_SEASONALITY',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
</div>
</div>
</div>
);
}
export default AnomalyThreshold;

View File

@@ -0,0 +1,135 @@
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ThresholdItemProps } from './types';
function ThresholdItem({
threshold,
updateThreshold,
removeThreshold,
showRemoveButton,
channels,
units,
}: ThresholdItemProps): JSX.Element {
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => {
let component = (
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}
onChange={(value): void => updateThreshold(threshold.id, 'unit', value)}
style={{ width: 150 }}
options={units}
disabled={units.length === 0}
/>
);
if (units.length === 0) {
component = (
<Tooltip
trigger="hover"
title="Please select a Y-axis unit for the query first"
>
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}
onChange={(value): void => updateThreshold(threshold.id, 'unit', value)}
style={{ width: 150 }}
options={units}
disabled={units.length === 0}
/>
</Tooltip>
);
}
return component;
}, [units, threshold.unit, updateThreshold, threshold.id]);
return (
<div key={threshold.id} className="threshold-item">
<div className="threshold-row">
<div className="threshold-indicator">
<div
className="threshold-dot"
style={{ backgroundColor: threshold.color }}
/>
</div>
<Space className="threshold-controls">
<div className="threshold-inputs">
<Input.Group>
<Input
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 260 }}
/>
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
{yAxisUnitSelect}
</Input.Group>
</div>
<Typography.Text className="sentence-text">to</Typography.Text>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 260 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
/>
<Button.Group>
{!showRecoveryThreshold && (
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={(): void => setShowRecoveryThreshold(true)}
/>
)}
{showRemoveButton && (
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
)}
</Button.Group>
</Space>
</div>
{showRecoveryThreshold && (
<Input.Group className="recovery-threshold-input-group">
<Input
placeholder="Recovery threshold"
disabled
style={{ width: 260 }}
className="recovery-threshold-label"
/>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
</Input.Group>
)}
</div>
);
}
export default ThresholdItem;

View File

@@ -0,0 +1,271 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
const STEPPER_TEST_ID = 'stepper';
const ALERT_THRESHOLD_TEST_ID = 'alert-threshold';
const ANOMALY_THRESHOLD_TEST_ID = 'anomaly-threshold';
const THRESHOLD_VIEW_TEST_ID = 'threshold-view';
const ANOMALY_VIEW_TEST_ID = 'anomaly-view';
const ANOMALY_TAB_TEXT = 'Anomaly';
const THRESHOLD_TAB_TEXT = 'Threshold';
const ACTIVE_TAB_CLASS = '.active-tab';
// Mock the Stepper component
jest.mock('../../Stepper', () => ({
__esModule: true,
default: function MockStepper({
stepNumber,
label,
}: {
stepNumber: number;
label: string;
}): JSX.Element {
return (
<div data-testid={STEPPER_TEST_ID}>{`Step ${stepNumber}: ${label}`}</div>
);
},
}));
// Mock the AlertThreshold component
jest.mock('../AlertThreshold', () => ({
__esModule: true,
default: function MockAlertThreshold(): JSX.Element {
return (
<div data-testid={ALERT_THRESHOLD_TEST_ID}>Alert Threshold Component</div>
);
},
}));
// Mock the AnomalyThreshold component
jest.mock('../AnomalyThreshold', () => ({
__esModule: true,
default: function MockAnomalyThreshold(): JSX.Element {
return (
<div data-testid={ANOMALY_THRESHOLD_TEST_ID}>
Anomaly Threshold Component
</div>
);
},
}));
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
builder: { queryData: unknown[]; queryFormulas: unknown[] };
dataSource: string;
queryName: string;
};
redirectWithQueryBuilderData: () => void;
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
},
redirectWithQueryBuilderData: jest.fn(),
}),
}));
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined;
return render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<AlertCondition />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
};
describe('AlertCondition', () => {
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
'Step 2: Set alert conditions',
);
});
it('verifies default props and initial state', () => {
renderAlertCondition();
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// Verify default props
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// Click anomaly tab
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
// Should show anomaly component
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
// Click threshold tab
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
fireEvent.click(thresholdTab);
// Should show threshold component again
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
// Threshold tab should be active by default
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
// Click anomaly tab
fireEvent.click(anomalyTab);
// Anomaly tab should be active now
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
});
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
renderAlertCondition('ANOMALY_BASED_ALERT');
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
renderAlertCondition('LOGS_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('shows only threshold tab for TRACES_BASED_ALERT', () => {
renderAlertCondition('TRACES_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('shows only threshold tab for EXCEPTIONS_BASED_ALERT', () => {
renderAlertCondition('EXCEPTIONS_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,272 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
// Mock the ThresholdItem component
jest.mock('../ThresholdItem', () => ({
__esModule: true,
default: function MockThresholdItem({
threshold,
removeThreshold,
showRemoveButton,
}: {
threshold: Record<string, unknown>;
removeThreshold: (id: string) => void;
showRemoveButton: boolean;
}): JSX.Element {
return (
<div data-testid={`threshold-item-${threshold.id}`}>
<span>{threshold.label as string}</span>
{showRemoveButton && (
<button
type="button"
data-testid={`remove-threshold-${threshold.id}`}
onClick={(): void => removeThreshold(threshold.id as string)}
>
Remove
</button>
)}
</div>
);
},
}));
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
dataSource: string;
queryName: string;
builder: {
queryData: Array<{ queryName: string }>;
queryFormulas: Array<{ queryName: string }>;
};
unit: string;
};
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }],
queryFormulas: [{ queryName: 'Formula 1' }],
},
unit: 'bytes',
},
}),
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
}));
// Mock alert format categories
jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
getCategoryByOptionId: jest.fn(() => ({ name: 'bytes' })),
getCategorySelectOptionByName: jest.fn(() => [
{ label: 'Bytes', value: 'bytes' },
{ label: 'KB', value: 'kb' },
]),
}));
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'IS ABOVE',
} as const;
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertThreshold = (): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<AlertThreshold />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
};
const verifySelectRenders = (title: string): void => {
const select = screen.getByTitle(title);
expect(select).toBeInTheDocument();
};
describe('AlertThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the main condition sentence', () => {
renderAlertThreshold();
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
expect(screen.getByText('the threshold(s)')).toBeInTheDocument();
expect(screen.getByText('during the')).toBeInTheDocument();
expect(screen.getByText('Evaluation Window.')).toBeInTheDocument();
});
it('renders query selection dropdown', async () => {
renderAlertThreshold();
await waitFor(() => {
const querySelect = screen.getByTitle('A');
expect(querySelect).toBeInTheDocument();
});
});
it('renders operator selection dropdown', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
});
it('renders match type selection dropdown', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
it('renders threshold items', () => {
renderAlertThreshold();
expect(screen.getByTestId(/threshold-item-/)).toBeInTheDocument();
});
it('renders add threshold button', () => {
renderAlertThreshold();
expect(screen.getByText(TEST_STRINGS.ADD_THRESHOLD)).toBeInTheDocument();
});
it('adds a new threshold when add button is clicked', () => {
renderAlertThreshold();
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Should now have multiple threshold items
const thresholdItems = screen.getAllByTestId(/threshold-item-/);
expect(thresholdItems).toHaveLength(2);
});
it('adds correct threshold types based on count', () => {
renderAlertThreshold();
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
// First addition should add WARNING threshold
fireEvent.click(addButton);
expect(screen.getByText('WARNING')).toBeInTheDocument();
// Second addition should add INFO threshold
fireEvent.click(addButton);
expect(screen.getByText('INFO')).toBeInTheDocument();
// Third addition should add random threshold
fireEvent.click(addButton);
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(4);
});
it('updates operator when operator dropdown changes', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
});
it('updates match type when match type dropdown changes', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
it('shows remove button for non-first thresholds', () => {
renderAlertThreshold();
// Add a threshold
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Second threshold should have remove button
expect(screen.getByTestId(/remove-threshold-/)).toBeInTheDocument();
});
it('does not show remove button for first threshold', () => {
renderAlertThreshold();
// First threshold should not have remove button
expect(screen.queryByTestId(/remove-threshold-/)).not.toBeInTheDocument();
});
it('removes threshold when remove button is clicked', () => {
renderAlertThreshold();
// Add a threshold first
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Get the remove button and click it
const removeButton = screen.getByTestId(/remove-threshold-/);
fireEvent.click(removeButton);
// Should be back to one threshold
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1);
});
it('does not remove threshold if only one remains', () => {
renderAlertThreshold();
// Should only have one threshold initially
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1);
// Try to remove (should not work)
const thresholdItems = screen.getAllByTestId(/threshold-item-/);
expect(thresholdItems).toHaveLength(1);
});
it('handles loading state for channels', () => {
renderAlertThreshold();
// Component should render even while channels are loading
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
});
it('renders with correct initial state', () => {
renderAlertThreshold();
// Should have initial critical threshold
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
});

View File

@@ -0,0 +1,89 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from 'container/CreateAlertV2/context/constants';
import * as context from '../../context';
import AnomalyThreshold from '../AnomalyThreshold';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
alertState: INITIAL_ALERT_STATE,
setAlertState: mockSetAlertState,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setThresholdState: mockSetThresholdState,
} as any);
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
dataSource: string;
queryName: string;
builder: {
queryData: Array<{ queryName: string }>;
queryFormulas: Array<{ queryName: string }>;
};
};
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }],
queryFormulas: [{ queryName: 'Formula 1' }],
},
},
}),
}));
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(<AnomalyThreshold />);
describe('AnomalyThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the first condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
});
it('renders the second condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
});
it('renders the third condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('using-the-text')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-select')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,398 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
// Mock the enableRecoveryThreshold utility
jest.mock('../../utils', () => ({
enableRecoveryThreshold: jest.fn(() => true),
}));
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
CRITICAL_LABEL: 'CRITICAL',
WARNING_LABEL: 'WARNING',
INFO_LABEL: 'INFO',
CHANNEL_1: 'channel-1',
CHANNEL_2: 'channel-2',
CHANNEL_3: 'channel-3',
EMAIL_CHANNEL_NAME: 'Email Channel',
ENTER_THRESHOLD_NAME: 'Enter threshold name',
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
} as const;
const mockThreshold = {
id: TEST_CONSTANTS.THRESHOLD_ID,
label: TEST_CONSTANTS.CRITICAL_LABEL,
thresholdValue: 100,
recoveryThresholdValue: 80,
unit: 'bytes',
channels: [TEST_CONSTANTS.CHANNEL_1],
color: '#ff0000',
};
const mockChannels: Channels[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,
} as any,
{ id: TEST_CONSTANTS.CHANNEL_2, name: 'Slack Channel' } as any,
{ id: TEST_CONSTANTS.CHANNEL_3, name: 'PagerDuty Channel' } as any,
];
const mockUnits: DefaultOptionType[] = [
{ label: 'Bytes', value: 'bytes' },
{ label: 'KB', value: 'kb' },
{ label: 'MB', value: 'mb' },
];
const defaultProps: ThresholdItemProps = {
threshold: mockThreshold,
updateThreshold: jest.fn(),
removeThreshold: jest.fn(),
showRemoveButton: false,
channels: mockChannels,
isLoadingChannels: false,
units: mockUnits,
};
const renderThresholdItem = (
props: Partial<ThresholdItemProps> = {},
): ReturnType<typeof render> => {
const mergedProps = { ...defaultProps, ...props };
return render(<ThresholdItem {...mergedProps} />);
};
const verifySelectorWidth = (
selectorIndex: number,
expectedWidth: string,
): void => {
const selectors = screen.getAllByRole('combobox');
const selector = selectors[selectorIndex];
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
};
const showRecoveryThreshold = (): void => {
const recoveryButton = screen.getByRole('button', { name: '' });
fireEvent.click(recoveryButton);
};
const verifyComponentRendersWithLoading = (): void => {
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_THRESHOLD_NAME),
).toBeInTheDocument();
};
const verifyUnitSelectorDisabled = (): void => {
const unitSelectors = screen.getAllByRole('combobox');
const unitSelector = unitSelectors[0]; // First combobox is the unit selector
expect(unitSelector).toBeDisabled();
};
describe('ThresholdItem', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders threshold indicator with correct color', () => {
renderThresholdItem();
// Find the threshold dot by its class
const thresholdDot = document.querySelector('.threshold-dot');
expect(thresholdDot).toHaveStyle('background-color: #ff0000');
});
it('renders threshold label input with correct value', () => {
renderThresholdItem();
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
expect(labelInput).toHaveValue(TEST_CONSTANTS.CRITICAL_LABEL);
});
it('renders threshold value input with correct value', () => {
renderThresholdItem();
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(valueInput).toHaveValue('100');
});
it('renders unit selector with correct value', () => {
renderThresholdItem();
// Check for the unit selector by looking for the displayed text
expect(screen.getByText('Bytes')).toBeInTheDocument();
});
it('renders channels selector with correct value', () => {
renderThresholdItem();
// Check for the channels selector by looking for the displayed text
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
});
it('updates threshold label when label input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
fireEvent.change(labelInput, {
target: { value: TEST_CONSTANTS.WARNING_LABEL },
});
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'label',
TEST_CONSTANTS.WARNING_LABEL,
);
});
it('updates threshold value when value input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
fireEvent.change(valueInput, { target: { value: '200' } });
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'thresholdValue',
'200',
);
});
it('updates threshold unit when unit selector changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Find the unit selector by its role and simulate change
const unitSelectors = screen.getAllByRole('combobox');
const unitSelector = unitSelectors[0]; // First combobox is the unit selector
// Simulate clicking to open the dropdown and selecting a value
fireEvent.click(unitSelector);
// The actual change event might not work the same way with Ant Design Select
// So we'll just verify the selector is present and can be interacted with
expect(unitSelector).toBeInTheDocument();
});
it('updates threshold channels when channels selector changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Find the channels selector by its role and simulate change
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
// Simulate clicking to open the dropdown
fireEvent.click(channelSelector);
// The actual change event might not work the same way with Ant Design Select
// So we'll just verify the selector is present and can be interacted with
expect(channelSelector).toBeInTheDocument();
});
it('shows remove button when showRemoveButton is true', () => {
renderThresholdItem({ showRemoveButton: true });
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // Recovery button + remove button
});
it('does not show remove button when showRemoveButton is false', () => {
renderThresholdItem({ showRemoveButton: false });
// Only the recovery button should be present
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Only recovery button
});
it('calls removeThreshold when remove button is clicked', () => {
const removeThreshold = jest.fn();
renderThresholdItem({ showRemoveButton: true, removeThreshold });
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
const removeButton = buttons[1]; // Second button is the remove button
fireEvent.click(removeButton);
expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID);
});
it('shows recovery threshold button when recovery threshold is enabled', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Recovery button
});
it('shows recovery threshold inputs when recovery button is clicked', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
const buttons = screen.getAllByRole('button');
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
).toBeInTheDocument();
});
it('updates recovery threshold value when input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Show recovery threshold first
const buttons = screen.getAllByRole('button');
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
const recoveryValueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
);
fireEvent.change(recoveryValueInput, { target: { value: '90' } });
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'recoveryThresholdValue',
'90',
);
});
it('disables unit selector when no units are available', () => {
renderThresholdItem({ units: [] });
verifyUnitSelectorDisabled();
});
it('shows tooltip when no units are available', () => {
renderThresholdItem({ units: [] });
// The tooltip should be present when hovering over disabled unit selector
verifyUnitSelectorDisabled();
});
it('renders channels as multiple select options', () => {
renderThresholdItem();
// Check that channels are rendered as multiple select
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select multiple channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, {
target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] },
});
});
it('handles empty threshold values correctly', () => {
const emptyThreshold = {
...mockThreshold,
label: '',
thresholdValue: 0,
unit: '',
channels: [],
};
renderThresholdItem({ threshold: emptyThreshold });
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
});
it('renders with correct input widths', () => {
renderThresholdItem();
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(labelInput).toHaveStyle('width: 260px');
expect(valueInput).toHaveStyle('width: 210px');
});
it('renders channels selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(1, '260px');
});
it('renders unit selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(0, '150px');
});
it('handles loading channels state', () => {
renderThresholdItem({ isLoadingChannels: true });
verifyComponentRendersWithLoading();
});
it('renders recovery threshold with correct initial value', () => {
renderThresholdItem();
showRecoveryThreshold();
const recoveryValueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
);
expect(recoveryValueInput).toHaveValue('80');
});
it('renders recovery threshold label as disabled', () => {
renderThresholdItem();
showRecoveryThreshold();
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
expect(recoveryLabelInput).toBeDisabled();
});
it('renders correct channel options', () => {
renderThresholdItem();
// Check that channels are rendered
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select different channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
});
it('handles threshold without channels', () => {
const thresholdWithoutChannels = {
...mockThreshold,
channels: [],
};
renderThresholdItem({ threshold: thresholdWithoutChannels });
// Should render channels selector without selected values
const channelSelectors = screen.getAllByRole('combobox');
expect(channelSelectors).toHaveLength(2); // Should have both unit and channel selectors
});
});

View File

@@ -0,0 +1,5 @@
export const THRESHOLD_TAB_TOOLTIP =
'An alert is triggered when the metric crosses a threshold.';
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';

View File

@@ -0,0 +1,3 @@
import AlertCondition from './AlertCondition';
export default AlertCondition;

View File

@@ -0,0 +1,277 @@
.alert-condition-container {
margin: 0 16px;
margin-top: 24px;
.alert-condition {
display: flex;
align-items: center;
margin-left: 12px;
margin-top: 24px;
.alert-condition-tabs {
display: flex;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 0.5px solid var(--bg-slate-400);
border-bottom: 0.5px solid var(--bg-slate-400);
width: 120px;
height: 36px;
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: none;
&:hover {
background-color: var(--bg-ink-500) !important;
}
}
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
}
}
.alert-threshold-container,
.anomaly-threshold-container {
padding: 24px;
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: fit-content;
.alert-condition-sentences {
display: flex;
flex-direction: column;
gap: 12px;
.alert-condition-sentence {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.sentence-text {
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 1.5;
}
.ant-select {
width: 240px !important;
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--text-vanilla-400);
font-family: 'Space Mono';
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.thresholds-section {
margin-top: 16px;
margin-left: 24px;
.threshold-item {
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 16px;
.threshold-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 2px;
.threshold-indicator {
.threshold-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
}
.threshold-controls {
display: flex;
align-items: center;
gap: 8px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
.icon-btn {
color: var(--bg-vanilla-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.recovery-threshold-input-group {
display: flex;
align-items: center;
gap: 0;
margin-left: 28px;
.recovery-threshold-label {
pointer-events: none;
cursor: default;
}
.recovery-threshold-btn {
pointer-events: none;
cursor: default;
color: var(--bg-vanilla-400);
background-color: var(--bg-ink-400) !important;
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
}
}
.add-threshold-btn {
margin-top: 8px;
border: 1px dashed var(--bg-slate-400);
color: var(--bg-vanilla-300);
background-color: transparent;
border-radius: 4px;
height: 32px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: var(--bg-vanilla-300);
color: var(--bg-vanilla-100);
}
.anticon {
margin-right: 8px;
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { Threshold } from '../context/types';
export type UpdateThreshold = {
(thresholdId: string, field: 'channels', value: string[]): void;
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string,
): void;
};
export interface ThresholdItemProps {
threshold: Threshold;
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
}

View File

@@ -0,0 +1,46 @@
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
currentQuery.builder.queryTraceOperator,
);
const queryConfig: Record<EQueryType, () => SelectProps['options']> = {
[EQueryType.QUERY_BUILDER]: () => [
...(getSelectedQueryOptions(currentQuery.builder.queryData)?.filter(
(option) =>
!involvedQueriesInTraceOperator.includes(option.value as string),
) || []),
...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []),
...(getSelectedQueryOptions(currentQuery.builder.queryTraceOperator) || []),
],
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
[EQueryType.CLICKHOUSE]: () =>
getSelectedQueryOptions(currentQuery.clickhouse_sql),
};
return queryConfig[currentQuery.queryType]?.() || [];
}
export function getCategoryByOptionId(id: string): string | undefined {
return Y_AXIS_CATEGORIES.find((category) =>
category.units.some((unit) => unit.id === id),
)?.name;
}
export function getCategorySelectOptionByName(
name: string,
): DefaultOptionType[] {
return (
Y_AXIS_CATEGORIES.find((category) => category.name === name)?.units.map(
(unit) => ({
label: unit.name,
value: unit.id,
}),
) || []
);
}

View File

@@ -0,0 +1,73 @@
import './styles.scss';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { Labels } from 'types/api/alerts/def';
import { useCreateAlertState } from '../context';
import LabelsInput from './LabelsInput';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
currentQuery.builder.queryData.forEach((query) => {
query.groupBy.forEach((groupBy) => {
labels.push(groupBy.key);
});
});
return labels;
}, [currentQuery]);
// If the label key is a group by label, then it is not allowed to be used as a label key
const validateLabelsKey = useCallback(
(key: string): string | null => {
if (groupByLabels.includes(key)) {
return `Cannot use ${key} as a key`;
}
return null;
},
[groupByLabels],
);
return (
<div className="alert-header">
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
<div className="alert-header__content">
<input
type="text"
value={alertState.name}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
}
className="alert-header__input title"
placeholder="Enter alert rule name"
/>
<input
type="text"
value={alertState.description}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
}
className="alert-header__input description"
placeholder="Click to add description..."
/>
<LabelsInput
labels={alertState.labels}
onLabelsChange={(labels: Labels): void =>
setAlertState({ type: 'SET_ALERT_LABELS', payload: labels })
}
validateLabelsKey={validateLabelsKey}
/>
</div>
</div>
);
}
export default CreateAlertHeader;

View File

@@ -0,0 +1,168 @@
import { CloseOutlined } from '@ant-design/icons';
import { useNotifications } from 'hooks/useNotifications';
import React, { useCallback, useState } from 'react';
import { LabelInputState, LabelsInputProps } from './types';
function LabelsInput({
labels,
onLabelsChange,
validateLabelsKey,
}: LabelsInputProps): JSX.Element {
const { notifications } = useNotifications();
const [inputState, setInputState] = useState<LabelInputState>({
key: '',
value: '',
isKeyInput: true,
});
const [isAdding, setIsAdding] = useState(false);
const handleAddLabelsClick = useCallback(() => {
setIsAdding(true);
setInputState({ key: '', value: '', isKeyInput: true });
}, []);
const handleKeyDown = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (inputState.isKeyInput) {
// Check if input contains a colon (key:value format)
if (inputState.key.includes(':')) {
const [key, ...valueParts] = inputState.key.split(':');
const value = valueParts.join(':'); // Rejoin in case value contains colons
if (key.trim() && value.trim()) {
if (labels[key.trim()]) {
notifications.error({
message: 'Label with this key already exists',
});
return;
}
const error = validateLabelsKey(key.trim());
if (error) {
notifications.error({
message: error,
});
return;
}
// Add the label immediately
const newLabels = {
...labels,
[key.trim()]: value.trim(),
};
onLabelsChange(newLabels);
// Reset input state
setInputState({ key: '', value: '', isKeyInput: true });
}
} else if (inputState.key.trim()) {
if (labels[inputState.key.trim()]) {
notifications.error({
message: 'Label with this key already exists',
});
return;
}
const error = validateLabelsKey(inputState.key.trim());
if (error) {
notifications.error({
message: error,
});
return;
}
setInputState((prev) => ({ ...prev, isKeyInput: false }));
}
} else if (inputState.value.trim()) {
// Add the label
const newLabels = {
...labels,
[inputState.key.trim()]: inputState.value.trim(),
};
onLabelsChange(newLabels);
// Reset and continue adding
setInputState({ key: '', value: '', isKeyInput: true });
}
} else if (e.key === 'Escape') {
// Cancel adding
setIsAdding(false);
setInputState({ key: '', value: '', isKeyInput: true });
}
},
[inputState, labels, notifications, onLabelsChange, validateLabelsKey],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (inputState.isKeyInput) {
setInputState((prev) => ({ ...prev, key: e.target.value }));
} else {
setInputState((prev) => ({ ...prev, value: e.target.value }));
}
},
[inputState.isKeyInput],
);
const handleRemoveLabel = useCallback(
(key: string) => {
const newLabels = { ...labels };
delete newLabels[key];
onLabelsChange(newLabels);
},
[labels, onLabelsChange],
);
const handleBlur = useCallback(() => {
if (!inputState.key && !inputState.value) {
setIsAdding(false);
setInputState({ key: '', value: '', isKeyInput: true });
}
}, [inputState]);
return (
<div className="labels-input">
{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">
{key}: {value}
<button
type="button"
className="labels-input__remove-button"
onClick={(): void => handleRemoveLabel(key)}
>
<CloseOutlined />
</button>
</span>
))}
</div>
)}
{!isAdding ? (
<button
className="labels-input__add-button"
type="button"
onClick={handleAddLabelsClick}
>
+ Add labels
</button>
) : (
<div className="labels-input__input-container">
<input
type="text"
value={inputState.isKeyInput ? inputState.key : inputState.value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="labels-input__input"
placeholder={inputState.isKeyInput ? 'Enter key' : 'Enter value'}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</div>
)}
</div>
);
}
export default LabelsInput;

View File

@@ -0,0 +1,77 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string } => ({
search: '',
}),
}));
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render(
<CreateAlertProvider>
<CreateAlertHeader />
</CreateAlertProvider>,
);
describe('CreateAlertHeader', () => {
it('renders the header with title', () => {
renderCreateAlertHeader();
expect(screen.getByText('New Alert Rule')).toBeInTheDocument();
});
it('renders name input with placeholder', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
expect(nameInput).toBeInTheDocument();
});
it('renders description input with placeholder', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
expect(descriptionInput).toBeInTheDocument();
});
it('renders LabelsInput component', () => {
renderCreateAlertHeader();
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
});
it('updates name when typing in name input', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
expect(nameInput).toHaveValue('Test Alert');
});
it('updates description when typing in description input', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
});
});

View File

@@ -0,0 +1,510 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import LabelsInput from '../LabelsInput';
import { LabelsInputProps } from '../types';
// Mock the CloseOutlined icon
jest.mock('@ant-design/icons', () => ({
CloseOutlined: (): JSX.Element => <span data-testid="close-icon">×</span>,
}));
const mockOnLabelsChange = jest.fn();
const mockValidateLabelsKey = jest.fn().mockReturnValue(null);
const defaultProps: LabelsInputProps = {
labels: {},
onLabelsChange: mockOnLabelsChange,
validateLabelsKey: mockValidateLabelsKey,
};
const ADD_LABELS_TEXT = '+ Add labels';
const ENTER_KEY_PLACEHOLDER = 'Enter key';
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
const CLOSE_ICON_TEST_ID = 'close-icon';
const SEVERITY_HIGH_TEXT = 'severity: high';
const ENVIRONMENT_PRODUCTION_TEXT = 'environment: production';
const SEVERITY_HIGH_KEY_VALUE = 'severity:high';
const renderLabelsInput = (
props: Partial<LabelsInputProps> = {},
): ReturnType<typeof render> =>
render(<LabelsInput {...defaultProps} {...props} />);
describe('LabelsInput', () => {
beforeEach(() => {
jest.clearAllMocks();
mockValidateLabelsKey.mockReturnValue(null); // Reset validation to always pass
});
describe('Initial Rendering', () => {
it('renders add button when no labels exist', () => {
renderLabelsInput();
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(screen.queryByTestId(CLOSE_ICON_TEST_ID)).not.toBeInTheDocument();
});
it('renders existing labels when provided', () => {
const labels = { severity: 'high', environment: 'production' };
renderLabelsInput({ labels });
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(2);
});
it('does not render existing labels section when no labels', () => {
renderLabelsInput();
expect(screen.queryByText(SEVERITY_HIGH_TEXT)).not.toBeInTheDocument();
});
});
describe('Adding Labels', () => {
it('shows input field when add button is clicked', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
});
it('switches from key input to value input on Enter', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(
screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('adds label when both key and value are provided', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Enter value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('does not switch to value input if key is empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.keyDown(input, { key: 'Enter' });
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not add label if value is empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Try to add with empty value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('trims whitespace from key and value', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key with whitespace
fireEvent.change(input, { target: { value: ' severity ' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Enter value with whitespace
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: ' high ' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('resets input state after adding label', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add a label
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
// Should be back to key input
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
describe('Removing Labels', () => {
it('removes label when close button is clicked', () => {
const labels = { severity: 'high', environment: 'production' };
renderLabelsInput({ labels });
const removeButtons = screen.getAllByTestId(CLOSE_ICON_TEST_ID);
fireEvent.click(removeButtons[0]);
expect(mockOnLabelsChange).toHaveBeenCalledWith({
environment: 'production',
});
});
it('calls onLabelsChange with empty object when last label is removed', () => {
const labels = { severity: 'high' };
renderLabelsInput({ labels });
const removeButton = screen.getByTestId('close-icon');
fireEvent.click(removeButton);
expect(mockOnLabelsChange).toHaveBeenCalledWith({});
});
});
describe('Keyboard Interactions', () => {
it('cancels adding label on Escape key', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('cancels adding label on Escape key in value input', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Cancel in value input
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.keyDown(valueInput, { key: 'Escape' });
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
describe('Blur Behavior', () => {
it('closes input immediately when both key and value are empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.blur(input);
// The input should close immediately when both key and value are empty
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not close input immediately when key has value', () => {
jest.useFakeTimers();
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.blur(input);
jest.advanceTimersByTime(200);
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
jest.useRealTimers();
});
});
describe('Input Change Handling', () => {
it('updates key input value correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
expect(input).toHaveValue('severity');
});
it('updates value input correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Update value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
expect(valueInput).toHaveValue('high');
});
});
describe('Edge Cases', () => {
it('handles multiple labels correctly', () => {
const labels = {
severity: 'high',
environment: 'production',
service: 'api-gateway',
};
renderLabelsInput({ labels });
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
expect(screen.getByText('service: api-gateway')).toBeInTheDocument();
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(3);
});
it('handles empty string values', () => {
const labels = { severity: '' };
renderLabelsInput({ labels });
expect(screen.getByText(/severity/)).toBeInTheDocument();
});
it('handles special characters in labels', () => {
const labels = { 'service-name': 'api-gateway-v1' };
renderLabelsInput({ labels });
expect(screen.getByText('service-name: api-gateway-v1')).toBeInTheDocument();
});
it('maintains focus on input after adding label', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add a label
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
// Should be focused on new key input
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
expect(newInput).toHaveFocus();
});
});
describe('Key:Value Format Support', () => {
it('adds label when key:value format is entered and Enter is pressed', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('trims whitespace from key and value in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with whitespace
fireEvent.change(input, { target: { value: ' severity : high ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('handles values with colons correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format where value contains colons
fireEvent.change(input, {
target: { value: 'url:https://example.com:8080' },
});
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({
url: 'https://example.com:8080',
});
});
it('does not add label if key is empty in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with empty key
fireEvent.change(input, { target: { value: ':high' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('does not add label if value is empty in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with empty value
fireEvent.change(input, { target: { value: 'severity:' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('does not add label if only colon is entered', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter only colon
fireEvent.change(input, { target: { value: ':' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('resets input state after adding label with key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add label with key:value format
fireEvent.change(input, { target: { value: 'severity:high' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Should be back to key input for next label
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not auto-save when typing key:value without pressing Enter', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Type key:value format but don't press Enter
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
// Should not have called onLabelsChange yet
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('handles multiple key:value entries correctly', () => {
const { rerender } = renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add first label
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
fireEvent.keyDown(input, { key: 'Enter' });
// Simulate parent component updating labels
const firstLabels = { severity: 'high' };
rerender(
<LabelsInput
labels={firstLabels}
onLabelsChange={mockOnLabelsChange}
validateLabelsKey={mockValidateLabelsKey}
/>,
);
// Add second label
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(newInput, { target: { value: 'environment:production' } });
fireEvent.keyDown(newInput, { key: 'Enter' });
// Check that we made two calls and the last one includes both labels
expect(mockOnLabelsChange).toHaveBeenCalledTimes(2);
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(1, { severity: 'high' });
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(2, {
severity: 'high',
environment: 'production',
});
});
});
});

View File

@@ -0,0 +1,3 @@
import CreateAlertHeader from './CreateAlertHeader';
export default CreateAlertHeader;

View File

@@ -0,0 +1,151 @@
.alert-header {
background-color: var(--bg-ink-500);
font-family: inherit;
color: var(--text-vanilla-100);
/* Top bar with diagonal stripes */
&__tab-bar {
height: 32px;
display: flex;
align-items: center;
background: repeating-linear-gradient(
-45deg,
#0f0f0f,
#0f0f0f 10px,
#101010 10px,
#101010 20px
);
padding-left: 0;
}
/* Tab block visuals */
&__tab {
display: flex;
align-items: center;
background-color: var(--bg-ink-500);
padding: 0 12px;
height: 32px;
font-size: 13px;
color: var(--text-vanilla-100);
margin-left: 12px;
margin-top: 12px;
}
&__tab::before {
content: '';
margin-right: 6px;
font-size: 14px;
color: var(--bg-slate-100);
}
&__content {
padding: 16px;
background: var(--bg-ink-500);
display: flex;
flex-direction: column;
gap: 8px;
}
&__input.title {
font-size: 18px;
font-weight: 500;
background-color: transparent;
color: var(--text-vanilla-100);
}
&__input:focus,
&__input:active {
border: none;
outline: none;
}
&__input.description {
font-size: 14px;
background-color: transparent;
color: var(--text-vanilla-300);
}
}
.labels-input {
display: flex;
flex-direction: column;
gap: 8px;
&__add-button {
width: fit-content;
font-size: 13px;
color: #ccc;
border: 1px solid #333;
background-color: transparent;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
&:hover {
border-color: #555;
color: #fff;
}
}
&__existing-labels {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__label-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background-color: #ad7f581a;
color: var(--bg-sienna-400);
padding: 4px 8px;
border-radius: 16px;
font-size: 12px;
border: 1px solid var(--bg-sienna-500);
font-family: 'Geist Mono';
}
&__remove-button {
background: none;
border: none;
color: var(--bg-sienna-400);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
&:hover {
color: var(--text-vanilla-100);
}
}
&__input-container {
display: flex;
align-items: center;
background-color: transparent;
border: none;
}
&__input {
flex: 1;
background-color: transparent;
border: none;
outline: none;
padding: 6px 8px;
color: #fff;
font-size: 13px;
&::placeholder {
color: #888;
}
&:focus,
&:active {
border: none;
outline: none;
}
}
}

View File

@@ -0,0 +1,13 @@
import { Labels } from 'types/api/alerts/def';
export interface LabelsInputProps {
labels: Labels;
onLabelsChange: (labels: Labels) => void;
validateLabelsKey: (key: string) => string | null;
}
export interface LabelInputState {
key: string;
value: string;
isKeyInput: boolean;
}

View File

@@ -0,0 +1,17 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
}
.top-nav-container {
background: repeating-linear-gradient(
-45deg,
$top-nav-background-1,
$top-nav-background-1 10px,
$top-nav-background-2 10px,
$top-nav-background-2 20px
);
margin-bottom: 0;
}

View File

@@ -0,0 +1,34 @@
import './CreateAlertV2.styles.scss';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import CreateAlertHeader from './CreateAlertHeader';
import QuerySection from './QuerySection';
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
}: {
initialQuery?: Query;
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
return (
<div className="create-alert-v2-container">
<CreateAlertProvider>
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
</CreateAlertProvider>
</div>
);
}
CreateAlertV2.defaultProps = {
initialQuery: initialQueriesMap.metrics,
};
export default CreateAlertV2;

View File

@@ -0,0 +1,80 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { AlertDef } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
export interface ChartPreviewProps {
alertDef: AlertDef;
}
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
const { thresholdState, alertState } = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const [, setQueryStatus] = useState<string>('');
const yAxisUnit = alertState.yAxisUnit || '';
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
yAxisUnit={yAxisUnit || ''}
graphType={panelType || PANEL_TYPES.TIME_SERIES}
setQueryStatus={setQueryStatus}
showSideLegend
additionalThresholds={thresholdState.thresholds}
/>
);
const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}
yAxisUnit={yAxisUnit || ''}
graphType={panelType || PANEL_TYPES.TIME_SERIES}
setQueryStatus={setQueryStatus}
showSideLegend
additionalThresholds={thresholdState.thresholds}
/>
);
return (
<div className="chart-preview-container">
{currentQuery.queryType === EQueryType.QUERY_BUILDER &&
renderQBChartPreview()}
{currentQuery.queryType === EQueryType.PROM &&
renderPromAndChQueryChartPreview()}
{currentQuery.queryType === EQueryType.CLICKHOUSE &&
renderPromAndChQueryChartPreview()}
</div>
);
}
export default ChartPreview;

View File

@@ -0,0 +1,3 @@
import ChartPreview from './ChartPreview';
export default ChartPreview;

View File

@@ -0,0 +1,98 @@
import './styles.scss';
import { Button } from 'antd';
import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import ChartPreview from './ChartPreview';
import { buildAlertDefForChartPreview } from './utils';
function QuerySection(): JSX.Element {
const { currentQuery, handleRunQuery } = useQueryBuilder();
const {
alertState,
setAlertState,
alertType,
setAlertType,
thresholdState,
} = useCreateAlertState();
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
const tabs = [
{
label: 'Metrics',
icon: <BarChart2 size={14} data-testid="metrics-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
{
label: 'Logs',
icon: <ScrollText size={14} data-testid="logs-view" />,
value: AlertTypes.LOGS_BASED_ALERT,
},
{
label: 'Traces',
icon: <DraftingCompass size={14} data-testid="traces-view" />,
value: AlertTypes.TRACES_BASED_ALERT,
},
{
label: 'Exceptions',
icon: <FileText size={14} data-testid="exceptions-view" />,
value: AlertTypes.EXCEPTIONS_BASED_ALERT,
},
];
return (
<div className="query-section">
<Stepper
stepNumber={1}
label="Define the query you want to set an alert on"
/>
<ChartPreview alertDef={alertDef} />
<YAxisUnitSelector
value={alertState.yAxisUnit}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
/>
<div className="query-section-tabs">
<div className="query-section-query-actions">
{tabs.map((tab) => (
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,
})}
onClick={(): void => {
setAlertType(tab.value as AlertTypes);
}}
>
{tab.icon}
{tab.label}
</Button>
))}
</div>
</div>
<QuerySectionComponent
queryCategory={currentQuery.queryType}
setQueryCategory={(): void => {}}
alertType={alertType}
runQuery={handleRunQuery}
alertDef={alertDef}
panelType={PANEL_TYPES.TIME_SERIES}
key={currentQuery.queryType}
ruleId=""
hideTitle
/>
</div>
);
}
export default QuerySection;

View File

@@ -0,0 +1,282 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/destructuring-assignment */
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from 'container/CreateAlertV2/context/constants';
import { buildInitialAlertDef } from 'container/CreateAlertV2/context/utils';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { CreateAlertProvider } from '../../context';
import ChartPreview from '../ChartPreview/ChartPreview';
const REQUESTS_PER_SEC = 'requests/sec';
const CHART_PREVIEW_NAME = 'Chart Preview';
const QUERY_TYPE_TEST_ID = 'query-type';
const GRAPH_TYPE_TEST_ID = 'graph-type';
const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component';
const PLOT_QUERY_TYPE_TEST_ID = 'plot-query-type';
const PLOT_PANEL_TYPE_TEST_ID = 'plot-panel-type';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock(
'container/FormAlertRules/ChartPreview',
() =>
function MockChartPreviewComponent(props: any): JSX.Element {
return (
<div data-testid={CHART_PREVIEW_COMPONENT_TEST_ID}>
<div data-testid="headline">{props.headline}</div>
<div data-testid="name">{props.name}</div>
<div data-testid={QUERY_TYPE_TEST_ID}>{props.query?.queryType}</div>
<div data-testid="selected-interval">
{props.selectedInterval?.startTime}
</div>
<div data-testid="y-axis-unit">{props.yAxisUnit}</div>
<div data-testid={GRAPH_TYPE_TEST_ID}>{props.graphType}</div>
</div>
);
},
);
jest.mock(
'container/NewWidget/LeftContainer/WidgetGraph/PlotTag',
() =>
function MockPlotTag(props: any): JSX.Element {
return (
<div data-testid="plot-tag">
<div data-testid={PLOT_QUERY_TYPE_TEST_ID}>{props.queryType}</div>
<div data-testid={PLOT_PANEL_TYPE_TEST_ID}>{props.panelType}</div>
</div>
);
},
);
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock react-redux
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({
globalTime: {
selectedTime: {
startTime: 1713734400000,
endTime: 1713738000000,
},
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const mockUseQueryBuilder = {
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
unit: REQUESTS_PER_SEC,
builder: {
queryData: [
{
dataSource: 'metrics',
},
],
},
},
panelType: PANEL_TYPES.TIME_SERIES,
stagedQuery: {
queryType: EQueryType.QUERY_BUILDER,
unit: REQUESTS_PER_SEC,
},
};
const mockAlertDef = buildInitialAlertDef(AlertTypes.METRICS_BASED_ALERT);
jest.mock('../../context', () => ({
...jest.requireActual('../../context'),
useCreateAlertState: (): any => ({
alertState: {
...INITIAL_ALERT_STATE,
yAxisUnit: REQUESTS_PER_SEC,
},
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setAlertState: jest.fn(),
setThresholdState: jest.fn(),
}),
}));
const renderChartPreview = (): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider>
<ChartPreview alertDef={mockAlertDef} />
</CreateAlertProvider>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
describe('ChartPreview', () => {
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
beforeEach(() => {
jest.clearAllMocks();
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
});
it('renders the component with correct container class', () => {
renderChartPreview();
const container = screen
.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID)
.closest('.chart-preview-container');
expect(container).toBeInTheDocument();
});
it('renders QueryBuilder chart preview when query type is QUERY_BUILDER', () => {
renderChartPreview();
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('plot-tag')).toBeInTheDocument();
expect(screen.getByTestId(PLOT_QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.QUERY_BUILDER,
);
expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
});
it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => {
renderChartPreview();
expect(screen.getByTestId('name')).toHaveTextContent('');
});
it('renders QueryBuilder chart preview with correct props', () => {
renderChartPreview();
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.QUERY_BUILDER,
);
expect(screen.getByTestId('y-axis-unit')).toHaveTextContent(REQUESTS_PER_SEC);
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId('name')).toHaveTextContent('');
expect(screen.getByTestId('headline')).toBeInTheDocument();
expect(screen.getByTestId('selected-interval')).toBeInTheDocument();
});
it('renders PromQL chart preview when query type is PROM', () => {
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
currentQuery: {
...mockUseQueryBuilder.currentQuery,
queryType: EQueryType.PROM,
},
stagedQuery: {
queryType: EQueryType.PROM,
unit: REQUESTS_PER_SEC,
},
});
renderChartPreview();
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.PROM,
);
});
it('renders ClickHouse chart preview when query type is CLICKHOUSE', () => {
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
currentQuery: {
...mockUseQueryBuilder.currentQuery,
queryType: EQueryType.CLICKHOUSE,
},
stagedQuery: {
queryType: EQueryType.CLICKHOUSE,
unit: REQUESTS_PER_SEC,
},
});
renderChartPreview();
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.CLICKHOUSE,
);
});
it('uses default panel type when panelType is not provided', () => {
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
panelType: undefined,
});
renderChartPreview();
expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.QUERY_BUILDER,
);
});
it('uses custom panel type when provided', () => {
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
panelType: PANEL_TYPES.BAR,
});
renderChartPreview();
expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.BAR,
);
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.BAR,
);
});
});

View File

@@ -0,0 +1,307 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider } from '../../context';
import QuerySection from '../QuerySection';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({
globalTime: {
selectedTime: {
startTime: 1713734400000,
endTime: 1713738000000,
},
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
jest.mock(
'container/FormAlertRules/QuerySection',
() =>
function MockQuerySectionComponent({
queryCategory,
alertType,
panelType,
}: any): JSX.Element {
return (
<div data-testid="query-section-component">
<div data-testid="query-category">{queryCategory}</div>
<div data-testid="alert-type">{alertType}</div>
<div data-testid="panel-type">{panelType}</div>
</div>
);
},
);
jest.mock(
'../ChartPreview',
() =>
function MockChartPreview(): JSX.Element {
return <div data-testid="chart-preview">Chart Preview</div>;
},
);
jest.mock(
'../../Stepper',
() =>
function MockStepper({ stepNumber, label }: any): JSX.Element {
return (
<div data-testid="stepper">
<div data-testid="step-number">{stepNumber}</div>
<div data-testid="step-label">{label}</div>
</div>
);
},
);
const mockUseQueryBuilder = {
currentQuery: {
queryType: 'query_builder',
unit: 'requests/sec',
builder: {
queryData: [
{
dataSource: 'metrics',
},
],
},
},
handleRunQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderQuerySection = (): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
const METRICS_TEXT = 'Metrics';
const QUERY_BUILDER_TEXT = 'query_builder';
const LOGS_TEXT = 'Logs';
const TRACES_TEXT = 'Traces';
const ACTIVE_TAB_CLASS = 'active-tab';
describe('QuerySection', () => {
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
beforeEach(() => {
jest.clearAllMocks();
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
});
it('renders the component with all required elements', () => {
renderQuerySection();
// Check if Stepper is rendered
expect(screen.getByTestId('stepper')).toBeInTheDocument();
expect(screen.getByTestId('step-number')).toHaveTextContent('1');
expect(screen.getByTestId('step-label')).toHaveTextContent(
'Define the query you want to set an alert on',
);
// Check if ChartPreview is rendered
expect(screen.getByTestId('chart-preview')).toBeInTheDocument();
// Check if QuerySectionComponent is rendered
expect(screen.getByTestId('query-section-component')).toBeInTheDocument();
expect(screen.getByTestId('query-category')).toHaveTextContent(
QUERY_BUILDER_TEXT,
);
expect(screen.getByTestId('alert-type')).toHaveTextContent(
AlertTypes.METRICS_BASED_ALERT,
);
expect(screen.getByTestId('panel-type')).toHaveTextContent('graph');
});
it('renders all three alert type tabs', () => {
renderQuerySection();
// Check if all tabs are rendered
expect(screen.getByText(METRICS_TEXT)).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
expect(screen.getByText('Traces')).toBeInTheDocument();
// Check if icons are rendered
expect(screen.getByTestId('metrics-view')).toBeInTheDocument();
expect(screen.getByTestId('logs-view')).toBeInTheDocument();
expect(screen.getByTestId('traces-view')).toBeInTheDocument();
});
it('shows Metrics tab as active by default', () => {
renderQuerySection();
const metricsTab = screen.getByText(METRICS_TEXT).closest('button');
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
});
it('handles alert type change when clicking on different tabs', async () => {
const user = userEvent.setup();
renderQuerySection();
// Click on Logs tab
const logsTab = screen.getByText(LOGS_TEXT);
await user.click(logsTab);
// Verify that redirectWithQueryBuilderData was called with correct data
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
},
undefined,
true,
);
// Click on Traces tab
const tracesTab = screen.getByText(TRACES_TEXT);
await user.click(tracesTab);
// Verify that redirectWithQueryBuilderData was called with correct data
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
},
undefined,
true,
);
});
it('updates active tab when alert type changes', async () => {
const user = userEvent.setup();
renderQuerySection();
// Initially Metrics should be active
const metricsTab = screen.getByText(METRICS_TEXT).closest('button');
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
// Click on Logs tab
const logsTab = screen.getByText(LOGS_TEXT);
await user.click(logsTab);
// Logs should now be active
const logsButton = logsTab.closest('button');
expect(logsButton).toHaveClass(ACTIVE_TAB_CLASS);
expect(metricsTab).not.toHaveClass(ACTIVE_TAB_CLASS);
});
it('passes correct props to QuerySectionComponent', () => {
renderQuerySection();
// Check if the component receives the correct props
expect(screen.getByTestId('query-category')).toHaveTextContent(
QUERY_BUILDER_TEXT,
);
expect(screen.getByTestId('alert-type')).toHaveTextContent(
AlertTypes.METRICS_BASED_ALERT,
);
expect(screen.getByTestId('panel-type')).toHaveTextContent('graph');
});
it('has correct CSS classes for tab styling', () => {
renderQuerySection();
const tabs = screen.getAllByRole('button');
tabs.forEach((tab) => {
expect(tab).toHaveClass('list-view-tab');
expect(tab).toHaveClass('explorer-view-option');
});
});
it('renders with correct container structure', () => {
renderQuerySection();
const container = screen.getByText(METRICS_TEXT).closest('.query-section');
expect(container).toBeInTheDocument();
const tabsContainer = screen
.getByText(METRICS_TEXT)
.closest('.query-section-tabs');
expect(tabsContainer).toBeInTheDocument();
const actionsContainer = screen
.getByText(METRICS_TEXT)
.closest('.query-section-query-actions');
expect(actionsContainer).toBeInTheDocument();
});
it('handles multiple rapid tab clicks correctly', async () => {
const user = userEvent.setup();
renderQuerySection();
const logsTab = screen.getByText('Logs');
const tracesTab = screen.getByText('Traces');
// Rapidly click on different tabs
await user.click(logsTab);
await user.click(tracesTab);
await user.click(logsTab);
// Should have called redirectWithQueryBuilderData 3 times
expect(
mockUseQueryBuilder.redirectWithQueryBuilderData,
).toHaveBeenCalledTimes(3);
});
it('maintains tab state correctly after interactions', async () => {
const user = userEvent.setup();
renderQuerySection();
// Click on Logs tab
const logsTab = screen.getByText('Logs');
await user.click(logsTab);
// Verify Logs is active
const logsButton = logsTab.closest('button');
expect(logsButton).toHaveClass(ACTIVE_TAB_CLASS);
// Click back to Metrics
const metricsTab = screen.getByText(METRICS_TEXT);
await user.click(metricsTab);
// Verify Metrics is active again
const metricsButton = metricsTab.closest('button');
expect(metricsButton).toHaveClass(ACTIVE_TAB_CLASS);
expect(logsButton).not.toHaveClass(ACTIVE_TAB_CLASS);
});
});

View File

@@ -0,0 +1,3 @@
import QuerySection from './QuerySection';
export default QuerySection;

View File

@@ -0,0 +1,101 @@
.query-section {
margin: 0 16px;
.query-section-tabs {
display: flex;
align-items: center;
margin-left: 12px;
margin-top: 24px;
.query-section-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.prom-ql-icon {
height: 14px;
width: 14px;
}
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 0.5px solid var(--bg-slate-400);
border-bottom: 0.5px solid var(--bg-slate-400);
width: 120px;
height: 36px;
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: none;
&:hover {
background-color: var(--bg-ink-500) !important;
}
}
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
.frequency-chart-view-controller {
display: flex;
align-items: center;
padding-left: 8px;
gap: 8px;
}
}
.y-axis-unit-selector-component {
margin-top: 16px;
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
}
}
.chart-preview-container {
margin-right: 4px;
.alert-chart-container {
.ant-card {
border: 1px solid var(--bg-slate-500);
.ant-card-body {
background-color: var(--bg-ink-500);
}
}
}
}
.alert-query-section-container {
margin: 0;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
}
}

View File

@@ -0,0 +1,28 @@
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { AlertThresholdState } from '../context/types';
import { buildInitialAlertDef } from '../context/utils';
export function buildAlertDefForChartPreview({
alertType,
thresholdState,
}: {
alertType: AlertTypes;
thresholdState: AlertThresholdState;
}): AlertDef {
const initialAlertDef = buildInitialAlertDef(alertType);
return {
...initialAlertDef,
ruleType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
condition: {
...initialAlertDef.condition,
targetUnit: thresholdState.thresholds?.[0].unit,
},
};
}

View File

@@ -0,0 +1,18 @@
import './styles.scss';
interface StepperProps {
stepNumber: number;
label: string;
}
function Stepper({ stepNumber, label }: StepperProps): JSX.Element {
return (
<div className="stepper-container">
<div className="step-number">{stepNumber}</div>
<div className="step-label">{label}</div>
<div className="dotted-line" />
</div>
);
}
export default Stepper;

View File

@@ -0,0 +1,44 @@
.stepper-container {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 16px 0;
border-radius: 8px;
}
.step-number {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--bg-robin-400);
color: var(--text-slate-400);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.step-label {
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: #e5e7eb;
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
flex-shrink: 0;
}
.dotted-line {
flex: 1;
height: 8px;
background-image: radial-gradient(circle, #4a4a4a 1px, transparent 1px);
background-size: 8px 8px;
background-repeat: repeat-x;
background-position: center;
margin-left: 8px;
}

View File

@@ -0,0 +1,117 @@
import { Color } from '@signozhq/design-tokens';
import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid';
import {
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
Algorithm,
Seasonality,
Threshold,
TimeDuration,
} from './types';
export const INITIAL_ALERT_STATE: AlertState = {
name: '',
description: '',
labels: {},
yAxisUnit: undefined,
};
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
id: v4(),
label: 'CRITICAL',
thresholdValue: 0,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_SAKURA_500,
};
export const INITIAL_WARNING_THRESHOLD: Threshold = {
id: v4(),
label: 'WARNING',
thresholdValue: 0,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_AMBER_500,
};
export const INITIAL_INFO_THRESHOLD: Threshold = {
id: v4(),
label: 'INFO',
thresholdValue: 0,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_ROBIN_500,
};
export const INITIAL_RANDOM_THRESHOLD: Threshold = {
id: v4(),
label: '',
thresholdValue: 0,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: getRandomColor(),
};
export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
selectedQuery: 'A',
operator: AlertThresholdOperator.IS_ABOVE,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
evaluationWindow: TimeDuration.FIVE_MINUTES,
algorithm: Algorithm.STANDARD,
seasonality: Seasonality.HOURLY,
thresholds: [INITIAL_CRITICAL_THRESHOLD],
};
export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'IS EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'IS NOT EQUAL TO' },
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
{ value: AlertThresholdOperator.ABOVE_BELOW, label: 'ABOVE/BELOW' },
];
export const THRESHOLD_MATCH_TYPE_OPTIONS = [
{ value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' },
{ value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' },
{ value: AlertThresholdMatchType.ON_AVERAGE, label: 'ON AVERAGE' },
{ value: AlertThresholdMatchType.IN_TOTAL, label: 'IN TOTAL' },
{ value: AlertThresholdMatchType.LAST, label: 'LAST' },
];
export const ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS = [
{ value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' },
{ value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' },
];
export const ANOMALY_TIME_DURATION_OPTIONS = [
{ value: TimeDuration.FIVE_MINUTES, label: '5 minutes' },
{ value: TimeDuration.TEN_MINUTES, label: '10 minutes' },
{ value: TimeDuration.FIFTEEN_MINUTES, label: '15 minutes' },
{ value: TimeDuration.ONE_HOUR, label: '1 hour' },
{ value: TimeDuration.THREE_HOURS, label: '3 hours' },
{ value: TimeDuration.FOUR_HOURS, label: '4 hours' },
{ value: TimeDuration.TWENTY_FOUR_HOURS, label: '24 hours' },
];
export const ANOMALY_ALGORITHM_OPTIONS = [
{ value: Algorithm.STANDARD, label: 'Standard' },
];
export const ANOMALY_SEASONALITY_OPTIONS = [
{ value: Seasonality.HOURLY, label: 'Hourly' },
{ value: Seasonality.DAILY, label: 'Daily' },
{ value: Seasonality.WEEKLY, label: 'Weekly' },
];

View File

@@ -0,0 +1,106 @@
import { QueryParams } from 'constants/query';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import {
alertCreationReducer,
alertThresholdReducer,
buildInitialAlertDef,
getInitialAlertTypeFromURL,
} from './utils';
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
// Hook exposing context state for CreateAlert
export const useCreateAlertState = (): ICreateAlertContextProps => {
const context = useContext(CreateAlertContext);
if (!context) {
throw new Error(
'useCreateAlertState must be used within CreateAlertProvider',
);
}
return context;
};
export function CreateAlertProvider(
props: ICreateAlertProviderProps,
): JSX.Element {
const { children } = props;
const [alertState, setAlertState] = useReducer(
alertCreationReducer,
INITIAL_ALERT_STATE,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const [alertType, setAlertType] = useState<AlertTypes>(() =>
getInitialAlertTypeFromURL(queryParams, currentQuery),
);
const handleAlertTypeChange = useCallback(
(value: AlertTypes): void => {
const queryToRedirect = buildInitialAlertDef(value);
const currentQueryToRedirect = mapQueryDataFromApi(
queryToRedirect.condition.compositeQuery,
);
redirectWithQueryBuilderData(
currentQueryToRedirect,
{
[QueryParams.alertType]: value,
},
undefined,
true,
);
setAlertType(value);
},
[redirectWithQueryBuilderData],
);
const [thresholdState, setThresholdState] = useReducer(
alertThresholdReducer,
INITIAL_ALERT_THRESHOLD_STATE,
);
useEffect(() => {
setThresholdState({
type: 'RESET',
});
}, [alertType]);
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
alertState,
setAlertState,
alertType,
setAlertType: handleAlertTypeChange,
thresholdState,
setThresholdState,
}),
[alertState, alertType, handleAlertTypeChange, thresholdState],
);
return (
<CreateAlertContext.Provider value={contextValue}>
{children}
</CreateAlertContext.Provider>
);
}

View File

@@ -0,0 +1,103 @@
import { Dispatch } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Labels } from 'types/api/alerts/def';
export interface ICreateAlertContextProps {
alertState: AlertState;
setAlertState: Dispatch<CreateAlertAction>;
alertType: AlertTypes;
setAlertType: Dispatch<AlertTypes>;
thresholdState: AlertThresholdState;
setThresholdState: Dispatch<AlertThresholdAction>;
}
export interface ICreateAlertProviderProps {
children: React.ReactNode;
}
export enum AlertCreationStep {
ALERT_DEFINITION = 0,
ALERT_CONDITION = 1,
EVALUATION_SETTINGS = 2,
NOTIFICATION_SETTINGS = 3,
}
export interface AlertState {
name: string;
description: string;
labels: Labels;
yAxisUnit: string | undefined;
}
export type CreateAlertAction =
| { type: 'SET_ALERT_NAME'; payload: string }
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
| { type: 'SET_ALERT_LABELS'; payload: Labels }
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined };
export interface Threshold {
id: string;
label: string;
thresholdValue: number;
recoveryThresholdValue: number;
unit: string;
channels: string[];
color: string;
}
export enum AlertThresholdOperator {
IS_ABOVE = '1',
IS_BELOW = '2',
IS_EQUAL_TO = '3',
IS_NOT_EQUAL_TO = '4',
ABOVE_BELOW = '7',
}
export enum AlertThresholdMatchType {
AT_LEAST_ONCE = '1',
ALL_THE_TIME = '2',
ON_AVERAGE = '3',
IN_TOTAL = '4',
LAST = '5',
}
export interface AlertThresholdState {
selectedQuery: string;
operator: AlertThresholdOperator;
matchType: AlertThresholdMatchType;
evaluationWindow: string;
algorithm: string;
seasonality: string;
thresholds: Threshold[];
}
export enum TimeDuration {
ONE_MINUTE = '1m0s',
FIVE_MINUTES = '5m0s',
TEN_MINUTES = '10m0s',
FIFTEEN_MINUTES = '15m0s',
ONE_HOUR = '1h0m0s',
THREE_HOURS = '3h0m0s',
FOUR_HOURS = '4h0m0s',
TWENTY_FOUR_HOURS = '24h0m0s',
}
export enum Algorithm {
STANDARD = 'standard',
}
export enum Seasonality {
HOURLY = 'hourly',
DAILY = 'daily',
WEEKLY = 'weekly',
}
export type AlertThresholdAction =
| { type: 'SET_SELECTED_QUERY'; payload: string }
| { type: 'SET_OPERATOR'; payload: AlertThresholdOperator }
| { type: 'SET_MATCH_TYPE'; payload: AlertThresholdMatchType }
| { type: 'SET_EVALUATION_WINDOW'; payload: string }
| { type: 'SET_ALGORITHM'; payload: string }
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'RESET' };

View File

@@ -0,0 +1,112 @@
import { QueryParams } from 'constants/query';
import {
alertDefaults,
anamolyAlertDefaults,
exceptionAlertDefaults,
logAlertDefaults,
traceAlertDefaults,
} from 'container/CreateAlertRule/defaults';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
import {
AlertState,
AlertThresholdAction,
AlertThresholdState,
CreateAlertAction,
} from './types';
export const alertCreationReducer = (
state: AlertState,
action: CreateAlertAction,
): AlertState => {
switch (action.type) {
case 'SET_ALERT_NAME':
return {
...state,
name: action.payload,
};
case 'SET_ALERT_DESCRIPTION':
return {
...state,
description: action.payload,
};
case 'SET_ALERT_LABELS':
return {
...state,
labels: action.payload,
};
case 'SET_Y_AXIS_UNIT':
return {
...state,
yAxisUnit: action.payload,
};
default:
return state;
}
};
export function getInitialAlertType(currentQuery: Query): AlertTypes {
const dataSource =
currentQuery.builder.queryData[0].dataSource || DataSource.METRICS;
switch (dataSource) {
case DataSource.METRICS:
return AlertTypes.METRICS_BASED_ALERT;
case DataSource.LOGS:
return AlertTypes.LOGS_BASED_ALERT;
case DataSource.TRACES:
return AlertTypes.TRACES_BASED_ALERT;
default:
return AlertTypes.METRICS_BASED_ALERT;
}
}
export function buildInitialAlertDef(alertType: AlertTypes): AlertDef {
switch (alertType) {
case AlertTypes.LOGS_BASED_ALERT:
return logAlertDefaults;
case AlertTypes.TRACES_BASED_ALERT:
return traceAlertDefaults;
case AlertTypes.EXCEPTIONS_BASED_ALERT:
return exceptionAlertDefaults;
case AlertTypes.ANOMALY_BASED_ALERT:
return anamolyAlertDefaults;
case AlertTypes.METRICS_BASED_ALERT:
return alertDefaults;
default:
return alertDefaults;
}
}
export function getInitialAlertTypeFromURL(
urlSearchParams: URLSearchParams,
currentQuery: Query,
): AlertTypes {
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
return alertTypeFromURL
? (alertTypeFromURL as AlertTypes)
: getInitialAlertType(currentQuery);
}
export const alertThresholdReducer = (
state: AlertThresholdState,
action: AlertThresholdAction,
): AlertThresholdState => {
switch (action.type) {
case 'SET_SELECTED_QUERY':
return { ...state, selectedQuery: action.payload };
case 'SET_OPERATOR':
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':
return INITIAL_ALERT_THRESHOLD_STATE;
default:
return state;
}
};

View File

@@ -0,0 +1,3 @@
import CreateAlertV2 from './CreateAlertV2';
export default CreateAlertV2;

View File

@@ -0,0 +1,3 @@
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';

View File

@@ -8,6 +8,8 @@ import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
@@ -51,7 +53,7 @@ import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import { ChartContainer } from './styles';
import { getThresholdLabel } from './utils';
import { getThresholds } from './utils';
export interface ChartPreviewProps {
name: string;
@@ -65,6 +67,8 @@ export interface ChartPreviewProps {
allowSelectedIntervalForStepGen?: boolean;
yAxisUnit: string;
setQueryStatus?: (status: string) => void;
showSideLegend?: boolean;
additionalThresholds?: Threshold[];
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -80,10 +84,27 @@ function ChartPreview({
alertDef,
yAxisUnit,
setQueryStatus,
showSideLegend = false,
additionalThresholds,
}: ChartPreviewProps): JSX.Element | null {
const { t } = useTranslation('alerts');
const dispatch = useDispatch();
const threshold = alertDef?.condition.target || 0;
const thresholds: Threshold[] = useMemo(
() =>
additionalThresholds || [
{
...INITIAL_CRITICAL_THRESHOLD,
thresholdValue: alertDef?.condition.target || 0,
unit: alertDef?.condition.targetUnit || '',
},
],
[
additionalThresholds,
alertDef?.condition.target,
alertDef?.condition.targetUnit,
],
);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
@@ -236,6 +257,18 @@ function ChartPreview({
const { timezone } = useTimezone();
const legendPosition = useMemo(() => {
if (!showSideLegend) {
return LegendPosition.BOTTOM;
}
const numberOfSeries =
queryResponse?.data?.payload?.data?.result?.length || 0;
if (numberOfSeries <= 1) {
return LegendPosition.BOTTOM;
}
return LegendPosition.RIGHT;
}, [queryResponse?.data?.payload?.data?.result?.length, showSideLegend]);
const options = useMemo(
() =>
getUPlotChartOptions({
@@ -250,24 +283,7 @@ function ChartPreview({
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds: [
{
index: '0', // no impact
keyIndex: 0,
moveThreshold: (): void => {},
selectedGraph: PANEL_TYPES.TIME_SERIES, // no impact
thresholdValue: threshold,
thresholdLabel: `${t(
'preview_chart_threshold_label',
)} (y=${getThresholdLabel(
optionName,
threshold,
alertDef?.condition.targetUnit,
yAxisUnit,
)})`,
thresholdUnit: alertDef?.condition.targetUnit,
},
],
thresholds: getThresholds(thresholds, t, optionName, yAxisUnit),
softMax: null,
softMin: null,
panelType: graphType,
@@ -279,7 +295,7 @@ function ChartPreview({
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
legendPosition,
}),
[
yAxisUnit,
@@ -289,15 +305,15 @@ function ChartPreview({
maxTimeScale,
isDarkMode,
onDragSelect,
threshold,
thresholds,
t,
optionName,
alertDef?.condition.targetUnit,
graphType,
timezone.value,
currentQuery,
query,
graphVisibility,
legendPosition,
],
);
@@ -370,6 +386,8 @@ ChartPreview.defaultProps = {
allowSelectedIntervalForStepGen: false,
alertDef: undefined,
setQueryStatus: (): void => {},
showSideLegend: false,
additionalThresholds: undefined,
};
export default ChartPreview;

View File

@@ -1,3 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
BooleanFormats,
DataFormats,
@@ -6,6 +10,7 @@ import {
ThroughputFormats,
TimeFormats,
} from 'container/NewWidget/RightContainer/types';
import { TFunction } from 'i18next';
import {
dataFormatConfig,
@@ -83,3 +88,57 @@ interface IUnit {
sourceUnit?: string;
targetUnit?: string;
}
export const getThresholds = (
thresholds: Threshold[],
t: TFunction,
optionName: string,
yAxisUnit: string,
): ThresholdProps[] => {
const thresholdsToReturn = new Array<ThresholdProps>();
thresholds.forEach((threshold, index) => {
// Push main threshold
const mainThreshold = {
index: index.toString(),
keyIndex: index,
moveThreshold: (): void => {},
selectedGraph: PANEL_TYPES.TIME_SERIES,
thresholdValue: threshold.thresholdValue,
thresholdLabel:
threshold.label ||
`${t('preview_chart_threshold_label')} (y=${getThresholdLabel(
optionName,
threshold.thresholdValue,
threshold.unit,
yAxisUnit,
)})`,
thresholdUnit: threshold.unit,
thresholdColor: threshold.color || Color.TEXT_SAKURA_500,
};
thresholdsToReturn.push(mainThreshold);
// Push recovery threshold
if (threshold.recoveryThresholdValue) {
const recoveryThreshold = {
index: (thresholds.length + index).toString(),
keyIndex: thresholds.length + index,
moveThreshold: (): void => {},
selectedGraph: PANEL_TYPES.TIME_SERIES, // no impact
thresholdValue: threshold.recoveryThresholdValue,
thresholdLabel: threshold.label
? `${threshold.label} (Recovery)`
: `${t('preview_chart_threshold_label')} (y=${getThresholdLabel(
optionName,
threshold.thresholdValue,
threshold.unit,
yAxisUnit,
)})`,
thresholdUnit: threshold.unit,
thresholdColor: threshold.color || Color.TEXT_SAKURA_500,
};
thresholdsToReturn.push(recoveryThreshold);
}
});
return thresholdsToReturn;
};

View File

@@ -31,6 +31,7 @@ function QuerySection({
alertDef,
panelType,
ruleId,
hideTitle,
}: QuerySectionProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
@@ -218,7 +219,9 @@ function QuerySection({
return (
<>
<StepHeading> {t('alert_form_step2', { step: step2Label })}</StepHeading>
{!hideTitle && (
<StepHeading> {t('alert_form_step2', { step: step2Label })}</StepHeading>
)}
<FormContainer className="alert-query-section-container">
<div>{renderTabs(alertType)}</div>
{renderQuerySection(currentTab)}
@@ -235,6 +238,11 @@ interface QuerySectionProps {
alertDef: AlertDef;
panelType: PANEL_TYPES;
ruleId: string;
hideTitle?: boolean;
}
QuerySection.defaultProps = {
hideTitle: false,
};
export default QuerySection;

View File

@@ -53,7 +53,6 @@ const mockProps: WidgetGraphComponentProps = {
description: '',
fillSpans: false,
id: '17f905f6-d355-46bd-a78e-cbc87e6f58cc',
isStacked: false,
mergeAllActiveQueries: false,
nullZeroValues: 'zero',
opacity: '1',

View File

@@ -31,7 +31,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -105,7 +105,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Utilization (bytes)</div>,
title: <div className="column-header-left">Memory Utilization (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -113,7 +113,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Allocatable (bytes)</div>,
title: <div className="column-header-left">Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

View File

@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -211,10 +211,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -203,10 +203,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left small-col">Mem Usage</div>,
title: <div className="column-header-left small-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
sorter: true,
align: 'left',
},

View File

@@ -84,7 +84,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -238,10 +238,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header small-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -99,10 +99,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Mem Usage</div>,
title: <div className="column-header-left">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
sorter: true,
align: 'left',
},

View File

@@ -37,7 +37,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Memory Usage (bytes)',
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -121,7 +121,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Usage (bytes)</div>,
title: <div className="column-header-left">Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -129,7 +129,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Alloc (bytes)</div>,
title: <div className="column-header-left">Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

View File

@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -211,10 +211,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

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