Compare commits

...

19 Commits

Author SHA1 Message Date
nityanandagohain
445d3e8c3e fix: update validation for select and group by 2025-10-10 14:41:10 +05:30
primus-bot[bot]
6c59b5405e chore(release): bump to v0.97.0 (#9305)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-10-10 12:19:35 +05:30
Chitransh
d26b57b0d8 feat: added new datasources (#9167)
* feat: added new datasources

* fix: added new datasource
2025-10-09 08:46:14 +00:00
Aditya Singh
da17375f10 Preferences framework re-factor (#9206)
* fix: logs popover content logic extracted out

* fix: logs popover content in live view

* fix: destory popover on close

* feat: add logs format tests

* feat: minor refactor

* feat: test case refactor

* feat: remove menu refs in logs live view

* feat: globalise Preference context and remove async logic

* feat: change preference context state structure to support both logs and traces pref

* feat: test refactor
2025-10-09 04:40:52 +00:00
Vikrant Gupta
a96489d06e feat(authz): address tenant isolation for authz (#9293)
* feat(authz): address tenant isolation for authz

* feat(authz): handle role module self registry

* feat(authz): keep role / user / resource sync in naming

* feat(authz): rename orgId to orgID

* feat(authz): add the missing / for user

* feat(authz): remove embedding for pkgopenfgaauthz service
2025-10-08 17:04:00 +00:00
Nityananda Gohain
8c29debb52 fix: use numerical comparison instead of lexicographical for string-encoded numbers (#9154)
* fix: let clickhouse handle string to number conversion

* fix: ignore casting if it's a comparison operator for number key

* fix: add integration tests

* fix: update comments

* fix: convert only if it's actually not a integrer with comparison operator

* fix: force convert to float when number

* fix: integration tests

* fix: correct the comment

* fix: update comment

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-10-08 14:21:40 +05:30
Srikanth Chekuri
9cebd49a2c fix: anomaly with below operator negates the target (#9288) 2025-10-08 12:11:31 +05:30
Shaheer Kochai
a22ef64bb0 fix: fix the flaky test (#9255)
* fix: fix the flaky test

* chore: remove unnecessary changes
2025-10-06 22:02:12 +05:30
Amlan Kumar Nandy
c770a1a4e1 chore: remove routing policies feature flag (#9249) 2025-10-04 21:39:18 +05:30
Nageshbansal
101b3668b5 chore(statsreporter): fix azure IMDS endpoint in statsreporter (#9243) 2025-10-03 17:00:06 +00:00
Srikanth Chekuri
1b1aa4915b chore: link to docs, QB flavour for expr and update options (#9246) 2025-10-03 21:50:56 +05:30
aniketio-ctrl
f9a70a3a69 chore: notification routing | added notificaiton routing via expression based routes (#9195)
* chore: added custom distpatcher

* feat(notification-grouping): added notification grouping

* feat(notification-grouping): addded integration test dependency

* feat(notification-grouping): linting and test cases

* feat(notification-grouping): linting and test cases

* feat(notification-grouping): linting and test cases

* feat(notification-grouping): addded integration test dependency

* feat(notification-grouping): debug log lines

* feat(notification-grouping): debug log lines

* feat(notification-grouping): debug log lines

* feat(notification-grouping): addded integration test dependency

* feat(notification-grouping): addded integration test dependency

* feat(notification-grouping): addded integration test dependency

* feat(notification-grouping): added structure changes

* feat(notification-grouping): added structure changes

* feat(notification-routing): added notification routing

* chore(notification-grouping): added notificaiton grouping

* Update pkg/alertmanager/nfmanager/rulebasednotification/provider.go

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

* chore(notification-grouping): added renotification interval

* fix(notification-grouping): added fix for renotification

* chore(notificaiton-grouping): added no data renotify

* chore(notificaiton-grouping): added no data renotify

* chore(notificaiton-grouping): added no data renotify

* chore(notification-grouping): added no data renotify interval

* chore(notification-grouping): removed errors package from dispatcher

* chore(notification-grouping): removed errors package from dispatcher

* chore(notification-grouping): removed unwanted tests

* chore(notification-grouping): removed unwanted pkg name

* chore(notification-grouping): added delete notification setting

* chore(notification-grouping): added delete notification setting

* Update pkg/alertmanager/nfmanager/nfmanagertest/provider.go

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

* chore(notification-grouping): removed nfmanager config| notification settings in postable rule

* chore(notification-grouping): removed nfmanager config| notification settings in postable rule

* chore(notification-grouping): added test for dispatcher

* chore(notification-grouping): added test for dispatcher

* chore(notification-grouping): go linting errors

* chore(notification-grouping): added test cases for aggGroupPerRoute

* chore(notification-grouping): added test cases for aggGroupPerRoute

* chore(notification-grouping): corrected get notification config logic

* Update pkg/alertmanager/nfmanager/rulebasednotification/provider_test.go

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

* chore(notification-routing): added notification routing policies

* feat(notification-routing): added test cases for dispatcher

* chore(notification-routing): added notification routing policies

* chore(notification-routing): added notification routing policies

* Apply suggestions from code review

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

* chore(notification-routing): added notification routing policies

* chore(notification-routing): added notification routing policies

* Update pkg/alertmanager/alertmanagerserver/distpatcher_test.go

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

* chore(notification-routing): sorted imports

* chore(notification-routing): minor edit |pr resolve comments

* chore(notification-grouping): corrected dispatcher test cases

* chore(notification-routing): added notification routing policies

* chore(notification-routing): corrected race condition in test

* chore: resolved pr comments

* chore: passing threshold value to tempalte

* chore: completed delete rule functionality

* chore: added grouping disabled functionality

* chore: added grouping disabled functionality

* chore(notification-routing): resolved pr comments

* chore(notification-routing): resolved pr comments

* chore(notification-routing): resolved pr comments

* chore(notification-routing): sorted imports

* chore(notification-routing): fix linting errors

* chore(notification-routing): removed enabled flags

* fix: test rule multiple threhsold (#9224)

* chore: corrected linting errors

* chore: corrected linting errors

* chore: corrected linting errors

* chore: corrected linting errors

* chore: corrected migration errors

* chore: corrected migration errors

* chore: corrected migration errors

* chore: corrected migration errors

* Update pkg/sqlmigration/049_add_route_policy.go

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

* chore: added org_is as foreign key

* chore: resolved pr comments

* chore: removed route store unused

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-10-03 19:47:15 +05:30
Nityananda Gohain
d3be2632b6 fix: exists/not exists for resource attributes (#9129)
* fix: exists/not exists for resource attributes

* fix: update tests

* fix: remove unwanted changes

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-10-03 05:42:42 +00:00
Srikanth Chekuri
78e4f4f386 fix: handle resource/attribute context collision with expression using paranthesis (#9240) 2025-10-03 10:16:06 +05:30
Amlan Kumar Nandy
cbb24d9a34 chore: edit alerts api integration (#9210) 2025-09-30 17:37:47 +00:00
Nageshbansal
9ffe0d8143 chore(statsreporter): add nomad, vultr, aca platform detection (#9220) 2025-09-30 16:31:43 +05:30
Amlan Kumar Nandy
1a1ef5aff8 chore: create alerts ux improvements and api integration (#9165) 2025-09-29 17:03:29 +00:00
Abhi kumar
8b21ba5db9 ISSUE:2806 - View traces/logs functionality across the product with new QB (#9207)
* fix: issue-2806 view traces/logs functionality across the product with new qb

* test: added test for getfilter

* test: updated tests
2025-09-29 19:12:50 +05:30
Vikrant Gupta
1b818dd05d feat(authz): build role module (#9136)
* feat(authz): build role module

* feat(authz): build role module

* feat(authz): refactor the role module to move transactions out

* feat(authz): add handler implementation except patch objects

* feat(authz): added the missing handler

* feat(authz): added changes for selectors

* feat(authz): added changes for selectors

* feat(authz): added changes for selectors

* feat(authz): make the role create handler just to create metadata

* feat(authz): address review comments

* feat(authz): address review comments

* feat(authz): address review comments

* feat(authz): address review comments
2025-09-29 17:45:52 +05:30
224 changed files with 13015 additions and 2798 deletions

View File

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

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.96.1
image: signoz/signoz:v0.97.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.6
image: signoz/signoz-otel-collector:v0.129.7
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.6
image: signoz/signoz-schema-migrator:v0.129.7
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.96.1
image: signoz/signoz:v0.97.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.6
image: signoz/signoz-otel-collector:v0.129.7
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.6
image: signoz/signoz-schema-migrator:v0.129.7
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.96.1}
image: signoz/signoz:${VERSION:-v0.97.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.96.1}
image: signoz/signoz:${VERSION:-v0.97.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-async
command:
- async

View File

@@ -232,7 +232,7 @@ func (p *BaseSeasonalProvider) getPredictedSeries(
// moving avg of the previous period series + z score threshold * std dev of the series
// moving avg of the previous period series - z score threshold * std dev of the series
func (p *BaseSeasonalProvider) getBounds(
series, predictedSeries *qbtypes.TimeSeries,
series, predictedSeries, weekSeries *qbtypes.TimeSeries,
zScoreThreshold float64,
) (*qbtypes.TimeSeries, *qbtypes.TimeSeries) {
upperBoundSeries := &qbtypes.TimeSeries{
@@ -246,8 +246,8 @@ func (p *BaseSeasonalProvider) getBounds(
}
for idx, curr := range series.Values {
upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(series)
lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(series)
upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(weekSeries)
lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(weekSeries)
upperBoundSeries.Values = append(upperBoundSeries.Values, &qbtypes.TimeSeriesValue{
Timestamp: curr.Timestamp,
Value: upperBound,
@@ -398,8 +398,6 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
aggOfInterest := result.Aggregations[0]
for _, series := range aggOfInterest.Series {
stdDev := p.getStdDev(series)
p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels)
pastPeriodSeries := p.getMatchingSeries(ctx, pastPeriodResult, series)
currentSeasonSeries := p.getMatchingSeries(ctx, currentSeasonResult, series)
@@ -407,6 +405,9 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
past2SeasonSeries := p.getMatchingSeries(ctx, past2SeasonResult, series)
past3SeasonSeries := p.getMatchingSeries(ctx, past3SeasonResult, series)
stdDev := p.getStdDev(currentSeasonSeries)
p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels)
prevSeriesAvg := p.getAvg(pastPeriodSeries)
currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries)
pastSeasonSeriesAvg := p.getAvg(pastSeasonSeries)
@@ -435,6 +436,7 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
upperBoundSeries, lowerBoundSeries := p.getBounds(
series,
predictedSeries,
currentSeasonSeries,
zScoreThreshold,
)
aggOfInterest.UpperBoundSeries = append(aggOfInterest.UpperBoundSeries, upperBoundSeries)

View File

@@ -0,0 +1,79 @@
package openfgaauthz
import (
"context"
"github.com/SigNoz/signoz/pkg/authz"
pkgopenfgaauthz "github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
)
type provider struct {
pkgAuthzService authz.AuthZ
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
}
return &provider{
pkgAuthzService: pkgAuthzService,
}, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.pkgAuthzService.Start(ctx)
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.pkgAuthzService.Stop(ctx)
}
func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
return provider.pkgAuthzService.Check(ctx, tuple)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.BatchCheck(ctx, tuples)
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return provider.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -26,10 +26,6 @@ type resources
define create: [user, role#assignee]
define list: [user, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resource
relations
define read: [user, anonymous, role#assignee]

View File

@@ -1,132 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZ struct {
logger *slog.Logger
authzService authz.AuthZ
}
func NewAuthZ(logger *slog.Logger) *AuthZ {
if logger == nil {
panic("cannot build authz middleware, logger is empty")
}
return &AuthZ{logger: logger}
}
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)
})
}
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
selector, parentSelectors, err := cb(req)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}

View File

@@ -78,11 +78,6 @@ func NewAnomalyRule(
opts = append(opts, baserules.WithLogger(logger))
if p.RuleCondition.CompareOp == ruletypes.ValueIsBelow {
target := -1 * *p.RuleCondition.Target
p.RuleCondition.Target = &target
}
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
if err != nil {
return nil, err
@@ -251,7 +246,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@@ -301,7 +296,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@@ -336,14 +331,19 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
resultFPs := map[uint64]struct{}{}
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
ruleReceivers := r.Threshold.GetRuleReceivers()
ruleReceiverMap := make(map[string][]string)
for _, value := range ruleReceivers {
ruleReceiverMap[value.Name] = value.Channels
}
for _, smpl := range res {
l := make(map[string]string, len(smpl.Metric))
for _, lbl := range smpl.Metric {
l[lbl.Name] = lbl.Value
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(r.TargetVal(), r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
@@ -408,13 +408,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
State: model.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: r.PreferredChannels(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
Missing: smpl.IsMissing,
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
@@ -423,7 +422,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
alert.Value = a.Value
alert.Annotations = a.Annotations
alert.Receivers = r.PreferredChannels()
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
alert.Receivers = ruleReceiverMap[v]
}
continue
}

View File

@@ -126,7 +126,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Annotations[labels.AlertSummaryLabel] = fmt.Sprintf("The rule threshold is set to %.4f, and the observed metric value is {{$value}}.", *parsedRule.RuleCondition.Target)
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""

View File

@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LangChain</title><path d="M8.373 14.502c.013-.06.024-.118.038-.17l.061.145c.115.28.229.557.506.714-.012.254-.334.357-.552.326-.048-.114-.115-.228-.255-.164-.143.056-.3-.01-.266-.185.333-.012.407-.371.468-.666zM18.385 9.245c-.318 0-.616.122-.839.342l-.902.887c-.243.24-.368.572-.343.913l.006.056c.032.262.149.498.337.682.13.128.273.21.447.266a.866.866 0 01-.247.777l-.056.055a2.022 2.022 0 01-1.355-1.555l-.01-.057-.046.037c-.03.024-.06.05-.088.078l-.902.887a1.156 1.156 0 000 1.65c.231.228.535.342.84.342.304 0 .607-.114.838-.341l.902-.888a1.156 1.156 0 00-.436-1.921.953.953 0 01.276-.842 2.062 2.062 0 011.371 1.57l.01.057.047-.037c.03-.024.06-.05.088-.078l.902-.888a1.155 1.155 0 000-1.65 1.188 1.188 0 00-.84-.342z" fill="#1C3C3C"></path><path clip-rule="evenodd" d="M17.901 6H6.1C2.736 6 0 8.692 0 12s2.736 6 6.099 6H17.9C21.264 18 24 15.308 24 12s-2.736-6-6.099-6zm-5.821 9.407c-.195.04-.414.047-.562-.106-.045.1-.136.077-.221.056a.797.797 0 00-.061-.014c-.01.025-.017.048-.026.073-.329.021-.575-.309-.732-.558a4.991 4.991 0 00-.473-.21c-.172-.07-.345-.14-.509-.23a2.218 2.218 0 00-.004.173c-.002.244-.004.503-.227.651-.007.295.236.292.476.29.207-.003.41-.005.447.184a.485.485 0 01-.05.003c-.046 0-.092 0-.127.034-.117.111-.242.063-.372.013-.12-.046-.243-.094-.367-.02a2.318 2.318 0 00-.262.154.97.97 0 01-.548.194c-.024-.036-.014-.059.006-.08a.562.562 0 00.043-.056c.019-.028.035-.057.051-.084.054-.095.103-.18.242-.22-.185-.029-.344.055-.5.137l-.004.002a4.21 4.21 0 01-.065.034c-.097.04-.154.009-.212-.023-.082-.045-.168-.092-.376.04-.04-.032-.02-.061.002-.086.091-.109.21-.125.345-.119-.351-.193-.604-.056-.81.055-.182.098-.327.176-.471-.012-.065.017-.102.063-.138.108-.015.02-.03.038-.047.055-.035-.039-.027-.083-.018-.128l.005-.026a.242.242 0 00.003-.03l-.027-.01c-.053-.022-.105-.044-.09-.124-.117-.04-.2.03-.286.094-.054-.041-.01-.095.032-.145a.279.279 0 00.045-.065c.038-.065.103-.067.166-.069.054-.001.108-.003.145-.042.133-.075.297-.036.462.003.121.028.242.057.354.042.203.025.454-.18.352-.385-.186-.233-.184-.528-.183-.813v-.143c-.016-.108-.172-.233-.328-.358-.12-.095-.24-.191-.298-.28-.16-.177-.285-.382-.409-.585l-.015-.024c-.212-.404-.297-.86-.382-1.315-.103-.546-.205-1.09-.526-1.54-.266.144-.612.075-.841-.118-.12.107-.13.247-.138.396l-.001.014c-.297-.292-.26-.844-.023-1.17.097-.128.213-.233.342-.326.03-.021.04-.042.039-.074.235-1.04 1.836-.839 2.342-.103.167.206.281.442.395.678.137.283.273.566.5.795.22.237.452.463.684.689.359.35.718.699 1.032 1.089.49.587.839 1.276 1.144 1.97.05.092.08.193.11.293.044.15.089.299.2.417.026.035.084.088.149.148.156.143.357.328.289.409.009.019.027.04.05.06.032.028.074.058.116.088.122.087.25.178.16.25zm7.778-3.545l-.902.887c-.24.237-.537.413-.859.51l-.017.005-.006.015A2.021 2.021 0 0117.6 14l-.902.888c-.393.387-.916.6-1.474.6-.557 0-1.08-.213-1.474-.6a2.03 2.03 0 010-2.9l.902-.888c.242-.238.531-.409.859-.508l.016-.004.006-.016c.105-.272.265-.516.475-.724l.902-.887c.393-.387.917-.6 1.474-.6.558 0 1.08.213 1.474.6.394.387.61.902.61 1.45 0 .549-.216 1.064-.61 1.45v.001z" fill="#1C3C3C" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LlamaIndex</title><path d="M15.855 17.122c-2.092.924-4.358.545-5.23.24 0 .21-.01.857-.048 1.78-.038.924-.332 1.507-.475 1.684.016.577.029 1.837-.047 2.26a1.93 1.93 0 01-.476.914H8.295c.114-.577.555-.946.761-1.058.114-1.193-.11-2.229-.238-2.597-.126.449-.437 1.49-.665 2.068a6.418 6.418 0 01-.713 1.299h-.951c-.048-.578.27-.77.475-.77.095-.177.323-.731.476-1.54.152-.807-.064-2.324-.19-2.981v-2.068c-1.522-.818-2.092-1.636-2.473-2.55-.304-.73-.222-1.843-.142-2.308-.096-.176-.373-.625-.476-1.25-.142-.866-.063-1.491 0-1.828-.095-.096-.285-.587-.285-1.78 0-1.192.349-1.811.523-1.972v-.529c-.666-.048-1.331-.336-1.712-.721-.38-.385-.095-.962.143-1.154.238-.193.475-.049.808-.145.333-.096.618-.192.76-.48C4.512 1.403 4.287.448 4.16 0c.57.077.935.577 1.046.818V0c.713.337 1.997 1.154 2.425 2.934.342 1.424.586 4.409.665 5.723 1.823.016 4.137-.26 6.229.193 1.901.412 2.757 1.25 3.755 1.25.999 0 1.57-.577 2.282-.096.714.481 1.094 1.828.999 2.838-.076.808-.697 1.074-.998 1.106-.38 1.27 0 2.485.237 2.934v1.827c.111.16.333.655.333 1.347 0 .693-.222 1.154-.333 1.299.19 1.077-.08 2.18-.238 2.597h-1.283c.152-.385.412-.481.523-.481.228-1.193.063-2.293-.048-2.693-.722-.424-1.188-1.17-1.331-1.491.016.272-.029 1.029-.333 1.875-.304.847-.76 1.347-.95 1.491v1.01h-1.284c0-.615.348-.737.523-.721.222-.4.76-1.01.76-2.212 0-1.015-.713-1.492-1.236-2.405-.248-.434-.127-.978-.047-1.203z" fill="url(#lobe-icons-llama-index-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-llama-index-fill" x1="4.021" x2="24.613" y1="2.02" y2="19.277"><stop offset=".062" stop-color="#F6DCD9"></stop><stop offset=".326" stop-color="#FFA5EA"></stop><stop offset=".589" stop-color="#45DFF8"></stop><stop offset="1" stop-color="#BC8DEB"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#06D092" d="M8 0L1 4v8l7 4 7-4V4L8 0zm3.119 8.797L9.254 9.863 7.001 8.65v2.549l-2.118 1.33v-5.33l1.68-1.018 2.332 1.216V4.794l2.23-1.322-.006 5.325z"/></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<path fill="#f5a800" d="M67.648 69.797c-5.246 5.25-5.246 13.758 0 19.008 5.25 5.246 13.758 5.246 19.004 0 5.25-5.25 5.25-13.758 0-19.008-5.246-5.246-13.754-5.246-19.004 0Zm14.207 14.219a6.649 6.649 0 0 1-9.41 0 6.65 6.65 0 0 1 0-9.407 6.649 6.649 0 0 1 9.41 0c2.598 2.586 2.598 6.809 0 9.407ZM86.43 3.672l-8.235 8.234a4.17 4.17 0 0 0 0 5.875l32.149 32.149a4.17 4.17 0 0 0 5.875 0l8.234-8.235c1.61-1.61 1.61-4.261 0-5.87L92.29 3.671a4.159 4.159 0 0 0-5.86 0ZM28.738 108.895a3.763 3.763 0 0 0 0-5.31l-4.183-4.187a3.768 3.768 0 0 0-5.313 0l-8.644 8.649-.016.012-2.371-2.375c-1.313-1.313-3.45-1.313-4.75 0-1.313 1.312-1.313 3.449 0 4.75l14.246 14.242a3.353 3.353 0 0 0 4.746 0c1.3-1.313 1.313-3.45 0-4.746l-2.375-2.375.016-.012Zm0 0"/>
<path fill="#425cc7" d="M72.297 27.313 54.004 45.605c-1.625 1.625-1.625 4.301 0 5.926L65.3 62.824c7.984-5.746 19.18-5.035 26.363 2.153l9.148-9.149c1.622-1.625 1.622-4.297 0-5.922L78.22 27.313a4.185 4.185 0 0 0-5.922 0ZM60.55 67.585l-6.672-6.672c-1.563-1.562-4.125-1.562-5.684 0l-23.53 23.54a4.036 4.036 0 0 0 0 5.687l13.331 13.332a4.036 4.036 0 0 0 5.688 0l15.132-15.157c-3.199-6.609-2.625-14.593 1.735-20.73Zm0 0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg20"
sodipodi:docname="supabase-icon.svg"
style="fill:none"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1687"
inkscape:window-height="849"
id="namedview22"
showgrid="false"
inkscape:zoom="2.0884956"
inkscape:cx="54.5"
inkscape:cy="56.5"
inkscape:window-x="70"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg20" />
<path
d="m 37.41219,62.936701 c -1.634985,2.05896 -4.950068,0.93085 -4.989463,-1.69817 L 31.846665,22.786035 h 25.855406 c 4.683108,0 7.294967,5.409033 4.382927,9.07673 z"
id="path2"
style="fill:url(#paint0_linear);stroke-width:0.57177335"
inkscape:connector-curvature="0" />
<path
d="m 37.41219,62.936701 c -1.634985,2.05896 -4.950068,0.93085 -4.989463,-1.69817 L 31.846665,22.786035 h 25.855406 c 4.683108,0 7.294967,5.409033 4.382927,9.07673 z"
id="path4"
style="fill:url(#paint1_linear);fill-opacity:0.2;stroke-width:0.57177335"
inkscape:connector-curvature="0" />
<path
d="m 26.89694,1.0634102 c 1.634986,-2.05918508 4.950125,-0.93090008 4.989521,1.698149 L 32.138899,41.214003 H 6.607076 c -4.6832501,0 -7.29518376,-5.409032 -4.3830007,-9.07673 z"
id="path6"
inkscape:connector-curvature="0"
style="fill:#3ecf8e;stroke-width:0.57177335" />
<defs
id="defs18">
<linearGradient
id="paint0_linear"
x1="53.973801"
y1="54.973999"
x2="94.163498"
y2="71.829498"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.57177306,0,0,0.57177334,0.98590077,-0.12074988)">
<stop
stop-color="#249361"
id="stop8" />
<stop
offset="1"
stop-color="#3ECF8E"
id="stop10" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="36.1558"
y1="30.577999"
x2="54.484402"
y2="65.080597"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.57177306,0,0,0.57177334,0.98590077,-0.12074988)">
<stop
id="stop13" />
<stop
offset="1"
stop-opacity="0"
id="stop15" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -27,6 +27,7 @@ import { IUser } from 'providers/App/types';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Router, Switch } from 'react-router-dom';
@@ -382,20 +383,22 @@ function App(): JSX.Element {
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
AlertRuleV2,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
export interface CreateAlertRuleResponse {
data: AlertRuleV2;
status: string;
}
const createAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<CreateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/rules`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default createAlertRule;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface TestAlertRuleResponse {
data: {
alertCount: number;
message: string;
};
status: string;
}
const testAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<TestAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/testRule`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default testAlertRule;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface UpdateAlertRuleResponse {
data: string;
status: string;
}
const updateAlertRule = async (
id: string,
postableAlertRule: PostableAlertRuleV2,
): Promise<SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.put(`/rules/${id}`, {
...postableAlertRule,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateAlertRule;

View File

@@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface CreateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
channels: string[];
description?: string;
}
@@ -23,7 +21,7 @@ const createRoutingPolicy = async (
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.post(`/notification-policy`, props);
const response = await axios.post(`/route_policies`, props);
return {
httpStatusCode: response.status,
data: response.data,

View File

@@ -14,9 +14,7 @@ const deleteRoutingPolicy = async (
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.delete(
`/notification-policy/${routingPolicyId}`,
);
const response = await axios.delete(`/route_policies/${routingPolicyId}`);
return {
httpStatusCode: response.status,

View File

@@ -25,7 +25,7 @@ export const getRoutingPolicies = async (
headers?: Record<string, string>,
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
try {
const response = await axios.get('/notification-policy', {
const response = await axios.get('/route_policies', {
signal,
headers,
});

View File

@@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface UpdateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
channels: string[];
description: string;
}
@@ -24,7 +22,7 @@ const updateRoutingPolicy = async (
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.put(`/notification-policy/${id}`, {
const response = await axios.put(`/route_policies/${id}`, {
...props,
});

View File

@@ -634,4 +634,260 @@ describe('prepareQueryRangePayloadV5', () => {
}),
);
});
it('builds payload for builder queries with filters array but no filter expression', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q8',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: '' },
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'payment-service',
},
{
id: '2',
key: { key: 'http.status_code', type: 'number' },
op: '>=',
value: 400,
},
{
id: '3',
key: { key: 'message', type: 'string' },
op: 'contains',
value: 'error',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
expect(result.legendMap).toEqual({ A: 'Legend A' });
expect(result.queryPayload.compositeQuery.queries).toHaveLength(1);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.name).toBe('A');
expect(logSpec.signal).toBe('logs');
expect(logSpec.filter).toEqual({
expression:
"service.name = 'payment-service' AND http.status_code >= 400 AND message contains 'error'",
});
});
it('uses filter.expression when only expression is provided', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q9',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: 'http.status_code >= 500' },
filters: (undefined as unknown) as IBuilderQuery['filters'],
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: 'http.status_code >= 500' });
});
it('derives expression from filters when filter is undefined', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q10',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: (undefined as unknown) as IBuilderQuery['filter'],
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'checkout',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: "service.name = 'checkout'" });
});
it('prefers filter.expression over filters when both are present', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q11',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: "service.name = 'frontend'" },
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'backend',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: "service.name = 'frontend'" });
});
it('returns empty expression when neither filter nor filters provided', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q12',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: (undefined as unknown) as IBuilderQuery['filter'],
filters: (undefined as unknown) as IBuilderQuery['filters'],
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: '' });
});
it('returns empty expression when filters provided with empty items', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q13',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: '' },
filters: { items: [], op: 'AND' },
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: '' });
});
});

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable sonarjs/no-identical-functions */
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
@@ -14,6 +15,7 @@ import {
BaseBuilderQuery,
FieldContext,
FieldDataType,
Filter,
FunctionName,
GroupByKey,
Having,
@@ -111,6 +113,23 @@ function isDeprecatedField(fieldName: string): boolean {
);
}
function getFilter(queryData: IBuilderQuery): Filter {
const { filter } = queryData;
if (filter?.expression) {
return {
expression: filter.expression,
};
}
if (queryData.filters && queryData.filters?.items?.length > 0) {
return convertFiltersToExpression(queryData.filters);
}
return {
expression: '',
};
}
function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
@@ -124,7 +143,7 @@ function createBaseSpec(
return {
stepInterval: queryData?.stepInterval || null,
disabled: queryData.disabled,
filter: queryData?.filter?.expression ? queryData.filter : undefined,
filter: getFilter(queryData),
groupBy:
queryData.groupBy?.length > 0
? queryData.groupBy.map(

View File

@@ -42,18 +42,31 @@ export function useNavigateToExplorer(): (
builder: {
...widgetQuery.builder,
queryData: widgetQuery.builder.queryData
.map((item) => ({
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: [...(item.filters?.items || []), ...selectedFilters],
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
}))
.map((item) => {
// filter out filters with unique ids
const seen = new Set();
const filterItems = [
...(item.filters?.items || []),
...selectedFilters,
].filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
return {
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: filterItems,
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
};
})
.slice(0, 1),
queryFormulas: [],
},

View File

@@ -2,6 +2,7 @@ import { Form, Row } from 'antd';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
@@ -125,6 +126,16 @@ function CreateRules(): JSX.Element {
);
}
const showNewCreateAlertsPageFlag =
queryParams.get('showNewCreateAlertsPage') === 'true';
if (
showNewCreateAlertsPageFlag &&
alertType !== AlertTypes.ANOMALY_BASED_ALERT
) {
return <CreateAlertV2 alertType={alertType} />;
}
return (
<FormAlertRules
alertType={alertType}

View File

@@ -1,21 +1,34 @@
import './styles.scss';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { Activity, ChartLine } from 'lucide-react';
import { ChartLine } from 'lucide-react';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
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 showCondensedLayoutFlag = showCondensedLayout();
const {
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
@@ -27,15 +40,16 @@ function AlertCondition(): JSX.Element {
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,
},
]
: []),
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
@@ -76,13 +90,25 @@ function AlertCondition(): JSX.Element {
))}
</div>
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<AlertThreshold
channels={channels}
isLoadingChannels={isLoadingChannels}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
)}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && (
<AnomalyThreshold
channels={channels}
isLoadingChannels={isLoadingChannels}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
)}
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
</div>
);
}

View File

@@ -1,14 +1,13 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import { Button, Select, Tooltip, Typography } from 'antd';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import getRandomColor from 'lib/getRandomColor';
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 { useEffect } from 'react';
import { v4 } from 'uuid';
import { useCreateAlertState } from '../context';
import {
@@ -19,34 +18,47 @@ import {
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
getMatchTypeTooltip,
getQueryNames,
RoutingPolicyBanner,
} from './utils';
function AlertThreshold(): JSX.Element {
function AlertThreshold({
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const {
alertState,
thresholdState,
setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { data, isLoading: isLoadingChannels } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
useEffect(() => {
if (
queryNames.length > 0 &&
!queryNames.some((query) => query.value === thresholdState.selectedQuery)
) {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: queryNames[0].value,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryNames, thresholdState.selectedQuery]);
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory || '',
@@ -55,11 +67,15 @@ function AlertThreshold(): JSX.Element {
const addThreshold = (): void => {
let newThreshold;
if (thresholdState.thresholds.length === 1) {
newThreshold = INITIAL_WARNING_THRESHOLD;
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
} else if (thresholdState.thresholds.length === 2) {
newThreshold = INITIAL_INFO_THRESHOLD;
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
} else {
newThreshold = INITIAL_RANDOM_THRESHOLD;
newThreshold = {
...INITIAL_RANDOM_THRESHOLD,
id: v4(),
color: getRandomColor(),
};
}
setThresholdState({
type: 'SET_THRESHOLDS',
@@ -85,17 +101,71 @@ function AlertThreshold(): JSX.Element {
});
};
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
const onTooltipOpenChange = (open: boolean): void => {
// Stop propagation of click events on tooltip text to dropdown
if (open) {
setTimeout(() => {
const tooltipElement = document.querySelector(
'.copyable-tooltip .ant-tooltip-inner',
);
if (tooltipElement) {
tooltipElement.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true,
);
tooltipElement.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true,
);
}
}, 0);
}
};
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
(option) => ({
...option,
label: (
<Tooltip
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
placement="left"
overlayClassName="copyable-tooltip"
overlayStyle={{
maxWidth: '450px',
minWidth: '400px',
}}
overlayInnerStyle={{
padding: '12px 16px',
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text',
}}
mouseEnterDelay={0.2}
trigger={['hover', 'click']}
destroyTooltipOnHide={false}
onOpenChange={onTooltipOpenChange}
>
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
</Tooltip>
),
}),
);
return (
<div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
className={classNames(
'alert-threshold-container',
'condensed-alert-threshold-container',
)}
>
{/* Main condition sentence */}
<div className="alert-condition-sentences">
@@ -114,8 +184,7 @@ function AlertThreshold(): JSX.Element {
style={{ width: 80 }}
options={queryNames}
/>
</div>
<div className="alert-condition-sentence">
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
value={thresholdState.operator}
onChange={(value): void => {
@@ -124,7 +193,7 @@ function AlertThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 120 }}
style={{ width: 180 }}
options={THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text className="sentence-text">
@@ -138,11 +207,11 @@ function AlertThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 140 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS}
style={{ width: 180 }}
options={matchTypeOptionsWithTooltips}
/>
<Typography.Text className="sentence-text">
during the {evaluationWindowContext}
during the <EvaluationSettings />
</Typography.Text>
</div>
</div>
@@ -158,6 +227,8 @@ function AlertThreshold(): JSX.Element {
channels={channels}
isLoadingChannels={isLoadingChannels}
units={categorySelectOptions}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
))}
<Button
@@ -169,6 +240,11 @@ function AlertThreshold(): JSX.Element {
Add Threshold
</Button>
</div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Select, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
@@ -10,10 +11,26 @@ import {
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import { getQueryNames } from './utils';
import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
NotificationChannelsNotFoundContent,
RoutingPolicyBanner,
} from './utils';
function AnomalyThreshold(): JSX.Element {
const { thresholdState, setThresholdState } = useCreateAlertState();
function AnomalyThreshold({
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { user } = useAppContext();
const {
thresholdState,
setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
@@ -27,7 +44,11 @@ function AnomalyThreshold(): JSX.Element {
return options;
}, []);
const updateThreshold = (id: string, field: string, value: string): void => {
const updateThreshold = (
id: string,
field: string,
value: string | string[],
): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
@@ -53,7 +74,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
<Typography.Text
@@ -71,12 +91,11 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_TIME_DURATION_OPTIONS}
/>
</div>
{/* Sentence 2 */}
<div className="alert-condition-sentence">
{/* Sentence 2 */}
<Typography.Text data-testid="threshold-text" className="sentence-text">
is
</Typography.Text>
@@ -90,7 +109,6 @@ function AnomalyThreshold(): JSX.Element {
value.toString(),
);
}}
style={{ width: 80 }}
options={deviationOptions}
/>
<Typography.Text data-testid="deviations-text" className="sentence-text">
@@ -105,7 +123,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text
@@ -123,7 +140,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/>
</div>
@@ -141,7 +157,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_ALGORITHM_OPTIONS}
/>
<Typography.Text
@@ -159,14 +174,58 @@ function AnomalyThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"
className="sentence-text"
>
seasonality to
</Typography.Text>
<Select
value={thresholdState.thresholds[0].channels}
onChange={(value): void =>
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={
<NotificationChannelsNotFoundContent
user={user}
refreshChannels={refreshChannels}
/>
}
/>
</>
) : (
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
)}
</div>
</div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div>
);
}

View File

@@ -1,8 +1,12 @@
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { CircleX, Trash } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useMemo, useState } from 'react';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { ThresholdItemProps } from './types';
import { NotificationChannelsNotFoundContent } from './utils';
function ThresholdItem({
threshold,
@@ -11,7 +15,12 @@ function ThresholdItem({
showRemoveButton,
channels,
units,
isErrorChannels,
refreshChannels,
isLoadingChannels,
}: ThresholdItemProps): JSX.Element {
const { user } = useAppContext();
const { thresholdState, notificationSettings } = useCreateAlertState();
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => {
@@ -45,6 +54,31 @@ function ThresholdItem({
return component;
}, [units, threshold.unit, updateThreshold, threshold.id]);
const getOperatorSymbol = (): string => {
switch (thresholdState.operator) {
case AlertThresholdOperator.IS_ABOVE:
return '>';
case AlertThresholdOperator.IS_BELOW:
return '<';
case AlertThresholdOperator.IS_EQUAL_TO:
return '=';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return '!=';
default:
return '';
}
};
// const addRecoveryThreshold = (): void => {
// setShowRecoveryThreshold(true);
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
// };
const removeRecoveryThreshold = (): void => {
setShowRecoveryThreshold(false);
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
};
return (
<div key={threshold.id} className="threshold-item">
<div className="threshold-row">
@@ -54,80 +88,111 @@ function ThresholdItem({
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)
<div className="threshold-controls">
<Input
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 260 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
style={{ width: 200 }}
/>
<Typography.Text className="sentence-text">on value</Typography.Text>
<Typography.Text className="sentence-text highlighted-text">
{getOperatorSymbol()}
</Typography.Text>
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
{yAxisUnitSelect}
{!notificationSettings.routingPolicies && (
<>
<Typography.Text className="sentence-text">send to</Typography.Text>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.name,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={
<NotificationChannelsNotFoundContent
user={user}
refreshChannels={refreshChannels}
/>
}
/>
</>
)}
{showRecoveryThreshold && (
<>
<Typography.Text className="sentence-text">recover on</Typography.Text>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue ?? ''}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
<Tooltip title="Remove recovery threshold">
<Button
type="default"
icon={<Trash size={16} />}
onClick={removeRecoveryThreshold}
className="icon-btn"
/>
</Tooltip>
</>
)}
<Button.Group>
{!showRecoveryThreshold && (
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={(): void => setShowRecoveryThreshold(true)}
/>
)}
{/* TODO: Add recovery threshold back once the functionality is implemented */}
{/* {!showRecoveryThreshold && (
<Tooltip title="Add recovery threshold">
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={addRecoveryThreshold}
/>
</Tooltip>
)} */}
{showRemoveButton && (
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
<Tooltip title="Remove threshold">
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
</Tooltip>
)}
</Button.Group>
</Space>
</div>
</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>
);
}

View File

@@ -3,6 +3,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -105,7 +106,7 @@ const renderAlertCondition = (
return render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertCondition />
</CreateAlertProvider>
</QueryClientProvider>
@@ -126,9 +127,10 @@ describe('AlertCondition', () => {
// 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();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -136,7 +138,8 @@ describe('AlertCondition', () => {
// 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();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -151,7 +154,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('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();
@@ -165,7 +169,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -176,7 +181,8 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -201,7 +207,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -222,21 +229,21 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// Both tabs should be visible
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_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();
// 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();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -3,11 +3,23 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
const mockProps = {
channels: mockChannels,
isLoadingChannels: mockIsLoadingChannels,
isErrorChannels: mockIsErrorChannels,
refreshChannels: mockRefreshChannels,
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -96,10 +108,14 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
]),
}));
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'IS ABOVE',
IS_ABOVE: 'ABOVE',
} as const;
const createTestQueryClient = (): QueryClient =>
@@ -116,8 +132,8 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<AlertThreshold />
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertThreshold {...mockProps} />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
@@ -125,7 +141,10 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
};
const verifySelectRenders = (title: string): void => {
const select = screen.getByTitle(title);
let select = screen.queryByTitle(title);
if (!select) {
select = screen.getByText(title);
}
expect(select).toBeInTheDocument();
};
@@ -139,7 +158,9 @@ describe('AlertThreshold', () => {
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();
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
});
it('renders query selection dropdown', async () => {
@@ -189,11 +210,11 @@ describe('AlertThreshold', () => {
// First addition should add WARNING threshold
fireEvent.click(addButton);
expect(screen.getByText('WARNING')).toBeInTheDocument();
expect(screen.getByText('warning')).toBeInTheDocument();
// Second addition should add INFO threshold
fireEvent.click(addButton);
expect(screen.getByText('INFO')).toBeInTheDocument();
expect(screen.getByText('info')).toBeInTheDocument();
// Third addition should add random threshold
fireEvent.click(addButton);
@@ -265,7 +286,7 @@ describe('AlertThreshold', () => {
renderAlertThreshold();
// Should have initial critical threshold
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
expect(screen.getByText('critical')).toBeInTheDocument();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});

View File

@@ -1,14 +1,15 @@
/* 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 { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import * as context from '../../context';
import AnomalyThreshold from '../AnomalyThreshold';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -23,12 +24,12 @@ jest.mock('uplot', () => {
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);
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -54,7 +55,14 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(<AnomalyThreshold />);
render(
<AnomalyThreshold
channels={[]}
isLoadingChannels={false}
isErrorChannels={false}
refreshChannels={jest.fn()}
/>,
);
describe('AnomalyThreshold', () => {
beforeEach(() => {

View File

@@ -2,15 +2,37 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
// Mock the enableRecoveryThreshold utility
jest.mock('../../utils', () => ({
enableRecoveryThreshold: jest.fn(() => true),
}));
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
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(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
@@ -21,6 +43,7 @@ const TEST_CONSTANTS = {
CHANNEL_2: 'channel-2',
CHANNEL_3: 'channel-3',
EMAIL_CHANNEL_NAME: 'Email Channel',
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
ENTER_THRESHOLD_NAME: 'Enter threshold name',
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
@@ -59,6 +82,8 @@ const defaultProps: ThresholdItemProps = {
channels: mockChannels,
isLoadingChannels: false,
units: mockUnits,
isErrorChannels: false,
refreshChannels: jest.fn(),
};
const renderThresholdItem = (
@@ -77,10 +102,11 @@ const verifySelectorWidth = (
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
};
const showRecoveryThreshold = (): void => {
const recoveryButton = screen.getByRole('button', { name: '' });
fireEvent.click(recoveryButton);
};
// TODO: Unskip this when recovery threshold is implemented
// const showRecoveryThreshold = (): void => {
// const recoveryButton = screen.getByRole('button', { name: '' });
// fireEvent.click(recoveryButton);
// };
const verifyComponentRendersWithLoading = (): void => {
expect(
@@ -122,7 +148,7 @@ describe('ThresholdItem', () => {
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(valueInput).toHaveValue('100');
expect(valueInput).toHaveValue(100);
});
it('renders unit selector with correct value', () => {
@@ -132,15 +158,6 @@ describe('ThresholdItem', () => {
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 });
@@ -212,38 +229,31 @@ describe('ThresholdItem', () => {
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // Recovery button + remove button
expect(buttons).toHaveLength(1); // 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
// No buttons should be present
const buttons = screen.queryAllByRole('button');
expect(buttons).toHaveLength(0);
});
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)
// The remove button is the first button (with circle-x icon)
const buttons = screen.getAllByRole('button');
const removeButton = buttons[1]; // Second button is the remove button
const removeButton = buttons[0];
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', () => {
// TODO: Unskip this when recovery threshold is implemented
it.skip('shows recovery threshold inputs when recovery button is clicked', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
@@ -251,13 +261,16 @@ describe('ThresholdItem', () => {
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter recovery threshold value'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
).toBeInTheDocument();
});
it('updates recovery threshold value when input changes', () => {
// TODO: Unskip this when recovery threshold is implemented
it.skip('updates recovery threshold value when input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
@@ -290,22 +303,6 @@ describe('ThresholdItem', () => {
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,
@@ -318,7 +315,7 @@ describe('ThresholdItem', () => {
renderThresholdItem({ threshold: emptyThreshold });
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
});
it('renders with correct input widths', () => {
@@ -331,13 +328,13 @@ describe('ThresholdItem', () => {
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(labelInput).toHaveStyle('width: 260px');
expect(valueInput).toHaveStyle('width: 210px');
expect(labelInput).toHaveStyle('width: 200px');
expect(valueInput).toHaveStyle('width: 100px');
});
it('renders channels selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(1, '260px');
verifySelectorWidth(1, '350px');
});
it('renders unit selector with correct width', () => {
@@ -350,37 +347,14 @@ describe('ThresholdItem', () => {
verifyComponentRendersWithLoading();
});
it('renders recovery threshold with correct initial value', () => {
it.skip('renders recovery threshold with correct initial value', () => {
renderThresholdItem();
showRecoveryThreshold();
// 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();
expect(recoveryValueInput).toHaveValue(80);
});
it('handles threshold without channels', () => {

View File

@@ -67,7 +67,7 @@
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: fit-content;
width: 100%;
.alert-condition-sentences {
display: flex;
@@ -90,7 +90,7 @@
}
.ant-select {
width: 240px !important;
width: 240px;
.ant-select-selector {
background-color: var(--bg-ink-300);
@@ -148,6 +148,7 @@
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.ant-input {
background-color: var(--bg-ink-400);
@@ -277,6 +278,29 @@
}
}
}
.routing-policies-info-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
background-color: #4568dc1a;
border: 1px solid var(--bg-robin-500);
padding: 8px 16px;
.ant-typography {
color: var(--bg-robin-500);
}
}
}
.anomaly-threshold-container {
.ant-select {
.ant-select-selector {
min-width: 150px;
}
}
}
.condensed-alert-threshold-container,
@@ -293,7 +317,8 @@
.ant-btn {
display: flex;
align-items: center;
width: 240px;
min-width: 240px;
width: auto;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
@@ -301,6 +326,7 @@
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
@@ -308,6 +334,7 @@
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
@@ -318,3 +345,235 @@
}
}
}
.lightMode {
.alert-condition-container {
.alert-condition {
.alert-condition-tabs {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
}
.alert-threshold-container,
.anomaly-threshold-container {
background-color: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
.alert-condition-sentences {
.alert-condition-sentence {
.sentence-text {
color: var(--text-ink-400);
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--text-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
}
}
.thresholds-section {
.threshold-item {
.threshold-row {
.threshold-controls {
.threshold-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
.icon-btn {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
.recovery-threshold-input-group {
.recovery-threshold-btn {
color: var(--bg-ink-400);
background-color: var(--bg-vanilla-200) !important;
border: 1px solid var(--bg-vanilla-300);
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}
}
.add-threshold-btn,
.ant-btn.add-threshold-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-300);
background-color: transparent;
.ant-typography {
color: var(--bg-ink-400);
}
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-400);
}
}
}
}
.condensed-evaluation-settings-container {
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
min-width: 240px;
width: auto;
.evaluate-alert-conditions-button-left {
color: var(--bg-ink-400);
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
color: var(--bg-ink-400);
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
background-color: var(--bg-vanilla-300);
}
}
}
}
}
.highlighted-text {
font-weight: bold;
color: var(--bg-robin-400);
margin: 0 4px;
}
// Tooltip styles
.tooltip-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.tooltip-description {
margin-bottom: 8px;
span {
font-weight: bold;
color: var(--bg-robin-400);
}
}
.tooltip-example {
margin-bottom: 8px;
color: #8b92a0;
}
.tooltip-link {
.tooltip-link-text {
color: #1890ff;
font-size: 11px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -1,14 +1,18 @@
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { Threshold } from '../context/types';
import {
NotificationSettingsAction,
NotificationSettingsState,
Threshold,
} from '../context/types';
export type UpdateThreshold = {
(thresholdId: string, field: 'channels', value: string[]): void;
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string,
value: string | number | null,
): void;
};
@@ -20,4 +24,20 @@ export interface ThresholdItemProps {
channels: Channels[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
refreshChannels: () => void;
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;
}
export interface RoutingPolicyBannerProps {
notificationSettings: NotificationSettingsState;
setNotificationSettings: (
notificationSettings: NotificationSettingsAction,
) => void;
}

View File

@@ -1,9 +1,19 @@
import { Button, Flex, Switch, Typography } from 'antd';
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 ROUTES from 'constants/routes';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { RoutingPolicyBannerProps } from './types';
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
@@ -44,3 +54,360 @@ export function getCategorySelectOptionByName(
) || []
);
}
const getOperatorWord = (op: AlertThresholdOperator): string => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 'exceed';
case AlertThresholdOperator.IS_BELOW:
return 'fall below';
case AlertThresholdOperator.IS_EQUAL_TO:
return 'equal';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 'not equal';
default:
return 'exceed';
}
};
const getThresholdValue = (op: AlertThresholdOperator): number => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 80;
case AlertThresholdOperator.IS_BELOW:
return 50;
case AlertThresholdOperator.IS_EQUAL_TO:
return 100;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 0;
default:
return 80;
}
};
const getDataPoints = (
matchType: AlertThresholdMatchType,
op: AlertThresholdOperator,
): number[] => {
const dataPointMap: Record<
AlertThresholdMatchType,
Record<AlertThresholdOperator, number[]>
> = {
[AlertThresholdMatchType.AT_LEAST_ONCE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.ALL_THE_TIME]: {
[AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38],
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
},
[AlertThresholdMatchType.ON_AVERAGE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.IN_TOTAL]: {
[AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8],
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
},
[AlertThresholdMatchType.LAST]: {
[AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
};
return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95];
};
const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
const symbolMap: Record<AlertThresholdOperator, string> = {
[AlertThresholdOperator.IS_ABOVE]: '>',
[AlertThresholdOperator.IS_BELOW]: '<',
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
[AlertThresholdOperator.ABOVE_BELOW]: '>',
};
return symbolMap[op] || '>';
};
const handleTooltipClick = (
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
): void => {
e.stopPropagation();
};
function TooltipContent({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<div
role="button"
tabIndex={0}
onClick={handleTooltipClick}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleTooltipClick(e);
}
}}
className="tooltip-content"
>
{children}
</div>
);
}
function TooltipExample({
children,
dataPoints,
operatorSymbol,
thresholdValue,
matchType,
}: {
children: React.ReactNode;
dataPoints: number[];
operatorSymbol: string;
thresholdValue: number;
matchType: AlertThresholdMatchType;
}): JSX.Element {
return (
<div className="tooltip-example">
<strong>Example:</strong>
<br />
Say, For a 5-minute window (configured in Evaluation settings), 1 min
aggregation interval (set up in query) 5{' '}
{matchType === AlertThresholdMatchType.IN_TOTAL
? 'error counts'
: 'data points'}
: [{dataPoints.join(', ')}]<br />
With threshold {operatorSymbol} {thresholdValue}: {children}
</div>
);
}
function TooltipLink(): JSX.Element {
return (
<div className="tooltip-link">
<a
href="https://signoz.io/docs"
target="_blank"
rel="noopener noreferrer"
className="tooltip-link-text"
>
Learn more
</a>
</div>
);
}
export const getMatchTypeTooltip = (
matchType: AlertThresholdMatchType,
operator: AlertThresholdOperator,
): React.ReactNode => {
const operatorSymbol = getTooltipOperatorSymbol(operator);
const operatorWord = getOperatorWord(operator);
const thresholdValue = getThresholdValue(operator);
const dataPoints = getDataPoints(matchType, operator);
const getMatchingPointsCount = (): number =>
dataPoints.filter((p) => {
switch (operator) {
case AlertThresholdOperator.IS_ABOVE:
return p > thresholdValue;
case AlertThresholdOperator.IS_BELOW:
return p < thresholdValue;
case AlertThresholdOperator.IS_EQUAL_TO:
return p === thresholdValue;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return p !== thresholdValue;
default:
return p > thresholdValue;
}
}).length;
switch (matchType) {
case AlertThresholdMatchType.AT_LEAST_ONCE:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ANY</span> of
those aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '}
{thresholdValue})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ALL_THE_TIME:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ALL</span>{' '}
aggregated data points cross the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (all points {operatorWord} {thresholdValue})<br />
If any point was {thresholdValue}, no alert would fire
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ON_AVERAGE: {
const average = (
dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length
).toFixed(1);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>AVERAGE</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (average = {average})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.IN_TOTAL: {
const total = dataPoints.reduce((a, b) => a + b, 0);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>SUM</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (total = {total})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.LAST: {
const lastPoint = dataPoints[dataPoints.length - 1];
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers based on the{' '}
<span>MOST RECENT</span> aggregated data point only.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (last point = {lastPoint})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
default:
return '';
}
};
export function NotificationChannelsNotFoundContent({
user,
refreshChannels,
}: {
user: IUser;
refreshChannels: () => void;
}): JSX.Element {
return (
<Flex justify="space-between">
<Flex gap={4} align="center">
<Typography.Text>No channels yet.</Typography.Text>
{user?.role === USER_ROLES.ADMIN ? (
<Typography.Text>
Create one
<Button
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.
</Button>
</Typography.Text>
) : (
<Typography.Text>Please ask your admin to create one.</Typography.Text>
)}
</Flex>
<Button type="text" onClick={refreshChannels}>
Refresh
</Button>
</Flex>
);
}
export function RoutingPolicyBanner({
notificationSettings,
setNotificationSettings,
}: RoutingPolicyBannerProps): JSX.Element {
return (
<div className="routing-policies-info-banner">
<Typography.Text>
Use <strong>Routing Policies</strong> for dynamic routing
</Typography.Text>
<Switch
checked={notificationSettings.routingPolicies}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_ROUTING_POLICIES',
payload: value,
});
}}
/>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import './styles.scss';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { Labels } from 'types/api/alerts/def';
@@ -8,7 +9,7 @@ import { useCreateAlertState } from '../context';
import LabelsInput from './LabelsInput';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState } = useCreateAlertState();
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
@@ -34,11 +35,14 @@ function CreateAlertHeader(): JSX.Element {
);
return (
<div className="alert-header">
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
>
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
)}
<div className="alert-header__content">
<input
type="text"
@@ -49,15 +53,6 @@ function CreateAlertHeader(): JSX.Element {
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 =>

View File

@@ -1,9 +1,28 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -25,9 +44,11 @@ jest.mock('react-router-dom', () => ({
}),
}));
const ENTER_ALERT_RULE_NAME_PLACEHOLDER = 'Enter alert rule name';
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render(
<CreateAlertProvider>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertHeader />
</CreateAlertProvider>,
);
@@ -40,16 +61,10 @@ describe('CreateAlertHeader', () => {
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...',
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
expect(descriptionInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
});
it('renders LabelsInput component', () => {
@@ -59,19 +74,30 @@ describe('CreateAlertHeader', () => {
it('updates name when typing in name input', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
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...',
it('renders the header with title when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
expect(screen.queryByText('New Alert Rule')).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
).toHaveValue('TEST_ALERT');
});
});

View File

@@ -3,21 +3,6 @@
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;
@@ -44,6 +29,8 @@
display: flex;
flex-direction: column;
gap: 8px;
min-width: 300px;
flex: 1;
}
&__input.title {
@@ -51,6 +38,8 @@
font-weight: 500;
background-color: transparent;
color: var(--text-vanilla-100);
width: 100%;
min-width: 300px;
}
&__input:focus,
@@ -64,6 +53,15 @@
background-color: transparent;
color: var(--text-vanilla-300);
}
.ant-btn {
display: flex;
gap: 4px;
align-items: center;
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-300);
margin-right: 16px;
}
}
.labels-input {
@@ -149,3 +147,74 @@
}
}
}
.lightMode {
.alert-header {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
&__tab {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
}
&__tab::before {
color: var(--bg-ink-100);
}
&__content {
background: var(--bg-vanilla-100);
}
&__input.title {
color: var(--text-ink-100);
}
&__input.description {
color: var(--text-ink-300);
}
}
.edit-alert-header {
width: 100%;
}
.edit-alert-header .alert-header__content {
background: var(--bg-vanilla-200);
}
.labels-input {
&__add-button {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-500);
}
}
&__label-pill {
background-color: #ad7f581a;
color: var(--bg-sienna-400);
border: 1px solid var(--bg-sienna-500);
}
&__remove-button {
color: var(--bg-sienna-400);
&:hover {
color: var(--text-ink-100);
}
}
&__input {
color: var(--bg-ink-500);
&::placeholder {
color: var(--bg-ink-300);
}
}
}
}

View File

@@ -1,17 +1,20 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
padding-bottom: 100px;
}
.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;
.lightMode {
.create-alert-v2-container {
background-color: var(--bg-vanilla-100);
}
}
.sticky-page-spinner {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.35);
z-index: 10000;
pointer-events: auto;
}

View File

@@ -2,34 +2,36 @@ import './CreateAlertV2.styles.scss';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import { buildInitialAlertDef } from './context/utils';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import Footer from './Footer';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { showCondensedLayout } from './utils';
import { CreateAlertV2Props } from './types';
import { Spinner } from './utils';
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
}: {
initialQuery?: Query;
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
const queryToRedirect = buildInitialAlertDef(alertType);
const currentQueryToRedirect = mapQueryDataFromApi(
queryToRedirect.condition.compositeQuery,
);
const showCondensedLayoutFlag = showCondensedLayout();
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
return (
<CreateAlertProvider>
<CreateAlertProvider initialAlertType={alertType}>
<Spinner />
<div className="create-alert-v2-container">
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
<Footer />
</CreateAlertProvider>
);
}

View File

@@ -2,7 +2,7 @@ import './styles.scss';
import { Switch, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { IAdvancedOptionItemProps } from '../types';
@@ -12,9 +12,14 @@ function AdvancedOptionItem({
input,
tooltipText,
onToggle,
defaultShowInput,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
useEffect(() => {
setShowInput(defaultShowInput);
}, [defaultShowInput]);
const handleOnToggle = (): void => {
onToggle?.();
setShowInput((currentShowInput) => !currentShowInput);
@@ -42,7 +47,7 @@ function AdvancedOptionItem({
>
{input}
</div>
<Switch onChange={handleOnToggle} />
<Switch onChange={handleOnToggle} checked={showInput} />
</div>
</div>
);

View File

@@ -114,6 +114,14 @@
height: 32px;
border: 1px solid var(--bg-slate-400);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}

View File

@@ -1,5 +1,4 @@
import { Collapse, Input, Select, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { Collapse, Input, Typography } from 'antd';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
@@ -8,10 +7,6 @@ import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return (
<div className="advanced-options-container">
<Collapse bordered={false}>
@@ -38,24 +33,16 @@ function AdvancedOptions(): JSX.Element {
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit:
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
timeUnit: value as string,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
/>
<Typography.Text>Minutes</Typography.Text>
</div>
}
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
})
}
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
/>
<AdvancedOptionItem
title="Minimum data required"
@@ -80,8 +67,16 @@ function AdvancedOptions(): JSX.Element {
<Typography.Text>Datapoints</Typography.Text>
</div>
}
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
})
}
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
/>
<AdvancedOptionItem
{/* TODO: Add back when the functionality is implemented */}
{/* <AdvancedOptionItem
title="Account for data delay"
description="Shift the evaluation window backwards to account for data processing delays."
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
@@ -119,7 +114,7 @@ function AdvancedOptions(): JSX.Element {
/>
</div>
}
/>
/> */}
</Collapse.Panel>
</Collapse>
</div>

View File

@@ -1,8 +1,8 @@
import './styles.scss';
import '../AdvancedOptionItem/styles.scss';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { Info, Plus } from 'lucide-react';
import { Input, Select, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCreateAlertState } from '../../context';
@@ -36,10 +36,10 @@ function EvaluationCadence(): JSX.Element {
);
}, [advancedOptions.evaluationCadence.mode]);
const showCustomSchedule = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
setIsCustomScheduleButtonVisible(false);
};
// const showCustomSchedule = (): void => {
// setIsEvaluationCadenceDetailsVisible(true);
// setIsCustomScheduleButtonVisible(false);
// };
return (
<div className="evaluation-cadence-container">
@@ -98,13 +98,14 @@ function EvaluationCadence(): JSX.Element {
}
/>
</Input.Group>
<Button
{/* TODO: Add custom schedule back once the functionality is implemented */}
{/* <Button
className="advanced-option-item-button"
onClick={showCustomSchedule}
>
<Plus size={12} />
<Typography.Text>Add custom schedule</Typography.Text>
</Button>
</Button> */}
</div>
)}
</div>

View File

@@ -164,6 +164,14 @@
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
@@ -529,6 +537,15 @@
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}

View File

@@ -1,28 +1,19 @@
import './styles.scss';
import { Button, Popover, Typography } from 'antd';
import { Button, Popover } from 'antd';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AdvancedOptions from './AdvancedOptions';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const {
alertType,
evaluationWindow,
setEvaluationWindow,
} = useCreateAlertState();
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
] = useState(false);
const showCondensedLayoutFlag = showCondensedLayout();
const popoverContent = (
<Popover
@@ -57,33 +48,12 @@ function EvaluationSettings(): JSX.Element {
</Popover>
);
// Layout consists of only the evaluation window popover
if (showCondensedLayoutFlag) {
return (
<div
className="condensed-evaluation-settings-container"
data-testid="condensed-evaluation-settings-container"
>
{popoverContent}
</div>
);
}
// Layout consists of
// - Stepper header
// - Evaluation window popover
// - Advanced options
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<div className="evaluate-alert-conditions-container">
<Typography.Text>Check conditions using data from</Typography.Text>
<div className="evaluate-alert-conditions-separator" />
{popoverContent}
</div>
)}
<AdvancedOptions />
<div
className="condensed-evaluation-settings-container"
data-testid="condensed-evaluation-settings-container"
>
{popoverContent}
</div>
);
}

View File

@@ -3,8 +3,8 @@ import { useMemo } from 'react';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
ROLLING_WINDOW_DESCRIPTION,
getCumulativeWindowDescription,
getRollingWindowDescription,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
@@ -116,7 +116,9 @@ function EvaluationWindowDetails({
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
@@ -134,7 +136,9 @@ function EvaluationWindowDetails({
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
@@ -159,7 +163,9 @@ function EvaluationWindowDetails({
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
@@ -192,7 +198,9 @@ function EvaluationWindowDetails({
return (
<div className="evaluation-window-details">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getRollingWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">

View File

@@ -3,10 +3,10 @@ import classNames from 'classnames';
import { Check } from 'lucide-react';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
ROLLING_WINDOW_DESCRIPTION,
getCumulativeWindowDescription,
getRollingWindowDescription,
} from '../constants';
import {
CumulativeWindowTimeframes,
@@ -96,7 +96,9 @@ function EvaluationWindowPopover({
}
return (
<div className="selection-content">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getRollingWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
@@ -108,7 +110,9 @@ function EvaluationWindowPopover({
) {
return (
<div className="selection-content">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);

View File

@@ -33,6 +33,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -50,6 +51,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -65,6 +67,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -88,6 +91,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -117,6 +121,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -146,6 +151,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -160,9 +166,24 @@ describe('AdvancedOptionItem', () => {
description={defaultProps.description}
input={defaultProps.input}
tooltipText="mock tooltip text"
defaultShowInput={false}
/>,
);
const tooltipIcon = screen.getByTestId('tooltip-icon');
expect(tooltipIcon).toBeInTheDocument();
});
it('should show input when defaultShowInput is true', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput
/>,
);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeVisible();
});
});

View File

@@ -28,9 +28,10 @@ describe('AdvancedOptions', () => {
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
// ).not.toBeInTheDocument();
});
it('should be able to expand the advanced options', () => {
@@ -42,9 +43,10 @@ describe('AdvancedOptions', () => {
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
// ).not.toBeInTheDocument();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
@@ -52,7 +54,8 @@ describe('AdvancedOptions', () => {
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(screen.getByText('Account for data delay')).toBeInTheDocument();
});
it('"Alert when data stops coming" works as expected', () => {
@@ -112,7 +115,7 @@ describe('AdvancedOptions', () => {
});
});
it('"Account for data delay" works as expected', () => {
it.skip('"Account for data delay" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });

View File

@@ -64,10 +64,12 @@ describe('EvaluationCadence', () => {
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
expect(screen.getByText('Minutes')).toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
// TODO: Uncomment this when add custom schedule button is implemented
// expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('should hide the input group when add custom schedule button is clicked', () => {
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should hide the input group when add custom schedule button is clicked', () => {
render(<EvaluationCadence />);
expect(
@@ -84,12 +86,14 @@ describe('EvaluationCadence', () => {
).toBeInTheDocument();
});
it('should not show the edit custom schedule component in default mode', () => {
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should not show the edit custom schedule component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.queryByTestId('edit-custom-schedule')).not.toBeInTheDocument();
});
it('should show the custom schedule text when the mode is custom with selected values', () => {
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should show the custom schedule text when the mode is custom with selected values', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
@@ -118,7 +122,8 @@ describe('EvaluationCadence', () => {
).not.toBeInTheDocument();
});
it('should show evaluation cadence details component when clicked on add custom schedule button', () => {
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should show evaluation cadence details component when clicked on add custom schedule button', () => {
render(<EvaluationCadence />);
expect(

View File

@@ -1,11 +1,13 @@
import { render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import * as utils from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import EvaluationSettings from '../EvaluationSettings';
import { createMockAlertContextState } from './testUtils';
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
@@ -13,52 +15,14 @@ jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
}),
);
jest.mock('../AdvancedOptions', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="advanced-options">AdvancedOptions</div>
),
}));
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
'Check conditions using data from';
describe('EvaluationSettings', () => {
it('should render the default evaluation settings layout', () => {
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).toBeInTheDocument();
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
});
it('should not render evaluation window for anomaly based alert', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
alertType: AlertTypes.ANOMALY_BASED_ALERT,
}),
);
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
});
it('should render the condensed evaluation settings layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<EvaluationSettings />);
// Header, check conditions using data from and advanced options should be hidden
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
// Only evaluation window popover should be visible
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
// Verify that default option is selected
expect(screen.getByText('Rolling')).toBeInTheDocument();
expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
});
});

View File

@@ -24,9 +24,12 @@ describe('EvaluationWindowDetails', () => {
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'Monitors data over a fixed time period that moves forward continuously',
) ?? false,
)[0],
).toBeInTheDocument();
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();

View File

@@ -125,9 +125,12 @@ describe('EvaluationWindowPopover', () => {
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'Monitors data over a fixed time period that moves forward continuously',
) ?? false,
)[0],
).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),

View File

@@ -26,6 +26,14 @@ export const createMockAlertContextState = (
setEvaluationWindow: jest.fn(),
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
setNotificationSettings: jest.fn(),
discardAlertRule: jest.fn(),
testAlertRule: jest.fn(),
isCreatingAlertRule: false,
isTestingAlertRule: false,
createAlertRule: jest.fn(),
isUpdatingAlertRule: false,
updateAlertRule: jest.fn(),
isEditMode: false,
...overrides,
});

View File

@@ -62,8 +62,87 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
value: timezone.value,
}));
export const CUMULATIVE_WINDOW_DESCRIPTION =
'A Cumulative Window has a fixed starting point and expands over time.';
export const getCumulativeWindowDescription = (timeframe?: string): string => {
let example = '';
switch (timeframe) {
case 'currentHour':
example =
'An hourly cumulative window for error count alerts when errors exceed 100. Starting at the top of the hour, it tracks: 20 errors by :15, 55 by :30, 105 by :45 (alert fires).';
break;
case 'currentDay':
example =
'A daily cumulative window for sales alerts when total revenue exceeds $10,000. Starting at midnight, it tracks: $2,000 by 9 AM, $5,500 by noon, $11,000 by 3 PM (alert fires).';
break;
case 'currentMonth':
example =
'A monthly cumulative window for expense alerts when spending exceeds $50,000. Starting on the 1st, it tracks: $15,000 by the 7th, $32,000 by the 15th, $51,000 by the 22nd (alert fires).';
break;
default:
example = '';
}
return `Monitors data accumulated since a fixed starting point. The window grows over time, keeping all historical data from the start.\n\nExample: ${example}`;
};
export const ROLLING_WINDOW_DESCRIPTION =
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';
// eslint-disable-next-line sonarjs/cognitive-complexity
export const getRollingWindowDescription = (duration?: string): string => {
let timeWindow = '5-minute';
let examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
if (duration) {
const match = duration.match(/^(\d+)([mhs])/);
if (match) {
const value = parseInt(match[1], 10);
const unit = match[2];
if (unit === 'm' && !Number.isNaN(value)) {
timeWindow = `${value}-minute`;
const endMinutes1 = 1 + value;
const endMinutes2 = 2 + value;
examples = `14:01:00-14:${String(endMinutes1).padStart(
2,
'0',
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
} else if (unit === 'h' && !Number.isNaN(value)) {
timeWindow = `${value}-hour`;
const endHour1 = 14 + value;
const endHour2 = 14 + value;
examples = `14:00:00-${String(endHour1).padStart(
2,
'0',
)}:00:00, 14:01:00-${String(endHour2).padStart(2, '0')}:01:00`;
} else if (unit === 's' && !Number.isNaN(value)) {
timeWindow = `${value}-second`;
examples = `14:01:00-14:01:${String(value).padStart(
2,
'0',
)}, 14:01:01-14:01:${String(1 + value).padStart(2, '0')}`;
}
} else if (duration === 'custom') {
timeWindow = '5-minute';
examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
} else if (duration.includes('h')) {
const hours = parseInt(duration, 10);
if (!Number.isNaN(hours)) {
timeWindow = `${hours}-hour`;
const endHour = 14 + hours;
examples = `14:00:00-${String(endHour).padStart(
2,
'0',
)}:00:00, 14:01:00-${String(endHour).padStart(2, '0')}:01:00`;
}
} else if (duration.includes('m')) {
const minutes = parseInt(duration, 10);
if (!Number.isNaN(minutes)) {
timeWindow = `${minutes}-minute`;
const endMinutes1 = 1 + minutes;
const endMinutes2 = 2 + minutes;
examples = `14:01:00-14:${String(endMinutes1).padStart(
2,
'0',
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
}
}
}
return `Monitors data over a fixed time period that moves forward continuously.\n\nExample: A ${timeWindow} rolling window for error rate alerts with 1 minute evaluation cadence. Unlike fixed windows, this checks continuously: ${examples}, etc.`;
};

View File

@@ -209,6 +209,16 @@
.ant-select {
width: 40px;
.ant-select-selector {
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
}
@@ -231,6 +241,14 @@
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);
}
}
&:hover {
@@ -379,6 +397,14 @@
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
}
}
&:hover {

View File

@@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps {
input: JSX.Element;
tooltipText?: string;
onToggle?: () => void;
defaultShowInput: boolean;
}
export enum RollingWindowTimeframes {

View File

@@ -0,0 +1,193 @@
import './styles.scss';
import { toast } from '@signozhq/sonner';
import { Button, Tooltip, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Check, Send, X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useCreateAlertState } from '../context';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
function Footer(): JSX.Element {
const {
alertType,
alertState: basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleDiscard = (): void => {
discardAlertRule();
safeNavigate('/alerts');
};
const alertValidationMessage = useMemo(
() =>
validateCreateAlertState({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
}),
[
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
],
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
});
testAlertRule(payload, {
onSuccess: (response) => {
if (response.payload?.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
},
onError: (error) => {
toast.error(error.message);
},
});
}, [
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
testAlertRule,
]);
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
});
if (isEditMode) {
updateAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
} else {
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
}
}, [
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
isEditMode,
updateAlertRule,
createAlertRule,
safeNavigate,
]);
const disableButtons =
isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule;
const saveAlertButton = useMemo(() => {
let button = (
<Button
type="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Check size={14} />
<Typography.Text>Save Alert Rule</Typography.Text>
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
}
return button;
}, [alertValidationMessage, disableButtons, handleSaveAlert]);
const testAlertButton = useMemo(() => {
let button = (
<Button
type="default"
onClick={handleTestNotification}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Send size={14} />
<Typography.Text>Test Notification</Typography.Text>
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
}
return button;
}, [alertValidationMessage, disableButtons, handleTestNotification]);
return (
<div className="create-alert-v2-footer">
<Button type="default" onClick={handleDiscard} disabled={disableButtons}>
<X size={14} /> Discard
</Button>
<div className="button-group">
{testAlertButton}
{saveAlertButton}
</div>
</div>
);
}
export default Footer;

View File

@@ -0,0 +1,248 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as createAlertState from '../../context';
import Footer from '../Footer';
// Mock the hooks used by Footer component
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
const mockCreateAlertRule = jest.fn();
const mockTestAlertRule = jest.fn();
const mockUpdateAlertRule = jest.fn();
const mockDiscardAlertRule = jest.fn();
// Import the mocked hooks
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSafeNavigate } = jest.requireMock('hooks/useSafeNavigate');
const mockAlertContextState = createMockAlertContextState({
createAlertRule: mockCreateAlertRule,
testAlertRule: mockTestAlertRule,
updateAlertRule: mockUpdateAlertRule,
discardAlertRule: mockDiscardAlertRule,
alertState: {
name: 'Test Alert',
labels: {},
yAxisUnit: undefined,
},
thresholdState: {
selectedQuery: 'A',
operator: AlertThresholdOperator.ABOVE_BELOW,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
evaluationWindow: '5m0s',
algorithm: 'standard',
seasonality: 'hourly',
thresholds: [
{
id: '1',
label: 'CRITICAL',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: ['test-channel'],
color: '#ff0000',
},
],
},
});
jest
.spyOn(createAlertState, 'useCreateAlertState')
.mockReturnValue(mockAlertContextState);
const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
const TEST_NOTIFICATION_TEXT = 'Test Notification';
const DISCARD_TEXT = 'Discard';
describe('Footer', () => {
beforeEach(() => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [],
queryFormulas: [],
},
promql: [],
clickhouse_sql: [],
queryType: 'builder',
},
});
useSafeNavigate.mockReturnValue({
safeNavigate: jest.fn(),
});
});
it('should render the component with 3 buttons', () => {
render(<Footer />);
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('discard action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(DISCARD_TEXT));
expect(mockDiscardAlertRule).toHaveBeenCalled();
});
it('save alert rule action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockCreateAlertRule).toHaveBeenCalled();
});
it('update alert rule action works correctly', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isEditMode: true,
});
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockUpdateAlertRule).toHaveBeenCalled();
});
it('test notification action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
expect(mockTestAlertRule).toHaveBeenCalled();
});
it('all buttons are disabled when creating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when updating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when testing alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('create and test buttons are disabled when alert name is missing', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
alertState: {
...mockAlertContextState.alertState,
name: '',
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('create and test buttons are disabled when notifcation channels are missing and routing policies are disabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: false,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('buttons are enabled even with no notification channels when routing policies are enabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: true,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeEnabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeEnabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
});
});

