Compare commits
39 Commits
temp/study
...
v0.97.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101b3668b5 | ||
|
|
1b1aa4915b | ||
|
|
f9a70a3a69 | ||
|
|
d3be2632b6 | ||
|
|
78e4f4f386 | ||
|
|
cbb24d9a34 | ||
|
|
9ffe0d8143 | ||
|
|
1a1ef5aff8 | ||
|
|
8b21ba5db9 | ||
|
|
1b818dd05d | ||
|
|
3c3641493e | ||
|
|
411414fa45 | ||
|
|
735b90722d | ||
|
|
8b485de584 | ||
|
|
d595dcc222 | ||
|
|
7ddaa84387 | ||
|
|
6d5f0adab9 | ||
|
|
2c19f0171f | ||
|
|
9a5bcb6b64 | ||
|
|
96cdf21a92 | ||
|
|
1aa5f5d0e1 | ||
|
|
6ac812b5af | ||
|
|
0b4831ca04 | ||
|
|
340aa9ec21 | ||
|
|
5a47a4349b | ||
|
|
80f0c6dd92 | ||
|
|
c0acc69f87 | ||
|
|
9114b44c0e | ||
|
|
c68096152d | ||
|
|
4d8d0223e7 | ||
|
|
2f4b8f6f80 | ||
|
|
a54c3a3d7f | ||
|
|
2c59c1196d | ||
|
|
73ff89a80a | ||
|
|
b2dc2790d8 | ||
|
|
dc8e4365f5 | ||
|
|
eb38dd548a | ||
|
|
0ac5d97495 | ||
|
|
710f7740d3 |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
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.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
signoz-otel-collector:
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
container_name: signoz-otel-collector-dev
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
|
||||
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@@ -21,10 +21,9 @@ jobs:
|
||||
- postgres
|
||||
- sqlite
|
||||
clickhouse-version:
|
||||
- 24.1.2-alpine
|
||||
- 25.5.6
|
||||
schema-migrator-version:
|
||||
- v0.128.1
|
||||
- v0.129.6
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -230,4 +230,6 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
# cursor files
|
||||
frontend/.cursor/
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.95.0
|
||||
image: signoz/signoz:v0.96.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
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.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.95.0
|
||||
image: signoz/signoz:v0.96.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
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.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -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.95.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
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.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
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.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -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.95.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
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.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
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.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -192,7 +192,7 @@ Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` - Choose database provider (default: postgres)
|
||||
- `--postgres-version` - PostgreSQL version (default: 15)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
|
||||
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -107,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable 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 {
|
||||
@@ -115,13 +115,13 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
return
|
||||
}
|
||||
|
||||
selector, parentSelectors, err := cb(req)
|
||||
selector, err := cb(req.Context(), claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -325,17 +325,7 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeRulesManager(
|
||||
ch baseint.Reader,
|
||||
cache cache.Cache,
|
||||
alertmanager alertmanager.Alertmanager,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
orgGetter organization.Getter,
|
||||
querier querier.Querier,
|
||||
logger *slog.Logger,
|
||||
) (*baserules.Manager, error) {
|
||||
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
|
||||
@@ -251,7 +251,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 +301,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 +336,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)
|
||||
@@ -387,6 +392,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
}
|
||||
if smpl.IsMissing {
|
||||
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(labels.NoDataLabel, "true")
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
@@ -407,13 +413,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.
|
||||
@@ -422,7 +427,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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] = ""
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from '@jest/types';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
silent: true,
|
||||
clearMocks: true,
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
|
||||
81
frontend/public/Images/cloud.svg
Normal file
81
frontend/public/Images/cloud.svg
Normal file
@@ -0,0 +1,81 @@
|
||||
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.11 16.8483L14.0369 17.7304C14.0369 17.7304 12.3481 22.0324 12.2437 22.3235C12.1392 22.6146 12.1437 23.0746 12.6037 23.0746C13.1881 23.0746 16.1546 23.0591 16.1546 23.0591C16.1546 23.0591 15.4346 26.5322 15.3924 26.8433C15.3502 27.1544 15.6835 27.4277 15.9768 27.1144C16.2701 26.8011 20.3121 21.6058 20.4877 21.3525C20.801 20.8992 20.4988 20.4814 20.1433 20.4614C19.7877 20.4414 17.4056 20.4925 17.4056 20.4925L19.11 16.8483Z"
|
||||
fill="#FECA18"
|
||||
/>
|
||||
<path
|
||||
d="M17.7589 17.4527C17.7589 17.4527 16.6279 19.9548 16.5856 20.097C16.4612 20.5192 17.0078 20.6903 17.1634 20.3481C17.3189 20.0037 18.6655 17.2194 18.6655 17.2194L17.7589 17.4527Z"
|
||||
fill="#FDB900"
|
||||
/>
|
||||
<path
|
||||
d="M12.8859 22.2592C13.1836 22.2503 15.7968 22.2436 16.0146 22.2281C16.4213 22.1969 16.4835 22.7591 16.0146 22.7591C15.528 22.7591 12.9637 22.768 12.8081 22.7747C12.4481 22.7925 12.3992 22.2747 12.8859 22.2592Z"
|
||||
fill="#FDB900"
|
||||
/>
|
||||
<path
|
||||
d="M14.9813 17.1127C14.9813 17.1127 13.6481 20.4592 13.5725 20.7103C13.2592 21.7591 14.3858 21.728 14.6836 21.1325C14.8302 20.837 16.2635 17.8016 16.3257 17.2527C16.3879 16.7061 14.9813 17.1127 14.9813 17.1127Z"
|
||||
fill="#FFE36A"
|
||||
/>
|
||||
<path
|
||||
d="M15.3347 21.0148C15.1436 20.8837 14.9125 20.9992 14.7725 21.2192C14.6325 21.4392 14.428 21.797 14.7414 21.9858C15.0236 22.1547 15.2724 21.8147 15.3835 21.657C15.4924 21.4992 15.6324 21.217 15.3347 21.0148Z"
|
||||
fill="#FFE36A"
|
||||
/>
|
||||
<path
|
||||
d="M17.6301 21.7326C17.1212 21.6237 16.9568 22.0459 16.8479 22.5459C16.739 23.0459 16.3302 24.6636 16.2546 25.0635C16.1457 25.6257 16.6612 25.7057 16.8812 25.188C17.0457 24.8013 17.759 23.057 17.8501 22.7792C17.9901 22.3592 18.1456 21.8437 17.6301 21.7326Z"
|
||||
fill="#FFE36A"
|
||||
/>
|
||||
<path
|
||||
d="M25.7585 12.0573C25.7585 12.0573 26.3363 4.28441 19.69 3.2978C13.7147 2.41118 12.5415 8.08421 12.5415 8.08421C12.5415 8.08421 10.2838 7.55757 8.66166 9.00639C7.05064 10.4463 7.17508 12.1507 7.17508 12.1507C7.17508 12.1507 3.20195 11.524 2.79531 14.935C2.41533 18.1215 7.11286 17.3282 7.11286 17.3282L29.4183 15.686C29.4183 15.686 29.9472 14.0106 28.2606 12.7462C27.2585 11.9929 25.7585 12.0573 25.7585 12.0573Z"
|
||||
fill="#E4EAEE"
|
||||
/>
|
||||
<path
|
||||
d="M13.7347 13.8196C13.9213 13.7574 14.9857 14.6662 18.0522 14.6951C22.2653 14.7373 25.4563 12.2552 25.4563 12.2552C25.4563 12.2552 25.5629 13.0108 25.2274 13.5485C24.8096 14.2151 24.163 14.3418 24.163 14.3418C24.163 14.3418 25.3318 15.1551 26.9362 15.0307C28.3828 14.9173 29.4294 14.4262 29.4294 14.4262C29.4294 14.4262 29.5694 15.1395 29.4916 15.8351C29.3361 17.2217 28.5228 17.7861 27.6251 17.8883C26.9651 17.9638 21.3276 17.9905 19.0122 18.0127C16.9256 18.0327 6.29285 18.2994 5.20624 18.1794C4.07963 18.0549 3.22412 17.3772 2.91303 16.4061C2.67526 15.6706 2.78859 15.1973 2.78859 15.1973C2.78859 15.1973 4.85959 15.7462 6.02175 15.6151C7.48167 15.4484 8.46162 14.5307 8.46162 14.5307C8.46162 14.5307 9.33713 15.0307 11.277 14.864C12.9458 14.7173 13.7347 13.8196 13.7347 13.8196Z"
|
||||
fill="url(#paint0_radial_811_5475)"
|
||||
/>
|
||||
<path
|
||||
d="M24.8653 18.2661C24.6386 18.1394 23.832 18.8927 23.3787 19.346C23.1009 19.6238 22.2165 20.3504 22.0965 21.0504C21.7988 22.7703 23.7698 23.1614 24.552 22.3637C25.143 21.7615 25.0364 20.586 25.0364 20.1904C25.0386 19.6416 25.1475 18.4216 24.8653 18.2661Z"
|
||||
fill="#52C0EE"
|
||||
/>
|
||||
<path
|
||||
d="M8.67058 19.1904C8.45504 19.0659 7.68174 19.7948 7.24621 20.237C6.97956 20.5058 6.13294 21.2103 6.01294 21.8947C5.71962 23.5723 7.5973 23.9657 8.34837 23.1924C8.91501 22.608 8.82168 21.4591 8.82391 21.0725C8.82613 20.537 8.93723 19.3459 8.67058 19.1904Z"
|
||||
fill="#52C0EE"
|
||||
/>
|
||||
<path
|
||||
d="M12.2548 24.1634C12.0126 24.0723 11.1971 24.8145 10.7438 25.2678C10.466 25.5456 9.64386 26.2566 9.52386 26.9566C9.2261 28.6765 11.1349 29.0832 11.9171 28.2854C12.5081 27.6832 12.4104 26.5077 12.4015 26.1122C12.3793 25.0812 12.4215 24.2256 12.2548 24.1634Z"
|
||||
fill="#52C0EE"
|
||||
/>
|
||||
<path
|
||||
d="M23.5054 20.4926C23.1943 20.3304 22.8165 20.3993 22.5765 20.8992C22.3365 21.3992 22.5343 21.797 22.7854 21.9103C23.0743 22.0436 23.432 21.9214 23.672 21.5147C23.912 21.1081 23.7654 20.6281 23.5054 20.4926Z"
|
||||
fill="#B2E6FE"
|
||||
/>
|
||||
<path
|
||||
d="M10.6682 26.4279C10.3482 26.3168 9.99715 26.4345 9.83716 26.9478C9.67717 27.4611 9.92382 27.8122 10.1794 27.8878C10.4749 27.9744 10.7993 27.8078 10.9727 27.3834C11.1438 26.9589 10.9349 26.5212 10.6682 26.4279Z"
|
||||
fill="#B2E6FE"
|
||||
/>
|
||||
<path
|
||||
d="M7.12613 21.3258C6.78837 21.2325 6.43283 21.3769 6.30395 21.9169C6.17507 22.4569 6.45061 22.8035 6.71948 22.8613C7.03058 22.9302 7.35278 22.7369 7.50389 22.288C7.65277 21.8436 7.41056 21.4036 7.12613 21.3258Z"
|
||||
fill="#B2E6FE"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_811_5475"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="matrix(0.18837 -6.53799 9.79456 0.282554 16.401 18.5051)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1934" stopColor="#FFE366" />
|
||||
<stop offset="0.3305" stopColor="#EDDD82" />
|
||||
<stop offset="0.5709" stopColor="#D0D4AD" />
|
||||
<stop offset="0.7589" stopColor="#BFCFC7" />
|
||||
<stop offset="0.8699" stopColor="#B8CDD1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
28
frontend/src/api/alerts/createAlertRule.ts
Normal file
28
frontend/src/api/alerts/createAlertRule.ts
Normal 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;
|
||||
28
frontend/src/api/alerts/testAlertRule.ts
Normal file
28
frontend/src/api/alerts/testAlertRule.ts
Normal 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;
|
||||
26
frontend/src/api/alerts/updateAlertRule.ts
Normal file
26
frontend/src/api/alerts/updateAlertRule.ts
Normal 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;
|
||||
34
frontend/src/api/routingPolicies/createRoutingPolicy.ts
Normal file
34
frontend/src/api/routingPolicies/createRoutingPolicy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
export interface CreateRoutingPolicyBody {
|
||||
name: string;
|
||||
expression: string;
|
||||
channels: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoutingPolicyResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const createRoutingPolicy = async (
|
||||
props: CreateRoutingPolicyBody,
|
||||
): Promise<
|
||||
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(`/route_policies`, props);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default createRoutingPolicy;
|
||||
28
frontend/src/api/routingPolicies/deleteRoutingPolicy.ts
Normal file
28
frontend/src/api/routingPolicies/deleteRoutingPolicy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
export interface DeleteRoutingPolicyResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const deleteRoutingPolicy = async (
|
||||
routingPolicyId: string,
|
||||
): Promise<
|
||||
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.delete(`/route_policies/${routingPolicyId}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteRoutingPolicy;
|
||||
40
frontend/src/api/routingPolicies/getRoutingPolicies.ts
Normal file
40
frontend/src/api/routingPolicies/getRoutingPolicies.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
export interface ApiRoutingPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
expression: string;
|
||||
description: string;
|
||||
channels: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface GetRoutingPoliciesResponse {
|
||||
status: string;
|
||||
data?: ApiRoutingPolicy[];
|
||||
}
|
||||
|
||||
export const getRoutingPolicies = async (
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
|
||||
try {
|
||||
const response = await axios.get('/route_policies', {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
38
frontend/src/api/routingPolicies/updateRoutingPolicy.ts
Normal file
38
frontend/src/api/routingPolicies/updateRoutingPolicy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
export interface UpdateRoutingPolicyBody {
|
||||
name: string;
|
||||
expression: string;
|
||||
channels: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoutingPolicyResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const updateRoutingPolicy = async (
|
||||
id: string,
|
||||
props: UpdateRoutingPolicyBody,
|
||||
): Promise<
|
||||
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.put(`/route_policies/${id}`, {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateRoutingPolicy;
|
||||
31
frontend/src/api/thirdPartyApis/listOverview.ts
Normal file
31
frontend/src/api/thirdPartyApis/listOverview.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
|
||||
|
||||
const listOverview = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
const { start, end, show_ip: showIp, filter } = props;
|
||||
try {
|
||||
const response = await ApiBaseInstance.post(
|
||||
`/third-party-apis/overview/list`,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default listOverview;
|
||||
@@ -124,7 +124,7 @@ export const FUNCTION_NAMES: Record<string, FunctionName> = {
|
||||
RUNNING_DIFF: 'runningDiff',
|
||||
LOG2: 'log2',
|
||||
LOG10: 'log10',
|
||||
CUM_SUM: 'cumSum',
|
||||
CUM_SUM: 'cumulativeSum',
|
||||
EWMA3: 'ewma3',
|
||||
EWMA5: 'ewma5',
|
||||
EWMA7: 'ewma7',
|
||||
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ function ChangelogModal({ changelog, onClose }: Props): JSX.Element {
|
||||
|
||||
const onClickUpdateWorkspace = (): void => {
|
||||
window.open(
|
||||
'https://github.com/SigNoz/signoz/releases',
|
||||
'https://signoz.io/upgrade-path',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('ChangelogModal', () => {
|
||||
renderChangelog();
|
||||
fireEvent.click(screen.getByText('Update my workspace'));
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://github.com/SigNoz/signoz/releases',
|
||||
'https://signoz.io/upgrade-path',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
117
frontend/src/components/ErrorBoundaryHOC/README.md
Normal file
117
frontend/src/components/ErrorBoundaryHOC/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# withErrorBoundary HOC
|
||||
|
||||
A Higher-Order Component (HOC) that wraps React components with ErrorBoundary to provide error handling and recovery.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Error Catching**: Catches JavaScript errors in any component tree
|
||||
- **Integration**: Automatically reports errors with context
|
||||
- **Custom Fallback UI**: Supports custom error fallback components
|
||||
- **Error Logging**: Optional custom error handlers for additional logging
|
||||
- **TypeScript Support**: Fully typed with proper generics
|
||||
- **Component Context**: Automatically adds component name to tags
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { withErrorBoundary } from 'components/HOC';
|
||||
|
||||
// Wrap any component
|
||||
const SafeComponent = withErrorBoundary(MyComponent);
|
||||
|
||||
// Use it like any other component
|
||||
<SafeComponent prop1="value1" prop2="value2" />
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Fallback Component
|
||||
|
||||
```tsx
|
||||
const CustomFallback = () => (
|
||||
<div>
|
||||
<h3>Oops! Something went wrong</h3>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SafeComponent = withErrorBoundary(MyComponent, {
|
||||
fallback: <CustomFallback />
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Error Handler
|
||||
|
||||
```tsx
|
||||
const SafeComponent = withErrorBoundary(MyComponent, {
|
||||
onError: (error, componentStack, eventId) => {
|
||||
console.error('Component error:', error);
|
||||
// Send to analytics, logging service, etc.
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Sentry Configuration
|
||||
|
||||
```tsx
|
||||
const SafeComponent = withErrorBoundary(MyComponent, {
|
||||
sentryOptions: {
|
||||
tags: {
|
||||
section: 'dashboard',
|
||||
priority: 'high',
|
||||
feature: 'metrics'
|
||||
},
|
||||
level: 'error'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withErrorBoundary<P>(component, options?)`
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `component: ComponentType<P>` - The React component to wrap
|
||||
- `options?: WithErrorBoundaryOptions` - Configuration options
|
||||
|
||||
#### Options
|
||||
|
||||
```tsx
|
||||
interface WithErrorBoundaryOptions {
|
||||
/** Custom fallback component to render when an error occurs */
|
||||
fallback?: ReactElement;
|
||||
|
||||
/** Custom error handler function */
|
||||
onError?: (
|
||||
error: unknown,
|
||||
componentStack: string | undefined,
|
||||
eventId: string
|
||||
) => void;
|
||||
|
||||
/** Additional props to pass to the Sentry ErrorBoundary */
|
||||
sentryOptions?: {
|
||||
tags?: Record<string, string>;
|
||||
level?: Sentry.SeverityLevel;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Critical Components**: Wrap important UI components that shouldn't crash the entire app
|
||||
- **Third-party Integrations**: Wrap components that use external libraries
|
||||
- **Data-heavy Components**: Wrap components that process complex data
|
||||
- **Route Components**: Wrap page-level components to prevent navigation issues
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Sparingly**: Don't wrap every component - focus on critical ones
|
||||
2. **Meaningful Fallbacks**: Provide helpful fallback UI that guides users
|
||||
3. **Log Errors**: Always implement error logging for debugging
|
||||
4. **Component Names**: Ensure components have proper `displayName` for debugging
|
||||
5. **Test Error Scenarios**: Test that your error boundaries work as expected
|
||||
|
||||
## Examples
|
||||
|
||||
See `withErrorBoundary.example.tsx` for complete usage examples.
|
||||
@@ -0,0 +1,211 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import withErrorBoundary, {
|
||||
WithErrorBoundaryOptions,
|
||||
} from '../withErrorBoundary';
|
||||
|
||||
// Mock dependencies before imports
|
||||
jest.mock('@sentry/react', () => {
|
||||
const ReactMock = jest.requireActual('react');
|
||||
|
||||
class MockErrorBoundary extends ReactMock.Component<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
fallback: React.ReactElement;
|
||||
onError?: (error: Error, componentStack: string, eventId: string) => void;
|
||||
beforeCapture?: (scope: {
|
||||
setTag: (key: string, value: string) => void;
|
||||
setLevel: (level: string) => void;
|
||||
}) => void;
|
||||
},
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: MockErrorBoundary['props']) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: { componentStack: string }): void {
|
||||
const { beforeCapture, onError } = this.props;
|
||||
if (beforeCapture) {
|
||||
const mockScope = {
|
||||
setTag: jest.fn(),
|
||||
setLevel: jest.fn(),
|
||||
};
|
||||
beforeCapture(mockScope);
|
||||
}
|
||||
if (onError) {
|
||||
onError(error, errorInfo.componentStack, 'mock-event-id');
|
||||
}
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { hasError } = this.state;
|
||||
const { fallback, children } = this.props;
|
||||
if (hasError) {
|
||||
return <div data-testid="error-boundary-fallback">{fallback}</div>;
|
||||
}
|
||||
return <div data-testid="app-error-boundary">{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ErrorBoundary: MockErrorBoundary,
|
||||
SeverityLevel: {
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'../../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback',
|
||||
() =>
|
||||
function MockErrorBoundaryFallback(): JSX.Element {
|
||||
return (
|
||||
<div data-testid="default-error-fallback">Default Error Fallback</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Test component that can throw errors
|
||||
interface TestComponentProps {
|
||||
shouldThrow?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function TestComponent({
|
||||
shouldThrow = false,
|
||||
message = 'Test Component',
|
||||
}: TestComponentProps): JSX.Element {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return <div data-testid="test-component">{message}</div>;
|
||||
}
|
||||
|
||||
TestComponent.defaultProps = {
|
||||
shouldThrow: false,
|
||||
message: 'Test Component',
|
||||
};
|
||||
|
||||
// Test component with display name
|
||||
function NamedComponent(): JSX.Element {
|
||||
return <div data-testid="named-component">Named Component</div>;
|
||||
}
|
||||
NamedComponent.displayName = 'NamedComponent';
|
||||
|
||||
describe('withErrorBoundary', () => {
|
||||
// Suppress console errors for cleaner test output
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should wrap component with ErrorBoundary and render successfully', () => {
|
||||
// Arrange
|
||||
const SafeComponent = withErrorBoundary(TestComponent);
|
||||
|
||||
// Act
|
||||
render(<SafeComponent message="Hello World" />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('app-error-boundary')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallback UI when component throws error', () => {
|
||||
// Arrange
|
||||
const SafeComponent = withErrorBoundary(TestComponent);
|
||||
|
||||
// Act
|
||||
render(<SafeComponent shouldThrow />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('default-error-fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom fallback component when provided', () => {
|
||||
// Arrange
|
||||
const customFallback = (
|
||||
<div data-testid="custom-fallback">Custom Error UI</div>
|
||||
);
|
||||
const options: WithErrorBoundaryOptions = {
|
||||
fallback: customFallback,
|
||||
};
|
||||
const SafeComponent = withErrorBoundary(TestComponent, options);
|
||||
|
||||
// Act
|
||||
render(<SafeComponent shouldThrow />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Error UI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call custom error handler when error occurs', () => {
|
||||
// Arrange
|
||||
const mockErrorHandler = jest.fn();
|
||||
const options: WithErrorBoundaryOptions = {
|
||||
onError: mockErrorHandler,
|
||||
};
|
||||
const SafeComponent = withErrorBoundary(TestComponent, options);
|
||||
|
||||
// Act
|
||||
render(<SafeComponent shouldThrow />);
|
||||
|
||||
// Assert
|
||||
expect(mockErrorHandler).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.any(String),
|
||||
'mock-event-id',
|
||||
);
|
||||
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should set correct display name for debugging', () => {
|
||||
// Arrange & Act
|
||||
const SafeTestComponent = withErrorBoundary(TestComponent);
|
||||
const SafeNamedComponent = withErrorBoundary(NamedComponent);
|
||||
|
||||
// Assert
|
||||
expect(SafeTestComponent.displayName).toBe(
|
||||
'withErrorBoundary(TestComponent)',
|
||||
);
|
||||
expect(SafeNamedComponent.displayName).toBe(
|
||||
'withErrorBoundary(NamedComponent)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle component without display name', () => {
|
||||
// Arrange
|
||||
function AnonymousComponent(): JSX.Element {
|
||||
return <div>Anonymous</div>;
|
||||
}
|
||||
|
||||
// Act
|
||||
const SafeAnonymousComponent = withErrorBoundary(AnonymousComponent);
|
||||
|
||||
// Assert
|
||||
expect(SafeAnonymousComponent.displayName).toBe(
|
||||
'withErrorBoundary(AnonymousComponent)',
|
||||
);
|
||||
});
|
||||
});
|
||||
2
frontend/src/components/ErrorBoundaryHOC/index.ts
Normal file
2
frontend/src/components/ErrorBoundaryHOC/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { WithErrorBoundaryOptions } from './withErrorBoundary';
|
||||
export { default as withErrorBoundary } from './withErrorBoundary';
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Button } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { withErrorBoundary } from './index';
|
||||
|
||||
/**
|
||||
* Example component that can throw errors
|
||||
*/
|
||||
function ProblematicComponent(): JSX.Element {
|
||||
const [shouldThrow, setShouldThrow] = useState(false);
|
||||
|
||||
if (shouldThrow) {
|
||||
throw new Error('This is a test error from ProblematicComponent!');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Problematic Component</h3>
|
||||
<p>This component can throw errors when the button is clicked.</p>
|
||||
<Button type="primary" onClick={(): void => setShouldThrow(true)} danger>
|
||||
Trigger Error
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic usage - wraps component with default error boundary
|
||||
*/
|
||||
export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent);
|
||||
|
||||
/**
|
||||
* Usage with custom fallback component
|
||||
*/
|
||||
function CustomErrorFallback(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{ padding: '20px', border: '1px solid red', borderRadius: '4px' }}
|
||||
>
|
||||
<h4 style={{ color: 'red' }}>Custom Error Fallback</h4>
|
||||
<p>Something went wrong in this specific component!</p>
|
||||
<Button onClick={(): void => window.location.reload()}>Reload Page</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SafeProblematicComponentWithCustomFallback = withErrorBoundary(
|
||||
ProblematicComponent,
|
||||
{
|
||||
fallback: <CustomErrorFallback />,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Usage with custom error handler
|
||||
*/
|
||||
export const SafeProblematicComponentWithErrorHandler = withErrorBoundary(
|
||||
ProblematicComponent,
|
||||
{
|
||||
onError: (error, errorInfo) => {
|
||||
console.error('Custom error handler:', error);
|
||||
console.error('Error info:', errorInfo);
|
||||
// You could also send to analytics, logging service, etc.
|
||||
},
|
||||
sentryOptions: {
|
||||
tags: {
|
||||
section: 'dashboard',
|
||||
priority: 'high',
|
||||
},
|
||||
level: 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Example of wrapping an existing component from the codebase
|
||||
*/
|
||||
function ExistingComponent({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: any[];
|
||||
}): JSX.Element {
|
||||
// This could be any existing component that might throw errors
|
||||
return (
|
||||
<div>
|
||||
<h4>{title}</h4>
|
||||
<ul>
|
||||
{data.map((item, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={index}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SafeExistingComponent = withErrorBoundary(ExistingComponent, {
|
||||
sentryOptions: {
|
||||
tags: {
|
||||
component: 'ExistingComponent',
|
||||
feature: 'data-display',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Usage examples in a container component
|
||||
*/
|
||||
export function ErrorBoundaryExamples(): JSX.Element {
|
||||
const sampleData = [
|
||||
{ name: 'Item 1' },
|
||||
{ name: 'Item 2' },
|
||||
{ name: 'Item 3' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h2>Error Boundary HOC Examples</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>1. Basic Usage</h3>
|
||||
<SafeProblematicComponent />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>2. With Custom Fallback</h3>
|
||||
<SafeProblematicComponentWithCustomFallback />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>3. With Custom Error Handler</h3>
|
||||
<SafeProblematicComponentWithErrorHandler />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>4. Wrapped Existing Component</h3>
|
||||
<SafeExistingComponent title="Sample Data" data={sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { ComponentType, ReactElement } from 'react';
|
||||
|
||||
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
/**
|
||||
* Configuration options for the ErrorBoundary HOC
|
||||
*/
|
||||
interface WithErrorBoundaryOptions {
|
||||
/** Custom fallback component to render when an error occurs */
|
||||
fallback?: ReactElement;
|
||||
/** Custom error handler function */
|
||||
onError?: (
|
||||
error: unknown,
|
||||
componentStack: string | undefined,
|
||||
eventId: string,
|
||||
) => void;
|
||||
/** Additional props to pass to the ErrorBoundary */
|
||||
sentryOptions?: {
|
||||
tags?: Record<string, string>;
|
||||
level?: Sentry.SeverityLevel;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-Order Component that wraps a component with ErrorBoundary
|
||||
*
|
||||
* @param WrappedComponent - The component to wrap with error boundary
|
||||
* @param options - Configuration options for the error boundary
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* const SafeComponent = withErrorBoundary(MyComponent);
|
||||
*
|
||||
* @example
|
||||
* // With custom fallback
|
||||
* const SafeComponent = withErrorBoundary(MyComponent, {
|
||||
* fallback: <div>Something went wrong!</div>
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // With custom error handler
|
||||
* const SafeComponent = withErrorBoundary(MyComponent, {
|
||||
* onError: (error, errorInfo) => {
|
||||
* console.error('Component error:', error, errorInfo);
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
function withErrorBoundary<P extends Record<string, unknown>>(
|
||||
WrappedComponent: ComponentType<P>,
|
||||
options: WithErrorBoundaryOptions = {},
|
||||
): ComponentType<P> {
|
||||
const {
|
||||
fallback = <ErrorBoundaryFallback />,
|
||||
onError,
|
||||
sentryOptions = {},
|
||||
} = options;
|
||||
|
||||
function WithErrorBoundaryComponent(props: P): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={fallback}
|
||||
beforeCapture={(scope): void => {
|
||||
// Add component name to context
|
||||
scope.setTag(
|
||||
'component',
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Unknown',
|
||||
);
|
||||
|
||||
// Add any custom tags
|
||||
if (sentryOptions.tags) {
|
||||
Object.entries(sentryOptions.tags).forEach(([key, value]) => {
|
||||
scope.setTag(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Set severity level if provided
|
||||
if (sentryOptions.level) {
|
||||
scope.setLevel(sentryOptions.level);
|
||||
}
|
||||
}}
|
||||
onError={onError}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} />
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Set display name for debugging purposes
|
||||
WithErrorBoundaryComponent.displayName = `withErrorBoundary(${
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
||||
})`;
|
||||
|
||||
return WithErrorBoundaryComponent;
|
||||
}
|
||||
|
||||
export default withErrorBoundary;
|
||||
export type { WithErrorBoundaryOptions };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
function AnnouncementsModal(): JSX.Element {
|
||||
return (
|
||||
<div className="announcements-modal-container">
|
||||
<div className="announcements-modal-container-header">
|
||||
<Typography.Text className="announcements-modal-title">
|
||||
Announcements
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnnouncementsModal;
|
||||
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const location = useLocation();
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
let entityName = 'Feedback';
|
||||
if (activeTab === 'reportBug') {
|
||||
entityName = 'Bug report';
|
||||
} else if (activeTab === 'featureRequest') {
|
||||
entityName = 'Feature request';
|
||||
}
|
||||
|
||||
logEvent('Feedback: Submitted', {
|
||||
data: feedback,
|
||||
type: activeTab,
|
||||
page: location.pathname,
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
|
||||
toast.success(`${entityName} submitted successfully`, {
|
||||
position: 'top-right',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(`Failed to submit ${entityName}`);
|
||||
toast.error(`Failed to submit ${entityName}`, {
|
||||
position: 'top-right',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
setFeedback('');
|
||||
setActiveTab('feedback');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot feedback-tab" />
|
||||
Feedback
|
||||
</div>
|
||||
),
|
||||
key: 'feedback',
|
||||
value: 'feedback',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot bug-tab" />
|
||||
Report a bug
|
||||
</div>
|
||||
),
|
||||
key: 'reportBug',
|
||||
value: 'reportBug',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot feature-tab" />
|
||||
Feature request
|
||||
</div>
|
||||
),
|
||||
key: 'featureRequest',
|
||||
value: 'featureRequest',
|
||||
},
|
||||
];
|
||||
|
||||
const handleFeedbackChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
): void => {
|
||||
setFeedback(e.target.value);
|
||||
};
|
||||
|
||||
const handleContactSupportClick = useCallback((): void => {
|
||||
handleContactSupport(isCloudUserVal);
|
||||
}, [isCloudUserVal]);
|
||||
|
||||
return (
|
||||
<div className="feedback-modal-container">
|
||||
<div className="feedback-modal-header">
|
||||
<Radio.Group
|
||||
value={activeTab}
|
||||
defaultValue={activeTab}
|
||||
optionType="button"
|
||||
className="feedback-modal-tabs"
|
||||
options={items}
|
||||
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="feedback-modal-content">
|
||||
<div className="feedback-modal-content-header">
|
||||
<Input.TextArea
|
||||
placeholder="Write your feedback here..."
|
||||
rows={6}
|
||||
required
|
||||
className="feedback-input"
|
||||
value={feedback}
|
||||
onChange={handleFeedbackChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feedback-modal-content-footer">
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={feedback.length === 0}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<div className="feedback-modal-content-footer-info-text">
|
||||
<Typography.Text>
|
||||
Have a specific issue?{' '}
|
||||
<Typography.Link
|
||||
className="contact-support-link"
|
||||
onClick={handleContactSupportClick}
|
||||
>
|
||||
Contact Support{' '}
|
||||
</Typography.Link>
|
||||
or{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/introduction/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="read-docs-link"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -0,0 +1,253 @@
|
||||
.header-right-section-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
width: 460px;
|
||||
|
||||
border-radius: 4px;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.absolute-relative-time-toggler-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.absolute-relative-time-toggler-label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.absolute-relative-time-toggler {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.absolute-relative-time-error {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-600);
|
||||
}
|
||||
|
||||
.share-link {
|
||||
.url-share-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.url-share-container-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.url-share-title,
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.url-share-sub-title {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-tab {
|
||||
background-color: var(--bg-sakura-500);
|
||||
}
|
||||
|
||||
.bug-tab {
|
||||
background-color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.feature-tab {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
padding: 6px 16px;
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
margin: 0 !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
border-bottom: none !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.feedback-input {
|
||||
resize: none;
|
||||
|
||||
text-area {
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-content-include-console-logs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.feedback-modal-content-footer-info-text {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
text-align: center;
|
||||
|
||||
/* button/ small */
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 200% */
|
||||
|
||||
.contact-support-link,
|
||||
.read-docs-link {
|
||||
color: var(--bg-robin-400);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
.absolute-relative-time-toggler-container {
|
||||
.absolute-relative-time-toggler-label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.share-link {
|
||||
.url-share-container {
|
||||
.url-share-container-header {
|
||||
.url-share-title,
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content-footer {
|
||||
.feedback-modal-content-footer-info-text {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import './HeaderRightSection.styles.scss';
|
||||
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
|
||||
interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
enableShare: boolean;
|
||||
enableFeedback: boolean;
|
||||
}
|
||||
|
||||
function HeaderRightSection({
|
||||
enableAnnouncements,
|
||||
enableShare,
|
||||
enableFeedback,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
|
||||
const [openShareURLModal, setOpenShareURLModal] = useState(false);
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
|
||||
setOpenFeedbackModal(true);
|
||||
setOpenShareURLModal(false);
|
||||
setOpenAnnouncementsModal(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleOpenShareURLModal = useCallback((): void => {
|
||||
logEvent('Share: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
|
||||
setOpenShareURLModal(true);
|
||||
setOpenFeedbackModal(false);
|
||||
setOpenAnnouncementsModal(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleCloseFeedbackModal = (): void => {
|
||||
setOpenFeedbackModal(false);
|
||||
};
|
||||
|
||||
const handleOpenFeedbackModalChange = (open: boolean): void => {
|
||||
setOpenFeedbackModal(open);
|
||||
};
|
||||
|
||||
const handleOpenAnnouncementsModalChange = (open: boolean): void => {
|
||||
setOpenAnnouncementsModal(open);
|
||||
};
|
||||
|
||||
const handleOpenShareURLModalChange = (open: boolean): void => {
|
||||
setOpenShareURLModal(open);
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<FeedbackModal onClose={handleCloseFeedbackModal} />}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
open={openFeedbackModal}
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{enableAnnouncements && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<AnnouncementsModal />}
|
||||
arrow={false}
|
||||
destroyTooltipOnHide
|
||||
trigger="click"
|
||||
open={openAnnouncementsModal}
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{enableShare && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<ShareURLModal />}
|
||||
open={openShareURLModal}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderRightSection;
|
||||
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { Check, Info, Link2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const routesToBeSharedWithTime = [
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
ROUTES.METER_EXPLORER,
|
||||
];
|
||||
|
||||
function ShareURLModal(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
|
||||
selectedTime !== 'custom',
|
||||
);
|
||||
|
||||
const startTime = urlQuery.get(QueryParams.startTime);
|
||||
const endTime = urlQuery.get(QueryParams.endTime);
|
||||
const relativeTime = urlQuery.get(QueryParams.relativeTime);
|
||||
|
||||
const [isURLCopied, setIsURLCopied] = useState(false);
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isValidateRelativeTime = useMemo(
|
||||
() =>
|
||||
selectedTime !== 'custom' ||
|
||||
(startTime && endTime && selectedTime === 'custom'),
|
||||
[startTime, endTime, selectedTime],
|
||||
);
|
||||
|
||||
const shareURLWithTime = useMemo(
|
||||
() => relativeTime || (startTime && endTime),
|
||||
[relativeTime, startTime, endTime],
|
||||
);
|
||||
|
||||
const isRouteToBeSharedWithTime = useMemo(
|
||||
() =>
|
||||
routesToBeSharedWithTime.some((route) =>
|
||||
matchPath(location.pathname, { path: route, exact: true }),
|
||||
),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const processURL = (): string => {
|
||||
let currentUrl = window.location.href;
|
||||
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
|
||||
|
||||
if (shareURLWithTime || isRouteToBeSharedWithTime) {
|
||||
if (enableAbsoluteTime || isCustomTime) {
|
||||
if (selectedTime === 'custom') {
|
||||
if (startTime && endTime) {
|
||||
urlQuery.set(QueryParams.startTime, startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTime.toString());
|
||||
}
|
||||
} else {
|
||||
const { minTime, maxTime } = GetMinMax(selectedTime);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
}
|
||||
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
|
||||
urlQuery.set(QueryParams.relativeTime, selectedTime);
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
};
|
||||
|
||||
const handleCopyURL = (): void => {
|
||||
const URL = processURL();
|
||||
|
||||
handleCopyToClipboard(URL);
|
||||
setIsURLCopied(true);
|
||||
|
||||
logEvent('Share: Copy link clicked', {
|
||||
page: location.pathname,
|
||||
URL,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setIsURLCopied(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-modal-content">
|
||||
{(shareURLWithTime || isRouteToBeSharedWithTime) && (
|
||||
<>
|
||||
<div className="absolute-relative-time-toggler-container">
|
||||
<Typography.Text className="absolute-relative-time-toggler-label">
|
||||
Enable absolute time
|
||||
</Typography.Text>
|
||||
|
||||
<div className="absolute-relative-time-toggler">
|
||||
{!isValidateRelativeTime && (
|
||||
<Info size={14} color={Color.BG_AMBER_600} />
|
||||
)}
|
||||
<Switch
|
||||
checked={enableAbsoluteTime}
|
||||
disabled={!isValidateRelativeTime}
|
||||
size="small"
|
||||
onChange={(): void => {
|
||||
setEnableAbsoluteTime((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isValidateRelativeTime && (
|
||||
<div className="absolute-relative-time-error">
|
||||
Please select / enter valid relative time to toggle.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="share-link">
|
||||
<div className="url-share-container">
|
||||
<div className="url-share-container-header">
|
||||
<Typography.Text className="url-share-title">
|
||||
Share page link
|
||||
</Typography.Text>
|
||||
<Typography.Text className="url-share-sub-title">
|
||||
Share the current page link with your team member
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="periscope-btn secondary"
|
||||
onClick={handleCopyURL}
|
||||
icon={isURLCopied ? <Check size={14} /> : <Link2 size={14} />}
|
||||
>
|
||||
Copy page link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareURLModal;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import AnnouncementsModal from '../AnnouncementsModal';
|
||||
|
||||
describe('AnnouncementsModal', () => {
|
||||
it('should render announcements modal with title', () => {
|
||||
render(<AnnouncementsModal />);
|
||||
|
||||
expect(screen.getByText('Announcements')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper structure and classes', () => {
|
||||
render(<AnnouncementsModal />);
|
||||
|
||||
const container = screen
|
||||
.getByText('Announcements')
|
||||
.closest('.announcements-modal-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
const headerContainer = screen
|
||||
.getByText('Announcements')
|
||||
.closest('.announcements-modal-container-header');
|
||||
expect(headerContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without any errors', () => {
|
||||
expect(() => render(<AnnouncementsModal />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
// Mock dependencies before imports
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('pages/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
const mockToast = toast as jest.Mocked<typeof toast>;
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
const mockLocation = {
|
||||
pathname: '/test-path',
|
||||
};
|
||||
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
});
|
||||
mockToast.success.mockClear();
|
||||
mockToast.error.mockClear();
|
||||
});
|
||||
|
||||
it('should render feedback modal with all tabs', () => {
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Feedback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Report a bug')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feature request')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Write your feedback here...'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch between tabs when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Initially, feedback radio should be active
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Bug radio should now be active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Feature radio should now be active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('should update feedback text when typing in textarea', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const testFeedback = 'This is my feedback';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
|
||||
expect(textarea).toHaveValue(testFeedback);
|
||||
});
|
||||
|
||||
it('should submit feedback and log event when submit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'Test feedback content';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Feedback submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should submit bug report with correct type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Switch to bug report tab
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Verify bug report radio is now active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'This is a bug report';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Bug report submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should submit feature request with correct type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Switch to feature request tab
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Verify feature request radio is now active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'This is a feature request';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Feature request submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call handleContactSupport when contact support link is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const isCloudUser = true;
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
});
|
||||
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const contactSupportLink = screen.getByText('Contact Support');
|
||||
await user.click(contactSupportLink);
|
||||
|
||||
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
|
||||
});
|
||||
|
||||
it('should handle non-cloud user for contact support', async () => {
|
||||
const user = userEvent.setup();
|
||||
const isCloudUser = false;
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
});
|
||||
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const contactSupportLink = screen.getByText('Contact Support');
|
||||
await user.click(contactSupportLink);
|
||||
|
||||
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
|
||||
});
|
||||
|
||||
it('should render docs link with correct attributes', () => {
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const docsLink = screen.getByText('Read our docs');
|
||||
expect(docsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/introduction/',
|
||||
);
|
||||
expect(docsLink).toHaveAttribute('target', '_blank');
|
||||
expect(docsLink).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('should reset form state when component unmounts', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Render component
|
||||
const { unmount } = render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Change the form state first
|
||||
const textArea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
await user.type(textArea, 'Some feedback text');
|
||||
|
||||
// Change the active tab
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Verify state has changed
|
||||
expect(textArea).toHaveValue('Some feedback text');
|
||||
|
||||
// Unmount the component - this should trigger cleanup
|
||||
unmount();
|
||||
|
||||
// Re-render the component to verify state was reset
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Verify form state is reset
|
||||
const newTextArea = screen.getByPlaceholderText(
|
||||
'Write your feedback here...',
|
||||
);
|
||||
expect(newTextArea).toHaveValue(''); // Should be empty
|
||||
|
||||
// Verify active radio is reset to default (Feedback radio)
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
// Mock dependencies before imports
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../FeedbackModal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onClose }: { onClose: () => void }): JSX.Element => (
|
||||
<div data-testid="feedback-modal">
|
||||
<button onClick={onClose} type="button">
|
||||
Close Feedback
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareURLModal', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="share-modal">Share URL Modal</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../AnnouncementsModal', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="announcements-modal">Announcements Modal</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
const defaultProps = {
|
||||
enableAnnouncements: true,
|
||||
enableShare: true,
|
||||
enableFeedback: true,
|
||||
};
|
||||
|
||||
const mockLocation = {
|
||||
pathname: '/test-path',
|
||||
};
|
||||
|
||||
describe('HeaderRightSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
// Default to licensed user (Enterprise or Cloud)
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all buttons when all features are enabled', () => {
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
|
||||
|
||||
// Check for feedback button by class
|
||||
const feedbackButton = document.querySelector(
|
||||
'.share-feedback-btn[class*="share-feedback-btn"]',
|
||||
);
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
// Check for announcements button by finding the inbox icon
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render only enabled features', () => {
|
||||
render(
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare={false}
|
||||
enableFeedback
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /share/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Check that inbox icon is not present
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).not.toBeInTheDocument();
|
||||
|
||||
// Check that feedback button is present
|
||||
const squarePenIcon = document.querySelector('.lucide-square-pen');
|
||||
expect(squarePenIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open feedback modal and log event when feedback button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open share modal and log event when share button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log event when announcements button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const announcementsButton = document
|
||||
.querySelector('.lucide-inbox')
|
||||
?.closest('button');
|
||||
expect(announcementsButton).toBeInTheDocument();
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
it('should close feedback modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Open feedback modal
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
|
||||
// Close feedback modal
|
||||
const closeFeedbackButton = screen.getByText('Close Feedback');
|
||||
await user.click(closeFeedbackButton);
|
||||
expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close other modals when opening feedback modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Open share modal first
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
|
||||
// Open feedback modal - should close share modal
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show feedback button for Cloud users when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show feedback button for Enterprise self-hosted users when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide feedback button for Community users even when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: true,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide feedback button for Community Enterprise users even when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: true,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct number of buttons when feedback is hidden due to license', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: true,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Should have 2 buttons (announcements + share) instead of 3
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// Verify which buttons are present
|
||||
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).toBeInTheDocument();
|
||||
|
||||
// Verify feedback button is not present
|
||||
const feedbackIcon = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// Mock dependencies before imports
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
matchPath: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
href: 'https://example.com/test-path?param=value',
|
||||
origin: 'https://example.com',
|
||||
};
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
const mockGetMinMax = GetMinMax as jest.Mock;
|
||||
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
|
||||
const mockMatchPath = matchPath as jest.Mock;
|
||||
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
toString: jest.fn(() => 'param=value'),
|
||||
};
|
||||
|
||||
const mockHandleCopyToClipboard = jest.fn();
|
||||
|
||||
const TEST_PATH = '/test-path';
|
||||
const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time';
|
||||
|
||||
describe('ShareURLModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: TEST_PATH,
|
||||
});
|
||||
|
||||
mockUseUrlQuery.mockReturnValue(mockUrlQuery);
|
||||
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1000000,
|
||||
maxTime: 2000000,
|
||||
});
|
||||
|
||||
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
|
||||
|
||||
mockMatchPath.mockReturnValue(false);
|
||||
|
||||
// Reset URL query mocks - all return null by default
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
|
||||
// Reset mock functions
|
||||
mockUrlQuery.set.mockClear();
|
||||
mockUrlQuery.delete.mockClear();
|
||||
mockUrlQuery.toString.mockReturnValue('param=value');
|
||||
});
|
||||
|
||||
it('should render share modal with copy button', () => {
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText('Share page link')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Share the current page link with your team member'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy page link/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy URL and log event when copy button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ShareURLModal />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show absolute time toggle when on time-enabled route', () => {
|
||||
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show absolute time toggle when URL has time parameters', () => {
|
||||
mockUrlQuery.get.mockImplementation((key: string) =>
|
||||
key === 'relativeTime' ? '5min' : null,
|
||||
);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle absolute time switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min', // Non-custom time should enable absolute time by default
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
// Should be checked by default for non-custom time
|
||||
expect(toggleSwitch).toBeChecked();
|
||||
|
||||
await user.click(toggleSwitch);
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should disable toggle when relative time is invalid', () => {
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: 'custom',
|
||||
});
|
||||
|
||||
// Invalid - missing start and end time for custom
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Please select / enter valid relative time to toggle.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should process URL with absolute time for non-custom time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Absolute time should be enabled by default for non-custom time
|
||||
// Click copy button directly
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
|
||||
});
|
||||
|
||||
it('should process URL with custom time parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: 'custom',
|
||||
});
|
||||
|
||||
mockUrlQuery.get.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'startTime':
|
||||
return '1500000';
|
||||
case 'endTime':
|
||||
return '1600000';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Should be enabled by default for custom time
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000');
|
||||
});
|
||||
|
||||
it('should process URL with relative time when absolute time is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Disable absolute time first (it's enabled by default for non-custom time)
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
await user.click(toggleSwitch);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
|
||||
});
|
||||
|
||||
it('should handle routes that should be shared with time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: ROUTES.LOGS_EXPLORER,
|
||||
});
|
||||
|
||||
mockMatchPath.mockImplementation(
|
||||
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
|
||||
);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeChecked();
|
||||
|
||||
// on clicking copy page link, the copied url should have startTime and endTime
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
|
||||
|
||||
// toggle the switch to share url with relative time
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
await user.click(toggleSwitch);
|
||||
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ interface LogsFormatOptionsMenuProps {
|
||||
config: OptionsMenuConfig;
|
||||
}
|
||||
|
||||
export default function LogsFormatOptionsMenu({
|
||||
function OptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
@@ -49,7 +49,6 @@ export default function LogsFormatOptionsMenu({
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
@@ -209,7 +208,7 @@ export default function LogsFormatOptionsMenu({
|
||||
};
|
||||
}, [selectedValue]);
|
||||
|
||||
const popoverContent = (
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'nested-menu-container',
|
||||
@@ -447,15 +446,30 @@ export default function LogsFormatOptionsMenu({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsFormatOptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
content={
|
||||
<OptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={config}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="format-options-popover"
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
@@ -465,3 +479,5 @@ export default function LogsFormatOptionsMenu({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsFormatOptionsMenu;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateFormatting.mockClear();
|
||||
});
|
||||
|
||||
function setup(): {
|
||||
getByTestId: ReturnType<typeof render>['getByTestId'];
|
||||
findItemByLabel: (label: string) => Element | undefined;
|
||||
formatOnChange: jest.Mock<any, any>;
|
||||
maxLinesOnChange: jest.Mock<any, any>;
|
||||
fontSizeOnChange: jest.Mock<any, any>;
|
||||
} {
|
||||
const items = [
|
||||
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
|
||||
{ key: 'list', label: 'Default' },
|
||||
{ key: 'table', label: 'Column', data: { title: 'columns' } },
|
||||
];
|
||||
|
||||
const formatOnChange = jest.fn();
|
||||
const maxLinesOnChange = jest.fn();
|
||||
const fontSizeOnChange = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<LogsFormatOptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat="table"
|
||||
config={{
|
||||
format: { value: 'table', onChange: formatOnChange },
|
||||
maxLines: { value: 2, onChange: maxLinesOnChange },
|
||||
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
|
||||
addColumn: {
|
||||
isFetching: false,
|
||||
value: [],
|
||||
options: [],
|
||||
onFocus: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
onSearch: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the popover menu by default for each test
|
||||
const formatButton = getByTestId('periscope-btn-format-options');
|
||||
fireEvent.click(formatButton);
|
||||
|
||||
const getMenuItems = (): Element[] =>
|
||||
Array.from(document.querySelectorAll('.menu-items .item'));
|
||||
const findItemByLabel = (label: string): Element | undefined =>
|
||||
getMenuItems().find((el) => (el.textContent || '').includes(label));
|
||||
|
||||
return {
|
||||
getByTestId,
|
||||
findItemByLabel,
|
||||
formatOnChange,
|
||||
maxLinesOnChange,
|
||||
fontSizeOnChange,
|
||||
};
|
||||
}
|
||||
|
||||
// Covers: opens menu, changes format selection, updates max-lines, changes font size
|
||||
it('opens and toggles format selection', async () => {
|
||||
const { findItemByLabel, formatOnChange } = setup();
|
||||
|
||||
// Assert initial selection
|
||||
const columnItem = findItemByLabel('Column') as Element;
|
||||
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
|
||||
expect(columnItem.querySelector('svg')).toBeInTheDocument();
|
||||
|
||||
// Change selection to 'Raw'
|
||||
const rawItem = findItemByLabel('Raw') as Element;
|
||||
fireEvent.click(rawItem as HTMLElement);
|
||||
await waitFor(() => {
|
||||
const rawEl = findItemByLabel('Raw') as Element;
|
||||
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
|
||||
expect(rawEl.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
expect(formatOnChange).toHaveBeenCalledWith('raw');
|
||||
});
|
||||
|
||||
it('increments max-lines and calls onChange', async () => {
|
||||
const { maxLinesOnChange } = setup();
|
||||
|
||||
// Increment max lines
|
||||
const input = document.querySelector(
|
||||
'.max-lines-per-row-input input',
|
||||
) as HTMLInputElement;
|
||||
const initial = Number(input.value);
|
||||
const buttons = document.querySelectorAll(
|
||||
'.max-lines-per-row-input .periscope-btn',
|
||||
);
|
||||
const incrementBtn = buttons[1] as HTMLElement;
|
||||
fireEvent.click(incrementBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Number(input.value)).toBe(initial + 1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(maxLinesOnChange).toHaveBeenCalledWith(initial + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('changes font size to MEDIUM and calls onChange', async () => {
|
||||
const { fontSizeOnChange } = setup();
|
||||
// Open font dropdown
|
||||
const fontButton = document.querySelector(
|
||||
'.font-size-container .value',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(fontButton);
|
||||
|
||||
// Choose MEDIUM
|
||||
const optionButtons = Array.from(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn'),
|
||||
);
|
||||
const mediumBtn = optionButtons[1] as HTMLElement;
|
||||
fireEvent.click(mediumBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(optionButtons[1] as Element).querySelector('.icon'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,16 +80,20 @@ const stopEventsExtension = EditorView.domEventHandlers({
|
||||
});
|
||||
|
||||
function QuerySearch({
|
||||
placeholder,
|
||||
onChange,
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
dataSource: DataSource;
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -219,6 +223,11 @@ function QuerySearch({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hardcodedAttributeKeys) {
|
||||
setKeySuggestions(hardcodedAttributeKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
@@ -254,6 +263,7 @@ function QuerySearch({
|
||||
toggleSuggestions,
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1336,7 +1346,7 @@ function QuerySearch({
|
||||
]),
|
||||
),
|
||||
]}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
}}
|
||||
@@ -1483,6 +1493,9 @@ function QuerySearch({
|
||||
QuerySearch.defaultProps = {
|
||||
onRun: undefined,
|
||||
signalSource: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable import/named */
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): { selectedDashboard: undefined } => ({
|
||||
selectedDashboard: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }),
|
||||
handleRunQuery,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@codemirror/autocomplete', () => ({
|
||||
autocompletion: (): Record<string, unknown> => ({}),
|
||||
closeCompletion: (): boolean => true,
|
||||
completionKeymap: [] as unknown[],
|
||||
startCompletion: (): boolean => true,
|
||||
}));
|
||||
|
||||
jest.mock('@codemirror/lang-javascript', () => ({
|
||||
javascript: (): Record<string, unknown> => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-copilot', () => ({
|
||||
copilot: {},
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-github', () => ({
|
||||
githubLight: {},
|
||||
}));
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
|
||||
jest.mock(
|
||||
'@uiw/react-codemirror',
|
||||
(): Record<string, unknown> => {
|
||||
// Minimal EditorView shape used by the component
|
||||
class EditorViewMock {}
|
||||
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
|
||||
(EditorViewMock as any).lineWrapping = {} as unknown;
|
||||
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
|
||||
|
||||
const keymap = { of: (arr: unknown) => arr } as unknown;
|
||||
const Prec = { highest: (ext: unknown) => ext } as unknown;
|
||||
|
||||
type CodeMirrorProps = {
|
||||
value?: string;
|
||||
onChange?: (v: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
onCreateEditor?: (view: unknown) => unknown;
|
||||
onUpdate?: (arg: {
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head: number } };
|
||||
doc: {
|
||||
toString: () => string;
|
||||
lineAt: (
|
||||
_pos: number,
|
||||
) => { number: number; from: number; to: number; text: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => void;
|
||||
'data-testid'?: string;
|
||||
extensions?: unknown[];
|
||||
};
|
||||
|
||||
function CodeMirrorMock({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
onCreateEditor,
|
||||
onUpdate,
|
||||
'data-testid': dataTestId,
|
||||
extensions,
|
||||
}: CodeMirrorProps): JSX.Element {
|
||||
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
|
||||
|
||||
// Provide a fake editor instance
|
||||
React.useEffect(() => {
|
||||
if (onCreateEditor) {
|
||||
onCreateEditor(new EditorViewMock() as any);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Call onUpdate whenever localValue changes to simulate cursor and doc
|
||||
React.useEffect(() => {
|
||||
if (onUpdate) {
|
||||
const text = String(localValue ?? '');
|
||||
const head = text.length;
|
||||
onUpdate({
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head } },
|
||||
doc: {
|
||||
toString: (): string => text,
|
||||
lineAt: () => ({
|
||||
number: 1,
|
||||
from: 0,
|
||||
to: text.length,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localValue]);
|
||||
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
): void => {
|
||||
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
|
||||
if (!isModEnter) return;
|
||||
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
|
||||
const flat: unknown[] = exts.flatMap((x: unknown) =>
|
||||
Array.isArray(x) ? x : [x],
|
||||
);
|
||||
const keyBindings = flat.filter(
|
||||
(x) =>
|
||||
Boolean(x) &&
|
||||
typeof x === 'object' &&
|
||||
'key' in (x as Record<string, unknown>),
|
||||
) as Array<{ key?: string; run?: () => boolean | void }>;
|
||||
keyBindings
|
||||
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
|
||||
.forEach((b) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
b.run!();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
data-testid={dataTestId || 'query-where-clause-editor'}
|
||||
placeholder={placeholder}
|
||||
value={localValue}
|
||||
onChange={(e): void => {
|
||||
setLocalValue(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: '100%', minHeight: 80 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CodeMirrorMock,
|
||||
EditorView: EditorViewMock,
|
||||
keymap,
|
||||
Prec,
|
||||
};
|
||||
},
|
||||
);
|
||||
const handleRunQueryMock = ((UseQBModule as unknown) as {
|
||||
handleRunQuery: jest.MockedFunction<() => void>;
|
||||
}).handleRunQuery;
|
||||
|
||||
const PLACEHOLDER_TEXT =
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
|
||||
const TESTID_EDITOR = 'query-where-clause-editor';
|
||||
const SAMPLE_KEY_TYPING = 'http.';
|
||||
const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
|
||||
const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
|
||||
const SAMPLE_STATUS_QUERY = " status_code = '200'";
|
||||
|
||||
describe('QuerySearch', () => {
|
||||
it('renders with placeholder', () => {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches key suggestions when typing a key (debounced)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches value suggestions when editing value context', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches key suggestions on mount for LOGS', async () => {
|
||||
jest.useFakeTimers();
|
||||
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
const lastArgs = mockedGetKeysOnMount.mock.calls[
|
||||
mockedGetKeysOnMount.mock.calls.length - 1
|
||||
]?.[0] as { signal: unknown; searchText: string };
|
||||
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls provided onRun on Mod-Enter', async () => {
|
||||
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={onRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_STATUS_QUERY);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
||||
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
|
||||
() => void
|
||||
>;
|
||||
mockedHandleRunQuery.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import {
|
||||
generatePath,
|
||||
matchPath,
|
||||
@@ -17,6 +18,7 @@ function RouteTab({
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -59,7 +61,16 @@ function RouteTab({
|
||||
defaultActiveKey={currentRoute?.key || activeKey}
|
||||
animated
|
||||
items={items}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
tabBarExtraContent={
|
||||
showRightSection && (
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading ---- TODO: remove this once follow the linting rules
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
@@ -67,6 +78,7 @@ function RouteTab({
|
||||
|
||||
RouteTab.defaultProps = {
|
||||
onChangeHandler: undefined,
|
||||
showRightSection: true,
|
||||
};
|
||||
|
||||
export default RouteTab;
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface RouteTabProps {
|
||||
activeKey: TabsProps['activeKey'];
|
||||
onChangeHandler?: (key: string) => void;
|
||||
history: History<unknown>;
|
||||
showRightSection: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import UPlot from 'uplot';
|
||||
|
||||
import { dataMatch, optionsUpdateState } from './utils';
|
||||
|
||||
// Extended uPlot interface with custom properties
|
||||
interface ExtendedUPlot extends uPlot {
|
||||
_legendScrollCleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface UplotProps {
|
||||
options: uPlot.Options;
|
||||
data: uPlot.AlignedData;
|
||||
@@ -66,6 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
|
||||
|
||||
const destroy = useCallback((chart: uPlot | null) => {
|
||||
if (chart) {
|
||||
// Clean up legend scroll event listener
|
||||
const extendedChart = chart as ExtendedUPlot;
|
||||
if (extendedChart._legendScrollCleanup) {
|
||||
extendedChart._legendScrollCleanup();
|
||||
}
|
||||
|
||||
onDeleteRef.current?.(chart);
|
||||
chart.destroy();
|
||||
chartRef.current = null;
|
||||
|
||||
@@ -125,7 +125,7 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
|
||||
log10: {
|
||||
showInput: false,
|
||||
},
|
||||
cumSum: {
|
||||
cumulativeSum: {
|
||||
showInput: false,
|
||||
},
|
||||
ewma3: {
|
||||
|
||||
@@ -86,4 +86,7 @@ export const REACT_QUERY_KEY = {
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
|
||||
// Routing Policies Query Keys
|
||||
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
|
||||
} as const;
|
||||
|
||||
@@ -482,7 +482,7 @@ function AllErrors(): JSX.Element {
|
||||
pagination={{
|
||||
pageSize: getUpdatedPageSize,
|
||||
responsive: true,
|
||||
current: getUpdatedOffset / 10 + 1,
|
||||
current: Math.floor(getUpdatedOffset / getUpdatedPageSize) + 1,
|
||||
position: ['bottomLeft'],
|
||||
total: errorCountResponse.data?.payload || 0,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
@@ -137,4 +137,70 @@ describe('Exceptions - All Errors', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('pagination edge cases', () => {
|
||||
it('should navigate to page 2 when pageSize=100 and clicking next', async () => {
|
||||
// Arrange: start with pageSize=100 and offset=0
|
||||
render(
|
||||
<Exceptions
|
||||
initUrl={[
|
||||
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await screen.findByText(/redis timeout/i);
|
||||
|
||||
const nextPageItem = screen.getByTitle('Next Page');
|
||||
const nextPageButton = nextPageItem.querySelector(
|
||||
'button',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(nextPageButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
expect(qp.get('offset')).toBe('100');
|
||||
});
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
expect(queryParams.get('pageSize')).toBe('100');
|
||||
expect(queryParams.get('offset')).toBe('100');
|
||||
});
|
||||
|
||||
it('initializes current page from URL (offset/pageSize)', async () => {
|
||||
// offset=100, pageSize=100 => current page should be 2
|
||||
render(
|
||||
<Exceptions
|
||||
initUrl={[
|
||||
`/exceptions?pageSize=100&offset=100&order=ascending&orderParam=serviceName`,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(/redis timeout/i);
|
||||
const activeItem = document.querySelector('.ant-pagination-item-active');
|
||||
expect(activeItem?.textContent).toBe('2');
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
expect(qp.get('pageSize')).toBe('100');
|
||||
expect(qp.get('offset')).toBe('100');
|
||||
});
|
||||
|
||||
it('clicking a numbered page updates offset correctly', async () => {
|
||||
// pageSize=100, click page 3 => offset = 200
|
||||
render(
|
||||
<Exceptions
|
||||
initUrl={[
|
||||
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(/redis timeout/i);
|
||||
const page3Item = screen.getByTitle('3');
|
||||
const page3Anchor = page3Item.querySelector('a') as HTMLAnchorElement;
|
||||
fireEvent.click(page3Anchor);
|
||||
await waitFor(() => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
expect(qp.get('offset')).toBe('200');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,9 +157,12 @@ function DomainDetails({
|
||||
<div className="domain-details-drawer-header">
|
||||
<div className="domain-details-drawer-header-title">
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">
|
||||
{domainData.domainName}
|
||||
</Typography.Text>
|
||||
|
||||
{domainData?.domainName && (
|
||||
<Typography.Text className="title">
|
||||
{domainData.domainName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="domain-details-drawer-header-right-container">
|
||||
<DateTimeSelectionV2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
@@ -248,4 +249,4 @@ function TopErrors({
|
||||
);
|
||||
}
|
||||
|
||||
export default TopErrors;
|
||||
export default withErrorBoundary(TopErrors);
|
||||
|
||||
@@ -2,36 +2,29 @@ import '../Explorer.styles.scss';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table, Typography } from 'antd';
|
||||
import axios from 'api';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
|
||||
import { get } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
import {
|
||||
columnsConfig,
|
||||
formatDataForTable,
|
||||
hardcodedAttributeKeys,
|
||||
} from '../../utils';
|
||||
import { columnsConfig, formatDataForTable } from '../../utils';
|
||||
import DomainDetails from './DomainDetails/DomainDetails';
|
||||
|
||||
function DomainList(): JSX.Element {
|
||||
@@ -53,6 +46,21 @@ function DomainList(): JSX.Element {
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const { data, isLoading, isFetching } = useListOverview({
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
show_ip: Boolean(showIP),
|
||||
filter: {
|
||||
expression: `kind_string = 'Client' ${get(
|
||||
compositeData,
|
||||
'builder.queryData[0].filter.expression',
|
||||
'',
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
|
||||
// initialise tab with default query.
|
||||
useShareBuilderUrl({
|
||||
defaultValue: {
|
||||
@@ -74,63 +82,21 @@ function DomainList(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const handleChangeTagFilters = useCallback(
|
||||
(value: IBuilderQuery['filters']) => {
|
||||
handleChangeQueryData('filters', value);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
(handleChangeQueryData as HandleChangeQueryDataV5)('filter', {
|
||||
expression: value,
|
||||
});
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const fetchApiOverview = async (): Promise<
|
||||
SuccessResponse<any> | ErrorResponse
|
||||
> => {
|
||||
const requestBody = {
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
show_ip: showIP,
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: '212678b9',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
...(compositeData?.builder?.queryData[0]?.filters?.items || []),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/third-party-apis/overview/list',
|
||||
requestBody,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery(
|
||||
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, compositeData, showIP],
|
||||
fetchApiOverview,
|
||||
);
|
||||
|
||||
const formattedDataForTable = useMemo(
|
||||
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
|
||||
() =>
|
||||
formatDataForTable(
|
||||
data?.data?.data?.data.results[0]?.data || [],
|
||||
data?.data?.data?.data.results[0]?.columns || [],
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -150,13 +116,13 @@ function DomainList(): JSX.Element {
|
||||
showAutoRefresh={false}
|
||||
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
|
||||
/>
|
||||
{/* add bottom border here */}
|
||||
<div className={cx('api-monitoring-list-header')}>
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
placeholder="Search filters..."
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
<QuerySearch
|
||||
dataSource={DataSource.TRACES}
|
||||
queryData={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
endPointStatusCodeColumns,
|
||||
extractPortAndEndpoint,
|
||||
formatDataForTable,
|
||||
formatTopErrorsDataForTable,
|
||||
getAllEndpointsWidgetData,
|
||||
getCustomFiltersForBarChart,
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from './utils';
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
// Mock or define DataTypes since it seems to be missing from imports
|
||||
const DataTypes = {
|
||||
@@ -34,9 +36,9 @@ const DataTypes = {
|
||||
};
|
||||
|
||||
// Mock the external utils dependencies that are used within our tested functions
|
||||
jest.mock('./utils', () => {
|
||||
jest.mock('../utils', () => {
|
||||
// Import the actual module to partial mock
|
||||
const originalModule = jest.requireActual('./utils');
|
||||
const originalModule = jest.requireActual('../utils');
|
||||
|
||||
// Return a mocked version
|
||||
return {
|
||||
@@ -157,6 +159,54 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for formatDataForTable
|
||||
describe('formatDataForTable', () => {
|
||||
it('should format rows correctly with valid data', () => {
|
||||
const columns = APIMonitoringColumnsMock;
|
||||
const data = [
|
||||
[
|
||||
'test-domain', // domainName
|
||||
'10', // endpoints
|
||||
'25', // rps
|
||||
'2.5', // error_rate
|
||||
'15000000', // p99 (ns) -> 15 ms
|
||||
'2025-09-17T12:54:17.040Z', // lastseen
|
||||
],
|
||||
];
|
||||
|
||||
const result = formatDataForTable(data as any, columns as any);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].domainName).toBe('test-domain');
|
||||
expect(result[0].endpointCount).toBe('10');
|
||||
expect(result[0].rate).toBe('25');
|
||||
expect(result[0].errorRate).toBe('2.5');
|
||||
expect(result[0].latency).toBe(15);
|
||||
expect(result[0].lastUsed).toBe('2025-09-17T12:54:17.040Z');
|
||||
});
|
||||
|
||||
it('should handle n/a and undefined values', () => {
|
||||
const columns = APIMonitoringColumnsMock;
|
||||
const data = [
|
||||
[
|
||||
'test-domain',
|
||||
'n/a', // endpoints -> 0
|
||||
'n/a', // rps -> '-'
|
||||
'n/a', // error_rate -> 0
|
||||
'n/a', // p99 -> '-'
|
||||
'n/a', // lastseen -> '-'
|
||||
],
|
||||
];
|
||||
|
||||
const result = formatDataForTable(data as any, columns as any);
|
||||
|
||||
expect(result[0].endpointCount).toBe(0);
|
||||
expect(result[0].rate).toBe('-');
|
||||
expect(result[0].errorRate).toBe(0);
|
||||
expect(result[0].latency).toBe('-');
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
describe('getGroupByFiltersFromGroupByValues', () => {
|
||||
it('should convert row data to filters correctly', () => {
|
||||
// Arrange
|
||||
@@ -1288,7 +1338,7 @@ describe('API Monitoring Utils', () => {
|
||||
// Setup a mock
|
||||
jest
|
||||
.spyOn(
|
||||
jest.requireActual('./utils'),
|
||||
jest.requireActual('../utils'),
|
||||
'getFormattedEndPointStatusCodeChartData',
|
||||
)
|
||||
.mockReturnValue({
|
||||
65
frontend/src/container/ApiMonitoring/__tests__/mock.ts
Normal file
65
frontend/src/container/ApiMonitoring/__tests__/mock.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { domainNameKey } from '../constants';
|
||||
import { APIMonitoringResponseColumn } from '../types';
|
||||
|
||||
export const APIMonitoringColumnsMock: APIMonitoringResponseColumn[] = [
|
||||
{
|
||||
name: domainNameKey,
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'string',
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'endpoints',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'endpoints',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'rps',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'rps',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'error_rate',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'error_rate',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'p99',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'p99',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'lastseen',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'lastseen',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
];
|
||||
30
frontend/src/container/ApiMonitoring/constants.ts
Normal file
30
frontend/src/container/ApiMonitoring/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] = [
|
||||
{
|
||||
label: 'deployment.environment',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
{
|
||||
label: 'service.name',
|
||||
type: 'resource',
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
{
|
||||
label: 'rpc.method',
|
||||
type: 'tag',
|
||||
name: 'rpc.method',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
];
|
||||
|
||||
export const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
|
||||
39
frontend/src/container/ApiMonitoring/types.ts
Normal file
39
frontend/src/container/ApiMonitoring/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { domainNameKey } from './constants';
|
||||
|
||||
export interface APIMonitoringResponseRow {
|
||||
data: {
|
||||
endpoints: number | string;
|
||||
error_rate: number | string;
|
||||
lastseen: number | string;
|
||||
[domainNameKey]: string;
|
||||
p99: number | string;
|
||||
rps: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIMonitoringResponseColumn {
|
||||
name: string;
|
||||
signal: string;
|
||||
fieldContext: string;
|
||||
fieldDataType: string;
|
||||
queryName: string;
|
||||
aggregationIndex: number;
|
||||
meta: Record<string, any>;
|
||||
columnType: string;
|
||||
}
|
||||
|
||||
export interface EndPointsResponseRow {
|
||||
data: {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIDomainsRowData {
|
||||
key: string;
|
||||
domainName: string;
|
||||
endpointCount: number | string;
|
||||
rate: number | string;
|
||||
errorRate: number | string;
|
||||
latency: number | string;
|
||||
lastUsed: string;
|
||||
}
|
||||
@@ -32,7 +32,13 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { domainNameKey } from './constants';
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
APIDomainsRowData,
|
||||
APIMonitoringResponseColumn,
|
||||
EndPointsResponseRow,
|
||||
} from './types';
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -243,84 +249,47 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Rename this to a proper name
|
||||
export const hardcodedAttributeKeys: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'rpc.method',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
|
||||
|
||||
interface APIMonitoringResponseRow {
|
||||
data: {
|
||||
endpoints: number | string;
|
||||
error_rate: number | string;
|
||||
lastseen: number | string;
|
||||
[domainNameKey]: string;
|
||||
p99: number | string;
|
||||
rps: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EndPointsResponseRow {
|
||||
data: {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIDomainsRowData {
|
||||
key: string;
|
||||
domainName: string;
|
||||
endpointCount: number | string;
|
||||
rate: number | string;
|
||||
errorRate: number | string;
|
||||
latency: number | string;
|
||||
lastUsed: string;
|
||||
}
|
||||
|
||||
// Rename this to a proper name
|
||||
export const formatDataForTable = (
|
||||
data: APIMonitoringResponseRow[],
|
||||
): APIDomainsRowData[] =>
|
||||
data?.map((domain) => ({
|
||||
key: v4(),
|
||||
domainName: domain?.data[domainNameKey] || '-',
|
||||
endpointCount:
|
||||
domain?.data?.endpoints === 'n/a' || domain?.data?.endpoints === undefined
|
||||
? 0
|
||||
: domain?.data?.endpoints,
|
||||
rate:
|
||||
domain?.data?.rps === 'n/a' || domain?.data?.rps === undefined
|
||||
? '-'
|
||||
: domain?.data?.rps,
|
||||
errorRate:
|
||||
domain?.data?.error_rate === 'n/a' || domain?.data?.error_rate === undefined
|
||||
? 0
|
||||
: domain?.data?.error_rate,
|
||||
latency:
|
||||
domain?.data?.p99 === 'n/a' || domain?.data?.p99 === undefined
|
||||
? '-'
|
||||
: Math.round(Number(domain?.data?.p99) / 1000000), // Convert from nanoseconds to milliseconds
|
||||
lastUsed:
|
||||
domain?.data?.lastseen === 'n/a' || domain?.data?.lastseen === undefined
|
||||
? '-'
|
||||
: new Date(
|
||||
Math.floor(Number(domain?.data?.lastseen) / 1000000),
|
||||
).toISOString(), // Convert from nanoseconds to milliseconds
|
||||
}));
|
||||
data: string[][],
|
||||
columns: APIMonitoringResponseColumn[],
|
||||
): APIDomainsRowData[] => {
|
||||
const indexMap = columns.reduce((acc, column, index) => {
|
||||
if (column.name === domainNameKey) {
|
||||
acc[column.name] = index;
|
||||
} else {
|
||||
acc[column.queryName] = index;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return data.map((row) => {
|
||||
const rowData: APIDomainsRowData = {
|
||||
key: v4(),
|
||||
domainName: row[indexMap[domainNameKey]],
|
||||
endpointCount:
|
||||
row[indexMap.endpoints] === 'n/a' || row[indexMap.endpoints] === undefined
|
||||
? 0
|
||||
: row[indexMap.endpoints],
|
||||
rate:
|
||||
row[indexMap.rps] === 'n/a' || row[indexMap.rps] === undefined
|
||||
? '-'
|
||||
: row[indexMap.rps],
|
||||
errorRate:
|
||||
row[indexMap.error_rate] === 'n/a' || row[indexMap.error_rate] === undefined
|
||||
? 0
|
||||
: row[indexMap.error_rate],
|
||||
latency:
|
||||
row[indexMap.p99] === 'n/a' || row[indexMap.p99] === undefined
|
||||
? '-'
|
||||
: Math.round(Number(row[indexMap.p99]) / 1000000),
|
||||
lastUsed:
|
||||
row[indexMap.lastseen] === 'n/a' || row[indexMap.lastseen] === undefined
|
||||
? '-'
|
||||
: new Date(row[indexMap.lastseen]).toISOString(),
|
||||
};
|
||||
return rowData;
|
||||
});
|
||||
};
|
||||
|
||||
export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
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 ||
|
||||
@@ -24,15 +42,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 => {
|
||||
@@ -73,8 +92,27 @@ function AlertCondition(): JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
|
||||
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{showCondensedLayoutFlag ? (
|
||||
<div className="condensed-advanced-options-container">
|
||||
<AdvancedOptions />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +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 {
|
||||
@@ -17,32 +17,51 @@ import {
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
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 channels = data?.data || [];
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
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 || '',
|
||||
@@ -51,11 +70,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',
|
||||
@@ -81,8 +104,77 @@ function AlertThreshold(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
<strong>Evaluation Window.</strong>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-threshold-container">
|
||||
<div
|
||||
className={classNames('alert-threshold-container', {
|
||||
'condensed-alert-threshold-container': showCondensedLayoutFlag,
|
||||
})}
|
||||
>
|
||||
{/* Main condition sentence */}
|
||||
<div className="alert-condition-sentences">
|
||||
<div className="alert-condition-sentence">
|
||||
@@ -100,8 +192,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 => {
|
||||
@@ -110,7 +201,7 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
@@ -124,11 +215,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 <strong>Evaluation Window.</strong>
|
||||
during the {evaluationWindowContext}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,6 +235,8 @@ function AlertThreshold(): JSX.Element {
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={categorySelectOptions}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
@@ -155,6 +248,11 @@ function AlertThreshold(): JSX.Element {
|
||||
Add Threshold
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,15 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('container/CreateAlertV2/utils', () => ({
|
||||
...jest.requireActual('container/CreateAlertV2/utils'),
|
||||
showCondensedLayout: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
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 +133,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 +142,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();
|
||||
};
|
||||
|
||||
@@ -189,11 +209,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 +285,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);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -84,10 +84,13 @@
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px !important;
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
@@ -145,6 +148,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
@@ -274,4 +278,302 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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,
|
||||
.condensed-anomaly-threshold-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.condensed-advanced-options-container {
|
||||
margin-top: 16px;
|
||||
width: fit-parent;
|
||||
}
|
||||
|
||||
.condensed-evaluation-settings-container {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--bg-slate-400);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,28 +2,41 @@ 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 { CreateAlertV2Props } from './types';
|
||||
import { showCondensedLayout, 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,
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={alertType}>
|
||||
<Spinner />
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertHeader />
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</div>
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Collapse, Input, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
import EvaluationCadence from './EvaluationCadence';
|
||||
|
||||
function AdvancedOptions(): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
return (
|
||||
<div className="advanced-options-container">
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
|
||||
<EvaluationCadence />
|
||||
<AdvancedOptionItem
|
||||
title="Alert when data stops coming"
|
||||
description="Send notification if no data is received for a specified time period."
|
||||
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter tolerance limit..."
|
||||
type="number"
|
||||
style={{ width: 100 }}
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: Number(e.target.value),
|
||||
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
/>
|
||||
<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"
|
||||
description="Only trigger alert when there are enough data points to make a reliable decision."
|
||||
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter minimum datapoints..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
onToggle={(): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
|
||||
/>
|
||||
{/* 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."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter delay..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: Number(e.target.value),
|
||||
timeUnit: advancedOptions.delayEvaluation.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.delay}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: advancedOptions.delayEvaluation.delay,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.timeUnit}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/> */}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdvancedOptions;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Popover, Typography } 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 [
|
||||
isEvaluationWindowPopoverOpen,
|
||||
setIsEvaluationWindowPopoverOpen,
|
||||
] = useState(false);
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const popoverContent = (
|
||||
<Popover
|
||||
open={isEvaluationWindowPopoverOpen}
|
||||
onOpenChange={(visibility: boolean): void => {
|
||||
setIsEvaluationWindowPopoverOpen(visibility);
|
||||
}}
|
||||
content={
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
showArrow={false}
|
||||
>
|
||||
<Button>
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
<div className="evaluate-alert-conditions-button-right">
|
||||
<div className="evaluate-alert-conditions-button-right-text">
|
||||
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
|
||||
</div>
|
||||
{isEvaluationWindowPopoverOpen ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationSettings;
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import {
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
import { IEvaluationWindowDetailsProps } from '../types';
|
||||
import { getCumulativeWindowTimeframeText } from '../utils';
|
||||
|
||||
function EvaluationWindowDetails({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowDetailsProps): JSX.Element {
|
||||
const currentHourOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const currentMonthOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe === 'custom'
|
||||
) {
|
||||
return `Last ${evaluationWindow.startingAt.number} ${
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
|
||||
(option) => option.value === evaluationWindow.startingAt.unit,
|
||||
)?.label
|
||||
}`;
|
||||
}
|
||||
if (evaluationWindow.windowType === 'cumulative') {
|
||||
return getCumulativeWindowTimeframeText(evaluationWindow);
|
||||
}
|
||||
return '';
|
||||
}, [evaluationWindow]);
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe !== 'custom'
|
||||
) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isCurrentHour =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentHour';
|
||||
const isCurrentDay =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentDay';
|
||||
const isCurrentMonth =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentMonth';
|
||||
|
||||
const handleNumberChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: value,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
<Select
|
||||
options={currentHourOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<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>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
<Select
|
||||
options={currentMonthOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>VALUE</Typography.Text>
|
||||
<Input
|
||||
name="value"
|
||||
type="number"
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>UNIT</Typography.Text>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowDetails;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from '../types';
|
||||
import EvaluationWindowDetails from './EvaluationWindowDetails';
|
||||
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const {
|
||||
containerRef,
|
||||
firstItemRef,
|
||||
} = useKeyboardNavigationForEvaluationWindowPopover({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
if (sectionId === 'window-type') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
});
|
||||
} else if (sectionId === 'timeframe') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
});
|
||||
}
|
||||
},
|
||||
onEscape: (): void => {
|
||||
const triggerElement = document.querySelector(
|
||||
'[aria-haspopup="true"]',
|
||||
) as HTMLElement;
|
||||
triggerElement?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
sectionId: string,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item" data-section-id={sectionId}>
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option, index) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(option.value);
|
||||
}
|
||||
}}
|
||||
ref={index === 0 ? firstItemRef : undefined}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="evaluation-window-popover"
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
aria-label="Evaluation window options"
|
||||
>
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
'window-type',
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
'timeframe',
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,3 @@
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseKeyboardNavigationOptions {
|
||||
onSelect?: (value: string, sectionId: string) => void;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigationForEvaluationWindowPopover = ({
|
||||
onSelect,
|
||||
onEscape,
|
||||
}: UseKeyboardNavigationOptions = {}): {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
firstItemRef: React.RefObject<HTMLDivElement>;
|
||||
} => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getFocusableItems = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll(
|
||||
'.evaluation-window-content-list-item[tabindex="0"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getInteractiveElements = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
const detailsSection = containerRef.current.querySelector(
|
||||
'.evaluation-window-details',
|
||||
);
|
||||
if (!detailsSection) return [];
|
||||
|
||||
return Array.from(
|
||||
detailsSection.querySelectorAll(
|
||||
'input, select, button, [tabindex="0"], [tabindex="-1"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
return items.findIndex((item) => item === activeElement);
|
||||
}, []);
|
||||
|
||||
const navigateWithinSection = useCallback(
|
||||
(direction: 'up' | 'down'): void => {
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
let nextIndex: number;
|
||||
if (direction === 'down') {
|
||||
nextIndex = (currentIndex + 1) % items.length;
|
||||
} else {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}
|
||||
|
||||
items[nextIndex]?.focus();
|
||||
},
|
||||
[getFocusableItems, getCurrentIndex],
|
||||
);
|
||||
|
||||
const navigateToDetails = useCallback((): void => {
|
||||
const interactiveElements = getInteractiveElements();
|
||||
interactiveElements[0]?.focus();
|
||||
}, [getInteractiveElements]);
|
||||
|
||||
const navigateBackToSection = useCallback((): void => {
|
||||
const items = getFocusableItems();
|
||||
items[0]?.focus();
|
||||
}, [getFocusableItems]);
|
||||
|
||||
const navigateBetweenSections = useCallback(
|
||||
(direction: 'left' | 'right'): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInDetails = activeElement?.closest('.evaluation-window-details');
|
||||
|
||||
if (isInDetails && direction === 'left') {
|
||||
navigateBackToSection();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
const DATA_ATTR = 'data-section-id';
|
||||
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
|
||||
|
||||
if (currentSectionId === 'window-type' && direction === 'right') {
|
||||
const timeframeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
|
||||
);
|
||||
timeframeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'left') {
|
||||
const windowTypeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
|
||||
);
|
||||
windowTypeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'right') {
|
||||
navigateToDetails();
|
||||
}
|
||||
},
|
||||
[
|
||||
navigateBackToSection,
|
||||
navigateToDetails,
|
||||
getFocusableItems,
|
||||
getCurrentIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSelection = useCallback((): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (!activeElement || !onSelect) return;
|
||||
|
||||
const value = activeElement.getAttribute('data-value');
|
||||
const sectionId = activeElement.getAttribute('data-section-id');
|
||||
|
||||
if (value && sectionId) {
|
||||
onSelect(value, sectionId);
|
||||
}
|
||||
}, [onSelect]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('up');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('right');
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
handleSelection();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onEscape?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) | undefined => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (firstItemRef.current) {
|
||||
firstItemRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
|
||||
};
|
||||
};
|
||||
@@ -49,3 +49,40 @@
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.time-input-container {
|
||||
.time-input-field {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-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);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
|
||||
import AdvancedOptions from '../AdvancedOptions';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
|
||||
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
|
||||
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
|
||||
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
|
||||
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
|
||||
|
||||
describe('AdvancedOptions', () => {
|
||||
it('should render evaluation cadence and the advanced options minimized by default', () => {
|
||||
render(<AdvancedOptions />);
|
||||
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_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', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
expect(
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_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);
|
||||
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minimum data required')).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', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const alertWhenDataStopsComingContainer = screen
|
||||
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(alertWhenDataStopsComingSwitch);
|
||||
|
||||
const toleranceInput = screen.getByPlaceholderText(
|
||||
'Enter tolerance limit...',
|
||||
);
|
||||
fireEvent.change(toleranceInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('"Minimum data required" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const minimumDataRequiredContainer = screen
|
||||
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(minimumDataRequiredSwitch);
|
||||
|
||||
const minimumDataRequiredInput = screen.getByPlaceholderText(
|
||||
'Enter minimum datapoints...',
|
||||
);
|
||||
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('"Account for data delay" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const accountForDataDelayContainer = screen
|
||||
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(accountForDataDelaySwitch);
|
||||
|
||||
const delayInput = screen.getByPlaceholderText('Enter delay...');
|
||||
fireEvent.change(delayInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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'),
|
||||
showCondensedLayout: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setEvaluationWindow: mockSetEvaluationWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import EvaluationWindowDetails from '../EvaluationWindowPopover/EvaluationWindowDetails';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
describe('EvaluationWindowDetails', () => {
|
||||
it('should render the evaluation window details for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current hour, starting at minute 1 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current day, starting from 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current month, starting from day 1 at 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to change the value in rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueInput = screen.getByPlaceholderText('Enter value');
|
||||
fireEvent.change(valueInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: '10' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectComponent = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: 10,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeInputs = screen.getAllByDisplayValue('00');
|
||||
const hoursInput = timeInputs[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
const daySelectComponent = comboboxes[0];
|
||||
fireEvent.mouseDown(daySelectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from '../constants';
|
||||
import EvaluationWindowPopover from '../EvaluationWindowPopover';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindow: EvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
const EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS =
|
||||
'.evaluation-window-content-list-item';
|
||||
const EVALUATION_WINDOW_DETAILS_TEST_ID = 'evaluation-window-details';
|
||||
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
|
||||
const EVALUATION_WINDOW_TEXT = 'EVALUATION WINDOW';
|
||||
const LAST_5_MINUTES_TEXT = 'Last 5 minutes';
|
||||
|
||||
jest.mock('../EvaluationWindowPopover/EvaluationWindowDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid={EVALUATION_WINDOW_DETAILS_TEST_ID}>
|
||||
<input placeholder={ENTER_VALUE_PLACEHOLDER} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('EvaluationWindowPopover', () => {
|
||||
it('should render the evaluation window popover with 3 sections', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all window type options with rolling selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).toHaveClass('active');
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all window type options with cumulative selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).toHaveClass('active');
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const last5MinutesItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(last5MinutesItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in cumulative mode with current hour selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const currentHourItem = screen
|
||||
.getByText('Current hour')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(currentHourItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('renders help text in details section for rolling mode with non-custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
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),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
timeframe: 'custom',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for cumulative mode', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Cumulative Window has a fixed starting point and expands over time.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should navigate down through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowDown' });
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(cumulativeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate up through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate right from window type to timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowRight' });
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(timeframeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate left from timeframe to window type', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
timeframeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should select option with Enter key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'Enter' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
|
||||
it('should select option with Space key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: ' ' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,12 @@ import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { ICreateAlertContextProps } from 'container/CreateAlertV2/context/types';
|
||||
import {
|
||||
EvaluationWindowState,
|
||||
ICreateAlertContextProps,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
export const createMockAlertContextState = (
|
||||
@@ -20,5 +24,22 @@ export const createMockAlertContextState = (
|
||||
setAdvancedOptions: jest.fn(),
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
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,
|
||||
});
|
||||
|
||||
export const createMockEvaluationWindowState = (
|
||||
overrides?: Partial<EvaluationWindowState>,
|
||||
): EvaluationWindowState => ({
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
@@ -60,3 +61,88 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
// 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.`;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user