View File

@@ -0,0 +1,524 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants';
import {
AdvancedOptionsState,
EvaluationWindowState,
NotificationSettingsState,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
getEvaluationProps,
getFormattedTimeValue,
getNotificationSettingsProps,
validateCreateAlertState,
} from '../utils';
describe('Footer utils', () => {
describe('getFormattedTimeValue', () => {
it('for 60 seconds', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.SECONDS)).toBe('60s');
});
it('for 60 minutes', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.MINUTES)).toBe('60m');
});
it('for 60 hours', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.HOURS)).toBe('60h');
});
it('for 60 days', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.DAYS)).toBe('60d');
});
});
describe('validateCreateAlertState', () => {
const args: BuildCreateAlertRulePayloadArgs = {
alertType: AlertTypes.METRICS_BASED_ALERT,
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
query: initialQueriesMap.metrics,
};
it('when alert name is not provided', () => {
expect(validateCreateAlertState(args)).toBeDefined();
expect(validateCreateAlertState(args)).toBe('Please enter an alert name');
});
it('when threshold label is not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
label: '',
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please enter a label for each threshold',
);
});
it('when threshold channels are not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please select at least one channel for each threshold or enable routing policies',
);
});
it('when threshold channels are not provided but routing policies are enabled', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
notificationSettings: {
...args.notificationSettings,
routingPolicies: true,
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
it('when threshold channels are provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
channels: ['test channel'],
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
});
describe('getNotificationSettingsProps', () => {
it('when initial notification settings are provided', () => {
const notificationSettings = INITIAL_NOTIFICATION_SETTINGS_STATE;
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
});
});
});
it('renotification is enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
reNotification: {
enabled: true,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: true,
interval: '30m',
alertStates: ['firing'],
},
usePolicy: false,
});
});
it('routing policies are enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
routingPolicies: true,
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: true,
});
});
it('group by notifications are provided', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: ['test group'],
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: ['test group'],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
});
});
describe('getAlertOnAbsentProps', () => {
it('when alert on absent is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: false,
toleranceLimit: 0,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: false,
});
});
it('when alert on absent is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 13,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: true,
absentFor: 13,
});
});
});
describe('getEnforceMinimumDatapointsProps', () => {
it('when enforce minimum datapoints is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: false,
minimumDatapoints: 0,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: false,
});
});
it('when enforce minimum datapoints is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 12,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: true,
requiredNumPoints: 12,
});
});
});
describe('getEvaluationProps', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
value: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
};
it('for rolling window with non-custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: '5m0s',
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '12m',
},
});
});
it('for rolling window with custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '13m',
frequency: '12m',
},
});
});
it('for cumulative window with current hour', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '14',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'hourly', minute: 14 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current day', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: '15:43:00',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'daily', hour: 15, minute: 43 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current month', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '17',
timezone: 'UTC',
time: '16:34:00',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'monthly', day: 17, hour: 16, minute: 34 },
frequency: '12m',
timezone: 'UTC',
},
});
});
});
describe('buildCreateThresholdAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('verify buildCreateThresholdAlertRulePayload', () => {
const props = buildCreateThresholdAlertRulePayload(
INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alert: '',
alertType: 'METRIC_BASED_ALERT',
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
},
condition: {
alertOnAbsent: false,
compositeQuery: {
builderQueries: undefined,
chQueries: undefined,
panelType: 'graph',
promQueries: undefined,
queries: [
{
spec: {
aggregations: [
{
metricName: '',
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
},
],
disabled: false,
filter: {
expression: '',
},
functions: undefined,
groupBy: undefined,
having: undefined,
legend: undefined,
limit: undefined,
name: 'A',
offset: undefined,
order: undefined,
selectFields: undefined,
signal: 'metrics',
source: '',
stepInterval: null,
},
type: 'builder_query',
},
],
queryType: 'builder',
unit: undefined,
},
requireMinPoints: false,
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
channels: [],
matchType: '1',
name: 'critical',
op: '1',
target: 0,
targetUnit: '',
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
},
labels: {},
notificationSettings: {
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});
it('verify for promql query type', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
query: {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.query,
queryType: EQueryType.PROM,
},
};
const props = buildCreateThresholdAlertRulePayload(currentArgs);
expect(props).toBeDefined();
expect(props.condition.compositeQuery.queryType).toBe('promql');
expect(props.ruleType).toBe('promql_rule');
});
});
});

View File

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

View File

@@ -0,0 +1,51 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
left: 63px;
right: 0;
background-color: var(--bg-ink-500);
height: 70px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px 24px;
z-index: 1000;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
.button-group {
display: flex;
gap: 12px;
}
.ant-btn {
display: flex;
align-items: center;
gap: 8px;
}
.ant-btn-default {
background-color: var(--bg-slate-500);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
}
}
.lightMode {
.create-alert-v2-footer {
background-color: var(--bg-vanilla-100);
border-top: 1px solid var(--bg-vanilla-300);
.ant-btn-default {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-400);
color: var(--bg-ink-400);
}
.ant-btn-primary {
.ant-typography {
color: var(--bg-vanilla-100);
}
}
}
}

View File

@@ -0,0 +1,20 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
export interface BuildCreateAlertRulePayloadArgs {
alertType: AlertTypes;
basicAlertState: AlertState;
thresholdState: AlertThresholdState;
advancedOptions: AdvancedOptionsState;
evaluationWindow: EvaluationWindowState;
notificationSettings: NotificationSettingsState;
query: Query;
}

View File

@@ -0,0 +1,345 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import {
BasicThreshold,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { EQueryType } from 'types/common/dashboard';
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
import {
AdvancedOptionsState,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
export function getFormattedTimeValue(timeValue: number, unit: string): string {
const unitMap: Record<string, string> = {
[UniversalYAxisUnit.SECONDS]: 's',
[UniversalYAxisUnit.MINUTES]: 'm',
[UniversalYAxisUnit.HOURS]: 'h',
[UniversalYAxisUnit.DAYS]: 'd',
};
return `${timeValue}${unitMap[unit]}`;
}
// Validate create alert api payload
export function validateCreateAlertState(
args: BuildCreateAlertRulePayloadArgs,
): string | null {
const { basicAlertState, thresholdState, notificationSettings } = args;
// Validate alert name
if (!basicAlertState.name) {
return 'Please enter an alert name';
}
// Validate threshold state if routing policies is not enabled
for (let i = 0; i < thresholdState.thresholds.length; i++) {
const threshold = thresholdState.thresholds[i];
if (!threshold.label) {
return 'Please enter a label for each threshold';
}
if (!notificationSettings.routingPolicies && !threshold.channels.length) {
return 'Please select at least one channel for each threshold or enable routing policies';
}
}
return null;
}
// Get notification settings props for create alert api payload
export function getNotificationSettingsProps(
notificationSettings: NotificationSettingsState,
): PostableAlertRuleV2['notificationSettings'] {
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
groupBy: notificationSettings.multipleNotifications || [],
usePolicy: notificationSettings.routingPolicies,
renotify: {
enabled: notificationSettings.reNotification.enabled,
interval: getFormattedTimeValue(
notificationSettings.reNotification.value,
notificationSettings.reNotification.unit,
),
alertStates: notificationSettings.reNotification.conditions,
},
};
return notificationSettingsProps;
}
// Get alert on absent props for create alert api payload
export function getAlertOnAbsentProps(
advancedOptions: AdvancedOptionsState,
): Partial<PostableAlertRuleV2['condition']> {
if (advancedOptions.sendNotificationIfDataIsMissing.enabled) {
return {
alertOnAbsent: true,
absentFor: advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
};
}
return {
alertOnAbsent: false,
};
}
// Get enforce minimum datapoints props for create alert api payload
export function getEnforceMinimumDatapointsProps(
advancedOptions: AdvancedOptionsState,
): Partial<PostableAlertRuleV2['condition']> {
if (advancedOptions.enforceMinimumDatapoints.enabled) {
return {
requireMinPoints: true,
requiredNumPoints:
advancedOptions.enforceMinimumDatapoints.minimumDatapoints,
};
}
return {
requireMinPoints: false,
};
}
// Get evaluation props for create alert api payload
export function getEvaluationProps(
evaluationWindow: EvaluationWindowState,
advancedOptions: AdvancedOptionsState,
): PostableAlertRuleV2['evaluation'] {
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: evaluationWindow.timeframe,
frequency,
},
};
}
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: getFormattedTimeValue(
Number(evaluationWindow.startingAt.number),
evaluationWindow.startingAt.unit,
),
frequency,
},
};
}
// Only cumulative window type left now
if (evaluationWindow.timeframe === 'currentHour') {
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'hourly',
minute: Number(evaluationWindow.startingAt.number),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
if (evaluationWindow.timeframe === 'currentDay') {
// time is in the format of "HH:MM:SS"
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'daily',
hour: Number(hour),
minute: Number(minute),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
if (evaluationWindow.timeframe === 'currentMonth') {
// time is in the format of "HH:MM:SS"
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'monthly',
day: Number(evaluationWindow.startingAt.number),
hour: Number(hour),
minute: Number(minute),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: evaluationWindow.timeframe,
frequency,
},
};
}
// Build Create Threshold Alert Rule Payload
export function buildCreateThresholdAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
evaluationWindow,
advancedOptions,
notificationSettings,
query,
} = args;
const compositeQuery = compositeQueryToQueryEnvelope({
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
queryType: query.queryType,
panelType: PANEL_TYPES.TIME_SERIES,
unit: basicAlertState.yAxisUnit,
});
// Thresholds
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => ({
name: threshold.label,
target: parseFloat(threshold.thresholdValue.toString()),
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
}),
);
// Alert on absent data
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
// Enforce minimum datapoints
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
advancedOptions,
);
// Notification settings
const notificationSettingsProps = getNotificationSettingsProps(
notificationSettings,
);
// Evaluation
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
if (query.queryType === EQueryType.PROM) {
ruleType = 'promql_rule';
}
return {
alert: basicAlertState.name,
ruleType,
alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
evaluation: evaluationProps,
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
const compositeQuery = compositeQueryToQueryEnvelope({
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
queryType: query.queryType,
panelType: PANEL_TYPES.TIME_SERIES,
unit: basicAlertState.yAxisUnit,
});
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
advancedOptions,
);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps = getNotificationSettingsProps(
notificationSettings,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType,
condition: {
compositeQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
};
}

View File

@@ -1,4 +1,4 @@
import { Button, Popover, Tooltip, Typography } from 'antd';
import { Tooltip, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { Info } from 'lucide-react';
@@ -10,46 +10,46 @@ function NotificationMessage(): JSX.Element {
setNotificationSettings,
} = useCreateAlertState();
const templateVariables = [
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
{
variable: '{{value}}',
description: 'Current value that triggered the alert',
},
{
variable: '{{threshold}}',
description: 'Threshold value from alert condition',
},
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
{
variable: '{{severity}}',
description: 'Alert severity level (Critical, Warning, Info)',
},
{
variable: '{{queryname}}',
description: 'Name of the query that triggered the alert',
},
{
variable: '{{labels}}',
description: 'All labels associated with the alert',
},
{
variable: '{{timestamp}}',
description: 'Timestamp when alert was triggered',
},
];
// const templateVariables = [
// { variable: '{{alertname}}', description: 'Name of the alert rule' },
// {
// variable: '{{value}}',
// description: 'Current value that triggered the alert',
// },
// {
// variable: '{{threshold}}',
// description: 'Threshold value from alert condition',
// },
// { variable: '{{unit}}', description: 'Unit of measurement for the metric' },
// {
// variable: '{{severity}}',
// description: 'Alert severity level (Critical, Warning, Info)',
// },
// {
// variable: '{{queryname}}',
// description: 'Name of the query that triggered the alert',
// },
// {
// variable: '{{labels}}',
// description: 'All labels associated with the alert',
// },
// {
// variable: '{{timestamp}}',
// description: 'Timestamp when alert was triggered',
// },
// ];
const templateVariableContent = (
<div className="template-variable-content">
<Typography.Text strong>Available Template Variables:</Typography.Text>
{templateVariables.map((item) => (
<div className="template-variable-content-item" key={item.variable}>
<code>{item.variable}</code>
<Typography.Text>{item.description}</Typography.Text>
</div>
))}
</div>
);
// const templateVariableContent = (
// <div className="template-variable-content">
// <Typography.Text strong>Available Template Variables:</Typography.Text>
// {templateVariables.map((item) => (
// <div className="template-variable-content-item" key={item.variable}>
// <code>{item.variable}</code>
// <Typography.Text>{item.description}</Typography.Text>
// </div>
// ))}
// </div>
// );
return (
<div className="notification-message-container">
@@ -67,12 +67,13 @@ function NotificationMessage(): JSX.Element {
</Typography.Text>
</div>
<div className="notification-message-header-actions">
<Popover content={templateVariableContent}>
{/* TODO: Add back when the functionality is implemented */}
{/* <Popover content={templateVariableContent}>
<Button type="text">
<Info size={12} />
Variables
</Button>
</Popover>
</Popover> */}
</div>
</div>
<TextArea

View File

@@ -4,18 +4,15 @@ import { Input, Select, Typography } from 'antd';
import { useCreateAlertState } from '../context';
import {
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
RE_NOTIFICATION_CONDITION_OPTIONS,
RE_NOTIFICATION_TIME_UNIT_OPTIONS,
} from '../context/constants';
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import MultipleNotifications from './MultipleNotifications';
import NotificationMessage from './NotificationMessage';
function NotificationSettings(): JSX.Element {
const showCondensedLayoutFlag = showCondensedLayout();
const {
notificationSettings,
setNotificationSettings,
@@ -45,7 +42,7 @@ function NotificationSettings(): JSX.Element {
value={notificationSettings.reNotification.unit || null}
placeholder="Select unit"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_UNIT_OPTIONS}
options={RE_NOTIFICATION_TIME_UNIT_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
@@ -82,10 +79,7 @@ function NotificationSettings(): JSX.Element {
return (
<div className="notification-settings-container">
<Stepper
stepNumber={showCondensedLayoutFlag ? 3 : 4}
label="Notification settings"
/>
<Stepper stepNumber={3} label="Notification settings" />
<NotificationMessage />
<div className="notification-settings-content">
<MultipleNotifications />
@@ -103,6 +97,7 @@ function NotificationSettings(): JSX.Element {
},
});
}}
defaultShowInput={notificationSettings.reNotification.enabled}
/>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as createAlertContext from 'container/CreateAlertV2/context';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as utils from 'container/CreateAlertV2/utils';
import NotificationSettings from '../NotificationSettings';
@@ -24,6 +23,10 @@ jest.mock(
}),
);
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const initialNotificationSettings = createMockAlertContextState()
.notificationSettings;
const mockSetNotificationSettings = jest.fn();
@@ -37,10 +40,10 @@ const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
describe('NotificationSettings', () => {
it('renders the notification settings tab with step number 4 and default values', () => {
it('renders the notification settings tab with step number 3 and default values', () => {
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
@@ -51,15 +54,6 @@ describe('NotificationSettings', () => {
).toBeInTheDocument();
});
it('renders the notification settings tab with step number 3 in condensed layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
});
describe('Repeat notifications', () => {
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
render(<NotificationSettings />);

View File

@@ -84,12 +84,28 @@
.ant-select {
.ant-select-selector {
width: 120px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
.ant-select-multiple {
.ant-select-selector {
width: 200px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
@@ -202,6 +218,15 @@
flex-shrink: 0;
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
&: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);
}
}
}
@@ -327,6 +352,15 @@
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}

View File

@@ -1,3 +1,4 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
@@ -16,7 +17,7 @@ export interface ChartPreviewProps {
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
const { thresholdState, alertState } = useCreateAlertState();
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -25,14 +26,24 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const yAxisUnit = alertState.yAxisUnit || '';
const headline = (
<div className="chart-preview-headline">
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
<YAxisUnitSelector
value={alertState.yAxisUnit}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
/>
</div>
);
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
headline={headline}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
@@ -40,19 +51,13 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
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}
/>
}
headline={headline}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
@@ -60,7 +65,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
yAxisUnit={yAxisUnit || ''}
graphType={panelType || PANEL_TYPES.TIME_SERIES}
setQueryStatus={setQueryStatus}
showSideLegend
additionalThresholds={thresholdState.thresholds}
/>
);

View File

@@ -2,12 +2,13 @@ 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 { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
@@ -15,17 +16,20 @@ import ChartPreview from './ChartPreview';
import { buildAlertDefForChartPreview } from './utils';
function QuerySection(): JSX.Element {
const { currentQuery, handleRunQuery } = useQueryBuilder();
const {
alertState,
setAlertState,
alertType,
setAlertType,
thresholdState,
} = useCreateAlertState();
currentQuery,
handleRunQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
const onQueryCategoryChange = (val: EQueryType): void => {
const query: Query = { ...currentQuery, queryType: val };
redirectWithQueryBuilderData(query);
};
const tabs = [
{
label: 'Metrics',
@@ -51,17 +55,8 @@ function QuerySection(): JSX.Element {
return (
<div className="query-section">
<Stepper
stepNumber={1}
label="Define the query you want to set an alert on"
/>
<Stepper stepNumber={1} label="Define the query" />
<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) => (
@@ -82,7 +77,7 @@ function QuerySection(): JSX.Element {
</div>
<QuerySectionComponent
queryCategory={currentQuery.queryType}
setQueryCategory={(): void => {}}
setQueryCategory={onQueryCategoryChange}
alertType={alertType}
runQuery={handleRunQuery}
alertDef={alertDef}

View File

@@ -134,7 +134,7 @@ const renderChartPreview = (): ReturnType<typeof render> =>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<ChartPreview alertDef={mockAlertDef} />
</CreateAlertProvider>
</MemoryRouter>

View File

@@ -2,6 +2,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
@@ -104,7 +105,7 @@ const renderQuerySection = (): ReturnType<typeof render> =>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
@@ -135,7 +136,7 @@ describe('QuerySection', () => {
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',
'Define the query',
);
// Check if ChartPreview is rendered
@@ -186,6 +187,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -200,6 +202,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,

View File

@@ -77,6 +77,14 @@
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
@@ -88,6 +96,18 @@
border: 1px solid var(--bg-slate-500);
.ant-card-body {
background-color: var(--bg-ink-500);
.chart-preview-headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
.y-axis-unit-selector-component {
margin-top: 0;
}
}
}
}
}
@@ -99,3 +119,78 @@
border: 1px solid var(--bg-slate-400);
}
}
.lightMode {
.query-section {
.query-section-tabs {
.query-section-query-actions {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-300);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
.y-axis-unit-selector-component {
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}
}
.chart-preview-container {
.alert-chart-container {
.ant-card {
border: 1px solid var(--bg-vanilla-300);
.ant-card-body {
background-color: var(--bg-vanilla-100);
}
.ant-card-body {
.chart-preview-header {
.plot-tag {
background-color: var(--bg-vanilla-300);
color: var(--bg-slate-100);
}
}
}
}
}
}
.alert-query-section-container {
background-color: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -42,3 +42,22 @@
background-position: center;
margin-left: 8px;
}
.lightMode {
.step-number {
background-color: var(--bg-robin-400);
color: var(--text-slate-400);
}
.step-label {
color: var(--bg-ink-300);
}
.dotted-line {
background-image: radial-gradient(
circle,
var(--bg-ink-200) 1px,
transparent 1px
);
}
}

View File

@@ -0,0 +1,358 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { defaultPostableAlertRuleV2 } from '../constants';
import { INITIAL_ALERT_STATE } from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import {
getAdvancedOptionsStateFromAlertDef,
getColorForThreshold,
getCreateAlertLocalStateFromAlertDef,
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
getThresholdStateFromAlertDef,
parseGoTime,
} from '../utils';
describe('CreateAlertV2 utils', () => {
describe('getColorForThreshold', () => {
it('should return the correct color for the pre-defined threshold', () => {
expect(getColorForThreshold('critical')).toBe(Color.BG_SAKURA_500);
expect(getColorForThreshold('warning')).toBe(Color.BG_AMBER_500);
expect(getColorForThreshold('info')).toBe(Color.BG_ROBIN_500);
});
});
describe('parseGoTime', () => {
it('should return the correct time and unit for the given input', () => {
expect(parseGoTime('1h')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
expect(parseGoTime('1m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.MINUTES,
});
expect(parseGoTime('1s')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.SECONDS,
});
expect(parseGoTime('1h0m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
});
});
describe('getEvaluationWindowStateFromAlertDef', () => {
it('for rolling window with non-custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '5m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: '5m0s',
});
});
it('for rolling window with custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '13m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
});
});
it('for cumulative window with current hour', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
kind: 'cumulative',
spec: {
schedule: {
type: 'hourly',
minute: 14,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
number: '14',
},
});
});
it('for cumulative window with current day', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'daily',
hour: 14,
minute: 15,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
time: '14:15:00',
},
});
});
it('for cumulative window with current month', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'monthly',
day: 12,
hour: 16,
minute: 34,
},
timezone: 'UTC',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
number: '12',
timezone: 'UTC',
time: '16:34:00',
},
});
});
});
describe('getNotificationSettingsStateFromAlertDef', () => {
it('should return the correct notification settings state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
renotify: {
enabled: true,
interval: '1m0s',
alertStates: ['firing'],
},
usePolicy: true,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: true,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
routingPolicies: true,
});
});
it('when renotification is not provided', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
usePolicy: false,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: false,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},
});
});
});
describe('getAdvancedOptionsStateFromAlertDef', () => {
it('should return the correct advanced options state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
requiredNumPoints: 13,
requireMinPoints: true,
alertOnAbsent: true,
absentFor: 12,
},
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
spec: {
frequency: '1m0s',
},
},
};
const props = getAdvancedOptionsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 13,
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
});
});
});
describe('getThresholdStateFromAlertDef', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_ABOVE,
},
],
},
selectedQueryName: 'test',
},
};
const props = getThresholdStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
selectedQuery: 'test',
operator: AlertThresholdOperator.IS_ABOVE,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
thresholds: [
{
id: expect.any(String),
label: 'critical',
thresholdValue: 1,
recoveryThresholdValue: null,
unit: UniversalYAxisUnit.MINUTES,
color: Color.BG_SAKURA_500,
channels: ['email'],
},
],
});
});
describe('getCreateAlertLocalStateFromAlertDef', () => {
it('should return the correct create alert local state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
alert: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
},
};
const props = getCreateAlertLocalStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
basicAlertState: {
...INITIAL_ALERT_STATE,
name: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
yAxisUnit: UniversalYAxisUnit.MINUTES,
},
// as we have already verified these utils in their respective tests
thresholdState: expect.any(Object),
advancedOptionsState: expect.any(Object),
evaluationWindowState: expect.any(Object),
notificationSettingsState: expect.any(Object),
});
});
});
});

View File

@@ -0,0 +1,74 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { EQueryType } from 'types/common/dashboard';
const defaultAnnotations = {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
};
const defaultNotificationSettings: PostableAlertRuleV2['notificationSettings'] = {
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
};
const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
};
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {
builderQueries: {
A: initialQueryBuilderFormValuesMap.metrics,
},
promQueries: { A: initialQueryPromQLData },
chQueries: {
A: {
name: 'A',
query: ``,
legend: '',
disabled: false,
},
},
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
},
selectedQueryName: 'A',
alertOnAbsent: true,
absentFor: 10,
requireMinPoints: false,
requiredNumPoints: 0,
},
labels: {
severity: 'warning',
},
annotations: defaultAnnotations,
notificationSettings: defaultNotificationSettings,
alert: 'TEST_ALERT',
evaluation: defaultEvaluation,
};

View File

@@ -21,16 +21,15 @@ import {
export const INITIAL_ALERT_STATE: AlertState = {
name: '',
description: '',
labels: {},
yAxisUnit: undefined,
};
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
id: v4(),
label: 'CRITICAL',
label: 'critical',
thresholdValue: 0,
recoveryThresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: Color.BG_SAKURA_500,
@@ -38,9 +37,9 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
export const INITIAL_WARNING_THRESHOLD: Threshold = {
id: v4(),
label: 'WARNING',
label: 'warning',
thresholdValue: 0,
recoveryThresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: Color.BG_AMBER_500,
@@ -48,9 +47,9 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
export const INITIAL_INFO_THRESHOLD: Threshold = {
id: v4(),
label: 'INFO',
label: 'info',
thresholdValue: 0,
recoveryThresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: Color.BG_ROBIN_500,
@@ -60,7 +59,7 @@ export const INITIAL_RANDOM_THRESHOLD: Threshold = {
id: v4(),
label: '',
thresholdValue: 0,
recoveryThresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: getRandomColor(),
@@ -80,9 +79,11 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 15,
timeUnit: UniversalYAxisUnit.MINUTES,
enabled: false,
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
enabled: false,
},
delayEvaluation: {
delay: 5,
@@ -120,10 +121,10 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
};
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' },
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
@@ -169,7 +170,11 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
];
export const RE_NOTIFICATION_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
];
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
@@ -177,16 +182,17 @@ export const NOTIFICATION_MESSAGE_PLACEHOLDER =
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
{ value: 'no-data', label: 'No Data' },
{ value: 'nodata', label: 'No Data' },
];
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
multipleNotifications: [],
reNotification: {
enabled: false,
value: 1,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
routingPolicies: false,
};

View File

@@ -1,4 +1,8 @@
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import {
@@ -47,7 +51,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
export function CreateAlertProvider(
props: ICreateAlertProviderProps,
): JSX.Element {
const { children } = props;
const { children, initialAlertState, isEditMode, ruleId } = props;
const [alertState, setAlertState] = useReducer(
alertCreationReducer,
@@ -72,6 +76,10 @@ export function CreateAlertProvider(
currentQueryToRedirect,
{
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
value === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -107,6 +115,65 @@ export function CreateAlertProvider(
});
}, [alertType]);
useEffect(() => {
if (isEditMode && initialAlertState) {
setAlertState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.basicAlertState,
});
setThresholdState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.thresholdState,
});
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.evaluationWindowState,
});
setAdvancedOptions({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.advancedOptionsState,
});
setNotificationSettings({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.notificationSettingsState,
});
}
}, [initialAlertState, isEditMode]);
const discardAlertRule = useCallback(() => {
setAlertState({
type: 'RESET',
});
setThresholdState({
type: 'RESET',
});
setEvaluationWindow({
type: 'RESET',
});
setAdvancedOptions({
type: 'RESET',
});
setNotificationSettings({
type: 'RESET',
});
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
}, [handleAlertTypeChange]);
const {
mutate: createAlertRule,
isLoading: isCreatingAlertRule,
} = useCreateAlertRule();
const {
mutate: testAlertRule,
isLoading: isTestingAlertRule,
} = useTestAlertRule();
const {
mutate: updateAlertRule,
isLoading: isUpdatingAlertRule,
} = useUpdateAlertRule(ruleId || '');
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
alertState,
@@ -121,6 +188,14 @@ export function CreateAlertProvider(
setAdvancedOptions,
notificationSettings,
setNotificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode: isEditMode || false,
}),
[
alertState,
@@ -130,6 +205,14 @@ export function CreateAlertProvider(
evaluationWindow,
advancedOptions,
notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
],
);

View File

@@ -1,8 +1,16 @@
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
export interface ICreateAlertContextProps {
alertState: AlertState;
setAlertState: Dispatch<CreateAlertAction>;
@@ -16,10 +24,37 @@ export interface ICreateAlertContextProps {
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
notificationSettings: NotificationSettingsState;
setNotificationSettings: Dispatch<NotificationSettingsAction>;
isCreatingAlertRule: boolean;
createAlertRule: UseMutateFunction<
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isTestingAlertRule: boolean;
testAlertRule: UseMutateFunction<
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
discardAlertRule: () => void;
isUpdatingAlertRule: boolean;
updateAlertRule: UseMutateFunction<
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isEditMode: boolean;
}
export interface ICreateAlertProviderProps {
children: React.ReactNode;
initialAlertType: AlertTypes;
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
isEditMode?: boolean;
ruleId?: string;
}
export enum AlertCreationStep {
@@ -31,23 +66,22 @@ export enum AlertCreationStep {
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 }
| { type: 'SET_INITIAL_STATE'; payload: AlertState }
| { type: 'RESET' };
export interface Threshold {
id: string;
label: string;
thresholdValue: number;
recoveryThresholdValue: number;
recoveryThresholdValue: number | null;
unit: string;
channels: string[];
color: string;
@@ -108,15 +142,18 @@ export type AlertThresholdAction =
| { type: 'SET_ALGORITHM'; payload: string }
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState }
| { type: 'RESET' };
export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: {
toleranceLimit: number;
timeUnit: string;
enabled: boolean;
};
enforceMinimumDatapoints: {
minimumDatapoints: number;
enabled: boolean;
};
delayEvaluation: {
delay: number;
@@ -147,10 +184,18 @@ export type AdvancedOptionsAction =
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string };
}
| {
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: boolean;
}
| {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number };
}
| {
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS';
payload: boolean;
}
| {
type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string };
@@ -169,6 +214,7 @@ export type AdvancedOptionsAction =
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState }
| { type: 'RESET' };
export interface EvaluationWindowState {
@@ -190,6 +236,7 @@ export type EvaluationWindowAction =
payload: { time: string; number: string; timezone: string; unit: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
@@ -200,9 +247,10 @@ export interface NotificationSettingsState {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
conditions: ('firing' | 'nodata')[];
};
description: string;
routingPolicies: boolean;
}
export type NotificationSettingsAction =
@@ -216,8 +264,10 @@ export type NotificationSettingsAction =
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
conditions: ('firing' | 'nodata')[];
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
| { type: 'RESET' };

View File

@@ -41,11 +41,6 @@ export const alertCreationReducer = (
...state,
name: action.payload,
};
case 'SET_ALERT_DESCRIPTION':
return {
...state,
description: action.payload,
};
case 'SET_ALERT_LABELS':
return {
...state,
@@ -58,6 +53,8 @@ export const alertCreationReducer = (
};
case 'RESET':
return INITIAL_ALERT_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -99,6 +96,10 @@ export function getInitialAlertTypeFromURL(
urlSearchParams: URLSearchParams,
currentQuery: Query,
): AlertTypes {
const ruleType = urlSearchParams.get(QueryParams.ruleType);
if (ruleType === 'anomaly_rule') {
return AlertTypes.ANOMALY_BASED_ALERT;
}
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
return alertTypeFromURL
? (alertTypeFromURL as AlertTypes)
@@ -120,6 +121,8 @@ export const alertThresholdReducer = (
return { ...state, thresholds: action.payload };
case 'RESET':
return INITIAL_ALERT_THRESHOLD_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -131,9 +134,38 @@ export const advancedOptionsReducer = (
): AdvancedOptionsState => {
switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return { ...state, sendNotificationIfDataIsMissing: action.payload };
return {
...state,
sendNotificationIfDataIsMissing: {
...state.sendNotificationIfDataIsMissing,
toleranceLimit: action.payload.toleranceLimit,
timeUnit: action.payload.timeUnit,
},
};
case 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return {
...state,
sendNotificationIfDataIsMissing: {
...state.sendNotificationIfDataIsMissing,
enabled: action.payload,
},
};
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return { ...state, enforceMinimumDatapoints: action.payload };
return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
minimumDatapoints: action.payload.minimumDatapoints,
},
};
case 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS':
return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
enabled: action.payload,
},
};
case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE':
@@ -146,6 +178,8 @@ export const advancedOptionsReducer = (
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'SET_INITIAL_STATE':
return action.payload;
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
@@ -174,6 +208,8 @@ export const evaluationWindowReducer = (
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -190,8 +226,12 @@ export const notificationSettingsReducer = (
return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION':
return { ...state, description: action.payload };
case 'SET_ROUTING_POLICIES':
return { ...state, routingPolicies: action.payload };
case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}

View File

@@ -0,0 +1,21 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
export interface CreateAlertV2Props {
alertType: AlertTypes;
}
export interface GetCreateAlertLocalStateFromAlertDefReturn {
basicAlertState: AlertState;
thresholdState: AlertThresholdState;
advancedOptionsState: AdvancedOptionsState;
evaluationWindowState: EvaluationWindowState;
notificationSettingsState: NotificationSettingsState;
}

View File

@@ -1,9 +1,301 @@
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';
import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { createPortal } from 'react-dom';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
// UI side FF to switch between the 2 layouts of the create alert page
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true';
import { useCreateAlertState } from './context';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './context/constants';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
export function Spinner(): JSX.Element | null {
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
if (!isCreatingAlertRule && !isUpdatingAlertRule) return null;
return createPortal(
<div className="sticky-page-spinner">
<Spin size="large" spinning />
</div>,
document.body,
);
}
export function getColorForThreshold(thresholdLabel: string): string {
if (thresholdLabel === 'critical') {
return Color.BG_SAKURA_500;
}
if (thresholdLabel === 'warning') {
return Color.BG_AMBER_500;
}
if (thresholdLabel === 'info') {
return Color.BG_ROBIN_500;
}
return getRandomColor();
}
export function parseGoTime(
input: string,
): { time: number; unit: UniversalYAxisUnit } {
const regex = /(\d+)([hms])/g;
const matches = [...input.matchAll(regex)];
const nonZero = matches.find(([, value]) => parseInt(value, 10) > 0);
if (!nonZero) {
return { time: 1, unit: UniversalYAxisUnit.MINUTES };
}
const time = parseInt(nonZero[1], 10);
const unitMap: Record<string, UniversalYAxisUnit> = {
h: UniversalYAxisUnit.HOURS,
m: UniversalYAxisUnit.MINUTES,
s: UniversalYAxisUnit.SECONDS,
};
return { time, unit: unitMap[nonZero[2]] };
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function getEvaluationWindowStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): EvaluationWindowState {
const windowType = alertDef.evaluation?.kind as 'rolling' | 'cumulative';
function getRollingWindowTimeframe(): string {
if (
// Default values for rolling window
EVALUATION_WINDOW_TIMEFRAME.rolling
.map((option) => option.value)
.includes(alertDef.evaluation?.spec?.evalWindow || '')
) {
return alertDef.evaluation?.spec?.evalWindow || '';
}
return 'custom';
}
function getCumulativeWindowTimeframe(): string {
switch (alertDef.evaluation?.spec?.schedule?.type) {
case 'hourly':
return 'currentHour';
case 'daily':
return 'currentDay';
case 'monthly':
return 'currentMonth';
default:
return 'currentHour';
}
}
function convertApiFieldToTime(hour: number, minute: number): string {
return `${hour.toString().padStart(2, '0')}:${minute
.toString()
.padStart(2, '0')}:00`;
}
function getCumulativeWindowStartingAt(): EvaluationWindowState['startingAt'] {
const timeframe = getCumulativeWindowTimeframe();
if (timeframe === 'currentHour') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.minute?.toString() || '0',
};
}
if (timeframe === 'currentDay') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
};
}
if (timeframe === 'currentMonth') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.day?.toString() || '0',
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
};
}
return INITIAL_EVALUATION_WINDOW_STATE.startingAt;
}
if (windowType === 'rolling') {
const timeframe = getRollingWindowTimeframe();
if (timeframe === 'custom') {
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: parseGoTime(
alertDef.evaluation?.spec?.evalWindow || '1m',
).time.toString(),
unit: parseGoTime(alertDef.evaluation?.spec?.evalWindow || '1m').unit,
},
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe: getCumulativeWindowTimeframe(),
startingAt: getCumulativeWindowStartingAt(),
};
}
export function getNotificationSettingsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): NotificationSettingsState {
const description = alertDef.annotations?.description || '';
const multipleNotifications = alertDef.notificationSettings?.groupBy || [];
const routingPolicies = alertDef.notificationSettings?.usePolicy || false;
const reNotificationEnabled =
alertDef.notificationSettings?.renotify?.enabled || false;
const reNotificationConditions =
alertDef.notificationSettings?.renotify?.alertStates?.map(
(state) => state as 'firing' | 'nodata',
) || [];
const reNotificationValue = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '30m').time
: 30;
const reNotificationUnit = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '30m').unit
: UniversalYAxisUnit.MINUTES;
return {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
description,
multipleNotifications,
routingPolicies,
reNotification: {
enabled: reNotificationEnabled,
conditions: reNotificationConditions,
value: reNotificationValue,
unit: reNotificationUnit,
},
};
}
export function getAdvancedOptionsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AdvancedOptionsState {
return {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
toleranceLimit: alertDef.condition.absentFor || 0,
enabled: alertDef.condition.alertOnAbsent || false,
},
enforceMinimumDatapoints: {
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
minimumDatapoints: alertDef.condition.requiredNumPoints || 0,
enabled: alertDef.condition.requireMinPoints || false,
},
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence.default,
value: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').time,
timeUnit: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').unit,
},
},
};
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
channels: threshold.channels,
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
AlertThresholdOperator.IS_ABOVE,
matchType:
(alertDef.condition.thresholds?.spec[0]
.matchType as AlertThresholdMatchType) ||
AlertThresholdMatchType.AT_LEAST_ONCE,
};
}
export function getCreateAlertLocalStateFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): GetCreateAlertLocalStateFromAlertDefReturn {
if (!alertDef) {
return {
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
};
}
// Basic alert state
const basicAlertState: AlertState = {
...INITIAL_ALERT_STATE,
name: alertDef.alert,
labels: alertDef.labels || {},
yAxisUnit: alertDef.condition.compositeQuery.unit,
};
const thresholdState = getThresholdStateFromAlertDef(alertDef);
const advancedOptionsState = getAdvancedOptionsStateFromAlertDef(alertDef);
const evaluationWindowState = getEvaluationWindowStateFromAlertDef(alertDef);
const notificationSettingsState = getNotificationSettingsStateFromAlertDef(
alertDef,
);
return {
basicAlertState,
thresholdState,
advancedOptionsState,
evaluationWindowState,
notificationSettingsState,
};
}

View File

@@ -0,0 +1,52 @@
import '../CreateAlertV2/CreateAlertV2.styles.scss';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useMemo } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertCondition from '../CreateAlertV2/AlertCondition';
import { buildInitialAlertDef } from '../CreateAlertV2/context/utils';
import Footer from '../CreateAlertV2/Footer';
import NotificationSettings from '../CreateAlertV2/NotificationSettings';
import QuerySection from '../CreateAlertV2/QuerySection';
import { Spinner } from '../CreateAlertV2/utils';
interface EditAlertV2Props {
alertType?: AlertTypes;
initialAlert: PostableAlertRuleV2;
}
function EditAlertV2({
alertType = AlertTypes.METRICS_BASED_ALERT,
initialAlert,
}: EditAlertV2Props): JSX.Element {
const currentQueryToRedirect = useMemo(() => {
const basicAlertDef = buildInitialAlertDef(alertType);
return mapQueryDataFromApi(
initialAlert?.condition.compositeQuery ||
basicAlertDef.condition.compositeQuery,
);
}, [initialAlert, alertType]);
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
return (
<>
<Spinner />
<div className="create-alert-v2-container">
<QuerySection />
<AlertCondition />
<NotificationSettings />
</div>
<Footer />
</>
);
}
EditAlertV2.defaultProps = {
alertType: AlertTypes.METRICS_BASED_ALERT,
};
export default EditAlertV2;

View File

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

View File

@@ -1,11 +1,32 @@
import { Form } from 'antd';
import EditAlertV2 from 'container/EditAlertV2';
import FormAlertRules from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
function EditRules({
initialValue,
ruleId,
initialV2AlertValue,
}: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm();
if (
initialV2AlertValue !== null &&
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
) {
return (
<EditAlertV2
initialAlert={initialV2AlertValue}
alertType={initialValue.alertType as AlertTypes}
/>
);
}
return (
<FormAlertRules
alertType={
@@ -23,6 +44,7 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
interface EditRulesProps {
initialValue: AlertDef;
ruleId: string;
initialV2AlertValue: PostableAlertRuleV2 | null;
}
export default EditRules;

View File

@@ -10,6 +10,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.prom-ql-icon {
height: 14px;

View File

@@ -1,7 +1,7 @@
import './QuerySection.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Tooltip } from 'antd';
import { Button, Tabs, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
@@ -71,6 +71,7 @@ function QuerySection({
<Tooltip title="Query Builder">
<Button className="nav-btns">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
</Tooltip>
),
@@ -81,6 +82,7 @@ function QuerySection({
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
@@ -95,6 +97,7 @@ function QuerySection({
<Tooltip title="Query Builder">
<Button className="nav-btns" data-testid="query-builder-tab">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
</Tooltip>
),
@@ -105,6 +108,7 @@ function QuerySection({
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
@@ -117,6 +121,7 @@ function QuerySection({
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
<Typography.Text>PromQL</Typography.Text>
</Button>
</Tooltip>
),

View File

@@ -1,6 +1,14 @@
/* eslint-disable react/display-name */
import { PlusOutlined } from '@ant-design/icons';
import { Flex, Input, Typography } from 'antd';
import {
Button,
Dropdown,
Flex,
Input,
MenuProps,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import saveAlertApi from 'api/alerts/save';
import logEvent from 'api/common/logEvent';
@@ -31,7 +39,7 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
import { GettableAlert } from 'types/api/alerts/get';
import DeleteAlert from './DeleteAlert';
import { Button, ColumnButton, SearchContainer } from './styles';
import { ColumnButton, SearchContainer } from './styles';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
@@ -97,14 +105,41 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
});
}, [notificationsApi, t]);
const onClickNewAlertHandler = useCallback(() => {
const onClickNewAlertV2Handler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'new',
});
history.push(`${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onClickNewClassicAlertHandler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'classic',
});
history.push(ROUTES.ALERTS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const newAlertMenuItems: MenuProps['items'] = [
{
key: 'new',
label: (
<span>
Try the new experience <Tag color="blue">Beta</Tag>
</span>
),
onClick: onClickNewAlertV2Handler,
},
{
key: 'classic',
label: 'Continue with the classic experience',
onClick: onClickNewClassicAlertHandler,
},
];
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
params.set(
@@ -368,13 +403,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
/>
<Flex gap={12}>
{addNewAlert && (
<Button
type="primary"
onClick={onClickNewAlertHandler}
icon={<PlusOutlined />}
>
New Alert
</Button>
<Dropdown menu={{ items: newAlertMenuItems }} trigger={['click']}>
<Button type="primary" icon={<PlusOutlined />}>
New Alert
</Button>
</Dropdown>
)}
<TextToolTip
{...{

View File

@@ -87,6 +87,21 @@
"imgUrl": "/Logos/signoz-brand-logo.svg",
"link": "https://signoz.io/docs/migration/migrate-from-signoz-self-host-to-signoz-cloud/"
},
{
"dataSource": "migrate-from-existing-opentelemetry",
"label": "From Existing OpenTelemetry",
"tags": ["migrate to SigNoz"],
"module": "home",
"relatedSearchKeywords": [
"apm migration",
"opentelemetry",
"migration guide",
"migrate",
"migration"
],
"imgUrl": "/Logos/opentelemetry.svg",
"link": "https://signoz.io/docs/migration/migrate-from-opentelemetry-to-signoz/"
},
{
"dataSource": "java",
"entityID": "dataSource",
@@ -2656,6 +2671,156 @@
],
"link": "https://signoz.io/docs/community/llm-monitoring/"
},
{
"dataSource": "anthropic-api",
"label": "Anthropic API",
"imgUrl": "/Logos/anthropic-api-monitoring.svg",
"tags": ["LLM Monitoring"],
"module": "metrics",
"relatedSearchKeywords": [
"llm monitoring",
"large language model observability",
"monitor anthropic",
"llm response time tracing",
"llm metrics",
"otel llm integration",
"llm performance tracking",
"metrics",
"traces",
"logs"
],
"link": "https://signoz.io/docs/anthropic-monitoring/"
},
{
"dataSource": "claude-code",
"label": "Claude Code",
"imgUrl": "/Logos/claude-code.svg",
"tags": ["LLM Monitoring"],
"module": "metrics",
"relatedSearchKeywords": [
"claude code monitoring",
"claude code observability",
"claude code performance tracking",
"claude code latency tracing",
"claude code metrics",
"otel claude integration",
"claude code response time",
"claude code logs",
"claude code error tracking",
"claude code debugging",
"metrics",
"logs"
],
"link": "https://signoz.io/docs/claude-code-monitoring/"
},
{
"dataSource": "deepseek-api",
"label": "DeepSeek API",
"imgUrl": "/Logos/deepseek.svg",
"tags": ["LLM Monitoring"],
"module": "metrics",
"relatedSearchKeywords": [
"deepseek api monitoring",
"deepseek api observability",
"deepseek api performance tracking",
"deepseek api latency tracing",
"deepseek api metrics",
"otel deepseek integration",
"deepseek api response time",
"deepseek api logs",
"deepseek api error tracking",
"deepseek api debugging",
"metrics",
"logs"
],
"link": "https://signoz.io/docs/deepseek-monitoring/"
},
{
"dataSource": "google-gemini-api",
"label": "Google Gemini",
"imgUrl": "/Logos/google-gemini.svg",
"tags": ["LLM Monitoring"],
"module": "metrics",
"relatedSearchKeywords": [
"google gemini api monitoring",
"google gemini api observability",
"google gemini api performance tracking",
"google gemini api latency tracing",
"google gemini api metrics",
"otel google gemini integration",
"google gemini api response time",
"google gemini api logs",
"google gemini api error tracking",
"google gemini api debugging",
"gemini",
"metrics",
"logs"
],
"link": "https://signoz.io/docs/google-gemini-monitoring/"
},
{
"dataSource": "langchain",
"label": "LangChain",
"imgUrl": "/Logos/langchain.svg",
"tags": ["LLM Monitoring"],
"module": "apm",
"relatedSearchKeywords": [
"langchain monitoring",
"langchain observability",
"langchain performance tracking",
"langchain latency tracing",
"langchain metrics",
"otel langchain integration",
"langchain response time",
"langchain logs",
"langchain error tracking",
"langchain debugging",
"traces"
],
"link": "https://signoz.io/docs/langchain-monitoring/"
},
{
"dataSource": "llamaindex",
"label": "LlamaIndex",
"imgUrl": "/Logos/llamaindex.svg",
"tags": ["LLM Monitoring"],
"module": "apm",
"relatedSearchKeywords": [
"llamaindex monitoring",
"llamaindex observability",
"llamaindex performance tracking",
"llamaindex latency tracing",
"llamaindex metrics",
"otel llamaindex integration",
"llamaindex response time",
"llamaindex logs",
"llamaindex error tracking",
"llamaindex debugging",
"traces"
],
"link": "https://signoz.io/docs/llamaindex-monitoring/"
},
{
"dataSource": "vercel-ai-sdk",
"label": "Vercel AI SDK",
"imgUrl": "/Logos/vercel.svg",
"tags": ["LLM Monitoring"],
"module": "apm",
"relatedSearchKeywords": [
"vercel ai sdk monitoring",
"vercel ai sdk observability",
"vercel ai sdk performance tracking",
"vercel ai sdk latency tracing",
"vercel ai sdk metrics",
"otel vercel ai sdk integration",
"vercel ai sdk response time",
"vercel ai sdk logs",
"vercel ai sdk error tracking",
"vercel ai sdk debugging",
"traces"
],
"link": "https://signoz.io/docs/vercel-ai-sdk-monitoring/"
},
{
"dataSource": "http-endpoints-monitoring",
"label": "HTTP Endpoints Monitoring",
@@ -3391,5 +3556,58 @@
}
]
}
},
{
"dataSource": "microsoft-sql-server",
"label": "Microsoft SQL Server",
"imgUrl": "/Logos/microsoft-sql-server.svg",
"tags": ["integrations"],
"module": "metrics",
"relatedSearchKeywords": [
"sql server metrics",
"mssql monitoring",
"sql server performance",
"sql server observability",
"Microsoft",
"sql server logs",
"metrics",
"logs"
],
"id": "microsoft-sql-server",
"link": "https://signoz.io/docs/integrations/sql-server/"
},
{
"dataSource": "supabase",
"label": "Supabase",
"imgUrl": "/Logos/supabase.svg",
"tags": ["integrations"],
"module": "metrics",
"relatedSearchKeywords": [
"supabase metrics",
"supabase monitoring",
"supabase performance",
"supabase observability",
"supabase",
"metrics"
],
"id": "supabase",
"link": "https://signoz.io/docs/integrations/supabase/"
},
{
"dataSource": "nomad",
"label": "Nomad",
"imgUrl": "/Logos/nomad.svg",
"tags": ["integrations"],
"module": "metrics",
"relatedSearchKeywords": [
"nomad metrics",
"nomad monitoring",
"nomad performance",
"nomad observability",
"nomad",
"metrics"
],
"id": "nomad",
"link": "https://signoz.io/docs/integrations/nomad/"
}
]

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
@@ -55,11 +56,10 @@ const useOptionsMenu = ({
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const { notifications } = useNotifications();
const {
preferences,
updateColumns,
updateFormatting,
} = usePreferenceContext();
const prefCtx = usePreferenceContext();
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
const { preferences, updateColumns, updateFormatting } = slice;
const [searchText, setSearchText] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);

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