Compare commits
14 Commits
some-edits
...
chore/norm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3449db63 | ||
|
|
cbb24d9a34 | ||
|
|
9ffe0d8143 | ||
|
|
1a1ef5aff8 | ||
|
|
8b21ba5db9 | ||
|
|
1b818dd05d | ||
|
|
3c3641493e | ||
|
|
411414fa45 | ||
|
|
735b90722d | ||
|
|
8b485de584 | ||
|
|
d595dcc222 | ||
|
|
7ddaa84387 | ||
|
|
6d5f0adab9 | ||
|
|
2c19f0171f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -230,6 +230,6 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
frontend/.cursor/rules/
|
||||
# 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.96.0
|
||||
image: signoz/signoz:v0.96.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.96.0
|
||||
image: signoz/signoz:v0.96.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.96.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.96.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -387,6 +387,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()
|
||||
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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,9 +1,14 @@
|
||||
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';
|
||||
@@ -17,6 +22,16 @@ 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 ||
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
@@ -27,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 => {
|
||||
@@ -76,8 +92,22 @@ 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 />
|
||||
|
||||
@@ -2,14 +2,10 @@ import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
@@ -22,33 +18,48 @@ import {
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { UpdateThreshold } from './types';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
getMatchTypeTooltip,
|
||||
getQueryNames,
|
||||
RoutingPolicyBanner,
|
||||
} from './utils';
|
||||
|
||||
function AlertThreshold(): JSX.Element {
|
||||
function AlertThreshold({
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
}: AnomalyAndThresholdProps): JSX.Element {
|
||||
const {
|
||||
alertState,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
const { data, isLoading: isLoadingChannels } = useQuery<
|
||||
SuccessResponseV2<Channels[]>,
|
||||
APIError
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
const channels = data?.data || [];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const queryNames = getQueryNames(currentQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
queryNames.length > 0 &&
|
||||
!queryNames.some((query) => query.value === thresholdState.selectedQuery)
|
||||
) {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: queryNames[0].value,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryNames, thresholdState.selectedQuery]);
|
||||
|
||||
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
|
||||
const categorySelectOptions = getCategorySelectOptionByName(
|
||||
selectedCategory || '',
|
||||
@@ -87,6 +98,35 @@ 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,
|
||||
@@ -109,6 +149,7 @@ function AlertThreshold(): JSX.Element {
|
||||
mouseEnterDelay={0.2}
|
||||
trigger={['hover', 'click']}
|
||||
destroyTooltipOnHide={false}
|
||||
onOpenChange={onTooltipOpenChange}
|
||||
>
|
||||
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
|
||||
</Tooltip>
|
||||
@@ -188,6 +229,8 @@ function AlertThreshold(): JSX.Element {
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={categorySelectOptions}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
@@ -199,6 +242,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,10 +1,12 @@
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX, Trash } from 'lucide-react';
|
||||
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,
|
||||
@@ -13,8 +15,12 @@ function ThresholdItem({
|
||||
showRemoveButton,
|
||||
channels,
|
||||
units,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
isLoadingChannels,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { thresholdState } = useCreateAlertState();
|
||||
const { user } = useAppContext();
|
||||
const { thresholdState, notificationSettings } = useCreateAlertState();
|
||||
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
|
||||
|
||||
const yAxisUnitSelect = useMemo(() => {
|
||||
@@ -63,11 +69,10 @@ function ThresholdItem({
|
||||
}
|
||||
};
|
||||
|
||||
const addRecoveryThreshold = (): void => {
|
||||
// Recovery threshold - hidden for now
|
||||
// setShowRecoveryThreshold(true);
|
||||
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
|
||||
};
|
||||
// const addRecoveryThreshold = (): void => {
|
||||
// setShowRecoveryThreshold(true);
|
||||
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
|
||||
// };
|
||||
|
||||
const removeRecoveryThreshold = (): void => {
|
||||
setShowRecoveryThreshold(false);
|
||||
@@ -106,31 +111,42 @@ function ThresholdItem({
|
||||
type="number"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
<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.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
|
||||
}
|
||||
/>
|
||||
{/* Recovery threshold - hidden for now */}
|
||||
{/* {showRecoveryThreshold && (
|
||||
{!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
|
||||
@@ -151,8 +167,9 @@ function ThresholdItem({
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)} */}
|
||||
)}
|
||||
<Button.Group>
|
||||
{/* TODO: Add recovery threshold back once the functionality is implemented */}
|
||||
{/* {!showRecoveryThreshold && (
|
||||
<Tooltip title="Add recovery threshold">
|
||||
<Button
|
||||
|
||||
@@ -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,6 +108,11 @@ 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',
|
||||
@@ -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>,
|
||||
@@ -192,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);
|
||||
@@ -268,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,16 +2,17 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
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 { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
import { ThresholdItemProps } from '../types';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@@ -26,12 +27,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,
|
||||
}),
|
||||
);
|
||||
|
||||
const TEST_CONSTANTS = {
|
||||
THRESHOLD_ID: 'test-threshold-1',
|
||||
@@ -81,6 +82,8 @@ const defaultProps: ThresholdItemProps = {
|
||||
channels: mockChannels,
|
||||
isLoadingChannels: false,
|
||||
units: mockUnits,
|
||||
isErrorChannels: false,
|
||||
refreshChannels: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThresholdItem = (
|
||||
@@ -99,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(
|
||||
@@ -154,14 +158,6 @@ describe('ThresholdItem', () => {
|
||||
expect(screen.getByText('Bytes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channels selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates threshold label when label input changes', () => {
|
||||
const updateThreshold = jest.fn();
|
||||
renderThresholdItem({ updateThreshold });
|
||||
@@ -233,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)
|
||||
@@ -280,7 +269,8 @@ describe('ThresholdItem', () => {
|
||||
).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 });
|
||||
|
||||
@@ -313,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_TRUNCATED),
|
||||
).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,
|
||||
@@ -373,9 +347,9 @@ 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,
|
||||
|
||||
@@ -278,6 +278,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-info-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
background-color: #4568dc1a;
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
padding: 8px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anomaly-threshold-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
@@ -471,15 +494,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
.add-threshold-btn,
|
||||
.ant-btn.add-threshold-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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;
|
||||
@@ -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,13 +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(
|
||||
@@ -348,3 +354,60 @@ export const getMatchTypeTooltip = (
|
||||
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"
|
||||
|
||||
@@ -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,7 +61,9 @@ describe('CreateAlertHeader', () => {
|
||||
|
||||
it('renders name input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
|
||||
);
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -51,10 +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('renders the header with title when isEditMode is true', () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
isEditMode
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
initialAlertState={getCreateAlertLocalStateFromAlertDef(
|
||||
defaultPostableAlertRuleV2,
|
||||
)}
|
||||
>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
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 {
|
||||
@@ -155,16 +153,6 @@
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
|
||||
&__tab-bar {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#f5f5f5,
|
||||
#f5f5f5 10px,
|
||||
#e5e5e5 10px,
|
||||
#e5e5e5 20px
|
||||
);
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
@@ -187,10 +175,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
$top-nav-background-1: #0f0f0f;
|
||||
$top-nav-background-2: #101010;
|
||||
|
||||
$top-nav-background-1-light: #f5f5f5;
|
||||
$top-nav-background-2-light: #e5e5e5;
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$top-nav-background-1-light,
|
||||
$top-nav-background-1-light 10px,
|
||||
$top-nav-background-2-light 10px,
|
||||
$top-nav-background-2-light 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.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,32 @@ import './CreateAlertV2.styles.scss';
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
|
||||
import AlertCondition from './AlertCondition';
|
||||
import { CreateAlertProvider } from './context';
|
||||
import { buildInitialAlertDef } from './context/utils';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import Footer from './Footer';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
import { CreateAlertV2Props } from './types';
|
||||
import { 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 (
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={alertType}>
|
||||
<Spinner />
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertHeader />
|
||||
<QuerySection />
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import { Collapse, Input, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
@@ -8,10 +7,6 @@ import EvaluationCadence from './EvaluationCadence';
|
||||
function AdvancedOptions(): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const timeOptions = Y_AXIS_CATEGORIES.find(
|
||||
(category) => category.name === 'Time',
|
||||
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
|
||||
|
||||
return (
|
||||
<div className="advanced-options-container">
|
||||
<Collapse bordered={false}>
|
||||
@@ -38,24 +33,16 @@ function AdvancedOptions(): JSX.Element {
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit:
|
||||
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
onToggle={(): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
@@ -80,7 +67,15 @@ function AdvancedOptions(): JSX.Element {
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
onToggle={(): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
|
||||
/>
|
||||
{/* 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."
|
||||
|
||||
@@ -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,7 +98,7 @@ function EvaluationCadence(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
{/* Add custom schedule - hidden for now */}
|
||||
{/* TODO: Add custom schedule back once the functionality is implemented */}
|
||||
{/* <Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ function EvaluationWindowDetails({
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentHour')}
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
@@ -137,7 +137,7 @@ function EvaluationWindowDetails({
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentDay')}
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
@@ -164,7 +164,7 @@ function EvaluationWindowDetails({
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentMonth')}
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
@@ -199,9 +199,7 @@ function EvaluationWindowDetails({
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(
|
||||
`${evaluationWindow.startingAt.number}${evaluationWindow.startingAt.unit}`,
|
||||
)}
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
|
||||
@@ -3,10 +3,10 @@ import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,9 +28,10 @@ describe('AdvancedOptions', () => {
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: Uncomment this when account for data delay is implemented
|
||||
// expect(
|
||||
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
// ).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to expand the advanced options', () => {
|
||||
@@ -42,9 +43,10 @@ describe('AdvancedOptions', () => {
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: Uncomment this when account for data delay is implemented
|
||||
// expect(
|
||||
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
@@ -52,7 +54,8 @@ describe('AdvancedOptions', () => {
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
|
||||
// TODO: Uncomment this when account for data delay is implemented
|
||||
// expect(screen.getByText('Account for data delay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"Alert when data stops coming" works as expected', () => {
|
||||
@@ -112,7 +115,7 @@ describe('AdvancedOptions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('"Account for data delay" works as expected', () => {
|
||||
it.skip('"Account for data delay" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,6 +6,11 @@ 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({
|
||||
|
||||
@@ -24,9 +24,12 @@ describe('EvaluationWindowDetails', () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.textContent?.includes(
|
||||
'Monitors data over a fixed time period that moves forward continuously',
|
||||
) ?? false,
|
||||
)[0],
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
|
||||
|
||||
@@ -125,9 +125,12 @@ describe('EvaluationWindowPopover', () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.textContent?.includes(
|
||||
'Monitors data over a fixed time period that moves forward continuously',
|
||||
) ?? false,
|
||||
)[0],
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
|
||||
@@ -27,6 +27,13 @@ export const createMockAlertContextState = (
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -62,9 +62,7 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export const getCumulativeWindowDescription = (
|
||||
timeframe?: string,
|
||||
): string => {
|
||||
export const getCumulativeWindowDescription = (timeframe?: string): string => {
|
||||
let example = '';
|
||||
switch (timeframe) {
|
||||
case 'currentHour':
|
||||
@@ -80,12 +78,12 @@ export const getCumulativeWindowDescription = (
|
||||
'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 =
|
||||
'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).';
|
||||
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';
|
||||
@@ -93,45 +91,58 @@ export const getRollingWindowDescription = (duration?: string): string => {
|
||||
if (duration) {
|
||||
const match = duration.match(/^(\d+)([mhs])/);
|
||||
if (match) {
|
||||
const value = parseInt(match[1]);
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 'm' && !isNaN(value)) {
|
||||
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' && !isNaN(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' && !isNaN(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')}`;
|
||||
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' || !duration) {
|
||||
} 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);
|
||||
if (!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);
|
||||
if (!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`;
|
||||
}
|
||||
} 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.`;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -209,6 +209,16 @@
|
||||
|
||||
.ant-select {
|
||||
width: 40px;
|
||||
|
||||
.ant-select-selector {
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +241,14 @@
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -379,6 +397,14 @@
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps {
|
||||
input: JSX.Element;
|
||||
tooltipText?: string;
|
||||
onToggle?: () => void;
|
||||
defaultShowInput: boolean;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
|
||||
@@ -1,37 +1,190 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Typography } from 'antd';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Check, Send, X } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
const { discardAlertRule } = useCreateAlertState();
|
||||
const {
|
||||
alertType,
|
||||
alertState: basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
updateAlertRule,
|
||||
isUpdatingAlertRule,
|
||||
isEditMode,
|
||||
} = useCreateAlertState();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleDiscard = (): void => discardAlertRule();
|
||||
|
||||
const handleTestNotification = (): void => {
|
||||
// TODO: Implement test notification
|
||||
const handleDiscard = (): void => {
|
||||
discardAlertRule();
|
||||
safeNavigate('/alerts');
|
||||
};
|
||||
|
||||
const handleSaveAlert = (): void => {
|
||||
// TODO: Implement save alert
|
||||
};
|
||||
const alertValidationMessage = useMemo(
|
||||
() =>
|
||||
validateCreateAlertState({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
query: currentQuery,
|
||||
}),
|
||||
[
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
query: currentQuery,
|
||||
});
|
||||
testAlertRule(payload, {
|
||||
onSuccess: (response) => {
|
||||
if (response.payload?.data?.alertCount === 0) {
|
||||
toast.error(
|
||||
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success('Test notification sent successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
testAlertRule,
|
||||
]);
|
||||
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
query: currentQuery,
|
||||
});
|
||||
if (isEditMode) {
|
||||
updateAlertRule(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createAlertRule(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Alert rule created successfully');
|
||||
safeNavigate('/alerts');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
isEditMode,
|
||||
updateAlertRule,
|
||||
createAlertRule,
|
||||
safeNavigate,
|
||||
]);
|
||||
|
||||
const disableButtons =
|
||||
isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule;
|
||||
|
||||
const saveAlertButton = useMemo(() => {
|
||||
let button = (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
>
|
||||
<Check size={14} />
|
||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [alertValidationMessage, disableButtons, handleSaveAlert]);
|
||||
|
||||
const testAlertButton = useMemo(() => {
|
||||
let button = (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
>
|
||||
<Send size={14} />
|
||||
<Typography.Text>Test Notification</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [alertValidationMessage, disableButtons, handleTestNotification]);
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-footer">
|
||||
<Button type="text" onClick={handleDiscard}>
|
||||
<Button type="default" onClick={handleDiscard} disabled={disableButtons}>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
<div className="button-group">
|
||||
<Button type="default" onClick={handleTestNotification}>
|
||||
<Send size={14} />
|
||||
<Typography.Text>Test Notification</Typography.Text>
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSaveAlert}>
|
||||
<Check size={14} />
|
||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||
</Button>
|
||||
{testAlertButton}
|
||||
{saveAlertButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
|
||||
import * as createAlertState from '../../context';
|
||||
import Footer from '../Footer';
|
||||
|
||||
// Mock the hooks used by Footer component
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateAlertRule = jest.fn();
|
||||
const mockTestAlertRule = jest.fn();
|
||||
const mockUpdateAlertRule = jest.fn();
|
||||
const mockDiscardAlertRule = jest.fn();
|
||||
|
||||
// Import the mocked hooks
|
||||
const { useQueryBuilder } = jest.requireMock(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
const { useSafeNavigate } = jest.requireMock('hooks/useSafeNavigate');
|
||||
|
||||
const mockAlertContextState = createMockAlertContextState({
|
||||
createAlertRule: mockCreateAlertRule,
|
||||
testAlertRule: mockTestAlertRule,
|
||||
updateAlertRule: mockUpdateAlertRule,
|
||||
discardAlertRule: mockDiscardAlertRule,
|
||||
alertState: {
|
||||
name: 'Test Alert',
|
||||
labels: {},
|
||||
yAxisUnit: undefined,
|
||||
},
|
||||
thresholdState: {
|
||||
selectedQuery: 'A',
|
||||
operator: AlertThresholdOperator.ABOVE_BELOW,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
evaluationWindow: '5m0s',
|
||||
algorithm: 'standard',
|
||||
seasonality: 'hourly',
|
||||
thresholds: [
|
||||
{
|
||||
id: '1',
|
||||
label: 'CRITICAL',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: ['test-channel'],
|
||||
color: '#ff0000',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(createAlertState, 'useCreateAlertState')
|
||||
.mockReturnValue(mockAlertContextState);
|
||||
|
||||
const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
|
||||
const TEST_NOTIFICATION_TEXT = 'Test Notification';
|
||||
const DISCARD_TEXT = 'Discard';
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
queryType: 'builder',
|
||||
},
|
||||
});
|
||||
|
||||
useSafeNavigate.mockReturnValue({
|
||||
safeNavigate: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the component with 3 buttons', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discard action works correctly', () => {
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(DISCARD_TEXT));
|
||||
expect(mockDiscardAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save alert rule action works correctly', () => {
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
|
||||
expect(mockCreateAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update alert rule action works correctly', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isEditMode: true,
|
||||
});
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
|
||||
expect(mockUpdateAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('test notification action works correctly', () => {
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
|
||||
expect(mockTestAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('all buttons are disabled when creating alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isCreatingAlertRule: true,
|
||||
});
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('all buttons are disabled when updating alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isUpdatingAlertRule: true,
|
||||
});
|
||||
render(<Footer />);
|
||||
|
||||
// Target the button elements directly instead of the text spans inside them
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('all buttons are disabled when testing alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isTestingAlertRule: true,
|
||||
});
|
||||
render(<Footer />);
|
||||
|
||||
// Target the button elements directly instead of the text spans inside them
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('create and test buttons are disabled when alert name is missing', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
alertState: {
|
||||
...mockAlertContextState.alertState,
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('create and test buttons are disabled when notifcation channels are missing and routing policies are disabled', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
notificationSettings: {
|
||||
...mockAlertContextState.notificationSettings,
|
||||
routingPolicies: false,
|
||||
},
|
||||
thresholdState: {
|
||||
...mockAlertContextState.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...mockAlertContextState.thresholdState.thresholds[0],
|
||||
channels: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('buttons are enabled even with no notification channels when routing policies are enabled', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
notificationSettings: {
|
||||
...mockAlertContextState.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
thresholdState: {
|
||||
...mockAlertContextState.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...mockAlertContextState.thresholdState.thresholds[0],
|
||||
channels: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /test notification/i }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
getEvaluationProps,
|
||||
getFormattedTimeValue,
|
||||
getNotificationSettingsProps,
|
||||
validateCreateAlertState,
|
||||
} from '../utils';
|
||||
|
||||
describe('Footer utils', () => {
|
||||
describe('getFormattedTimeValue', () => {
|
||||
it('for 60 seconds', () => {
|
||||
expect(getFormattedTimeValue(60, UniversalYAxisUnit.SECONDS)).toBe('60s');
|
||||
});
|
||||
it('for 60 minutes', () => {
|
||||
expect(getFormattedTimeValue(60, UniversalYAxisUnit.MINUTES)).toBe('60m');
|
||||
});
|
||||
it('for 60 hours', () => {
|
||||
expect(getFormattedTimeValue(60, UniversalYAxisUnit.HOURS)).toBe('60h');
|
||||
});
|
||||
it('for 60 days', () => {
|
||||
expect(getFormattedTimeValue(60, UniversalYAxisUnit.DAYS)).toBe('60d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCreateAlertState', () => {
|
||||
const args: BuildCreateAlertRulePayloadArgs = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
basicAlertState: INITIAL_ALERT_STATE,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
query: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
it('when alert name is not provided', () => {
|
||||
expect(validateCreateAlertState(args)).toBeDefined();
|
||||
expect(validateCreateAlertState(args)).toBe('Please enter an alert name');
|
||||
});
|
||||
|
||||
it('when threshold label is not provided', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
basicAlertState: {
|
||||
...args.basicAlertState,
|
||||
name: 'test name',
|
||||
},
|
||||
thresholdState: {
|
||||
...args.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...args.thresholdState.thresholds[0],
|
||||
label: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBeDefined();
|
||||
expect(validateCreateAlertState(currentArgs)).toBe(
|
||||
'Please enter a label for each threshold',
|
||||
);
|
||||
});
|
||||
|
||||
it('when threshold channels are not provided', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
basicAlertState: {
|
||||
...args.basicAlertState,
|
||||
name: 'test name',
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBeDefined();
|
||||
expect(validateCreateAlertState(currentArgs)).toBe(
|
||||
'Please select at least one channel for each threshold or enable routing policies',
|
||||
);
|
||||
});
|
||||
|
||||
it('when threshold channels are not provided but routing policies are enabled', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
basicAlertState: {
|
||||
...args.basicAlertState,
|
||||
name: 'test name',
|
||||
},
|
||||
notificationSettings: {
|
||||
...args.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBeNull();
|
||||
});
|
||||
|
||||
it('when threshold channels are provided', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
basicAlertState: {
|
||||
...args.basicAlertState,
|
||||
name: 'test name',
|
||||
},
|
||||
thresholdState: {
|
||||
...args.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...args.thresholdState.thresholds[0],
|
||||
channels: ['test channel'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotificationSettingsProps', () => {
|
||||
it('when initial notification settings are provided', () => {
|
||||
const notificationSettings = INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
const props = getNotificationSettingsProps(notificationSettings);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
groupBy: [],
|
||||
renotify: {
|
||||
enabled: false,
|
||||
interval: '1m',
|
||||
alertStates: [],
|
||||
},
|
||||
usePolicy: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renotification is enabled', () => {
|
||||
const notificationSettings: NotificationSettingsState = {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: ['firing'],
|
||||
},
|
||||
};
|
||||
const props = getNotificationSettingsProps(notificationSettings);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
groupBy: [],
|
||||
renotify: {
|
||||
enabled: true,
|
||||
interval: '1m',
|
||||
alertStates: ['firing'],
|
||||
},
|
||||
usePolicy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('routing policies are enabled', () => {
|
||||
const notificationSettings: NotificationSettingsState = {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
routingPolicies: true,
|
||||
};
|
||||
const props = getNotificationSettingsProps(notificationSettings);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
groupBy: [],
|
||||
renotify: {
|
||||
enabled: false,
|
||||
interval: '1m',
|
||||
alertStates: [],
|
||||
},
|
||||
usePolicy: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('group by notifications are provided', () => {
|
||||
const notificationSettings: NotificationSettingsState = {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
multipleNotifications: ['test group'],
|
||||
};
|
||||
const props = getNotificationSettingsProps(notificationSettings);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
groupBy: ['test group'],
|
||||
renotify: {
|
||||
enabled: false,
|
||||
interval: '1m',
|
||||
alertStates: [],
|
||||
},
|
||||
usePolicy: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertOnAbsentProps', () => {
|
||||
it('when alert on absent is disabled', () => {
|
||||
const advancedOptions: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
enabled: false,
|
||||
toleranceLimit: 0,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
const props = getAlertOnAbsentProps(advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
alertOnAbsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('when alert on absent is enabled', () => {
|
||||
const advancedOptions: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
enabled: true,
|
||||
toleranceLimit: 13,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
const props = getAlertOnAbsentProps(advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
alertOnAbsent: true,
|
||||
absentFor: 13,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnforceMinimumDatapointsProps', () => {
|
||||
it('when enforce minimum datapoints is disabled', () => {
|
||||
const advancedOptions: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
enforceMinimumDatapoints: {
|
||||
enabled: false,
|
||||
minimumDatapoints: 0,
|
||||
},
|
||||
};
|
||||
const props = getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
requireMinPoints: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('when enforce minimum datapoints is enabled', () => {
|
||||
const advancedOptions: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
enforceMinimumDatapoints: {
|
||||
enabled: true,
|
||||
minimumDatapoints: 12,
|
||||
},
|
||||
};
|
||||
const props = getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
requireMinPoints: true,
|
||||
requiredNumPoints: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEvaluationProps', () => {
|
||||
const advancedOptions: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 12,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('for rolling window with non-custom timeframe', () => {
|
||||
const evaluationWindow: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
};
|
||||
const props = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '5m0s',
|
||||
frequency: '12m',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for rolling window with custom timeframe', () => {
|
||||
const evaluationWindow: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: '13',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
const props = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '13m',
|
||||
frequency: '12m',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current hour', () => {
|
||||
const evaluationWindow: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: '14',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
};
|
||||
const props = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: { type: 'hourly', minute: 14 },
|
||||
frequency: '12m',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current day', () => {
|
||||
const evaluationWindow: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
time: '15:43:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
};
|
||||
const props = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: { type: 'daily', hour: 15, minute: 43 },
|
||||
frequency: '12m',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current month', () => {
|
||||
const evaluationWindow: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
startingAt: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: '17',
|
||||
timezone: 'UTC',
|
||||
time: '16:34:00',
|
||||
},
|
||||
};
|
||||
const props = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: { type: 'monthly', day: 17, hour: 16, minute: 34 },
|
||||
frequency: '12m',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCreateThresholdAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: mockCreateAlertContextState.thresholdState,
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: mockCreateAlertContextState.alertType,
|
||||
};
|
||||
|
||||
it('verify buildCreateThresholdAlertRulePayload', () => {
|
||||
const props = buildCreateThresholdAlertRulePayload(
|
||||
INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
|
||||
);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toStrictEqual({
|
||||
alert: '',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
annotations: {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
},
|
||||
condition: {
|
||||
alertOnAbsent: false,
|
||||
compositeQuery: {
|
||||
builderQueries: undefined,
|
||||
chQueries: undefined,
|
||||
panelType: 'graph',
|
||||
promQueries: undefined,
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
aggregations: [
|
||||
{
|
||||
metricName: '',
|
||||
reduceTo: undefined,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: undefined,
|
||||
timeAggregation: 'count',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: {
|
||||
expression: '',
|
||||
},
|
||||
functions: undefined,
|
||||
groupBy: undefined,
|
||||
having: undefined,
|
||||
legend: undefined,
|
||||
limit: undefined,
|
||||
name: 'A',
|
||||
offset: undefined,
|
||||
order: undefined,
|
||||
selectFields: undefined,
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
stepInterval: null,
|
||||
},
|
||||
type: 'builder_query',
|
||||
},
|
||||
],
|
||||
queryType: 'builder',
|
||||
unit: undefined,
|
||||
},
|
||||
requireMinPoints: false,
|
||||
selectedQueryName: 'A',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
channels: [],
|
||||
matchType: '1',
|
||||
name: 'critical',
|
||||
op: '1',
|
||||
target: 0,
|
||||
targetUnit: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m',
|
||||
},
|
||||
},
|
||||
labels: {},
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: {
|
||||
enabled: false,
|
||||
interval: '1m',
|
||||
alertStates: [],
|
||||
},
|
||||
usePolicy: false,
|
||||
},
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: 'http://localhost/',
|
||||
version: 'v5',
|
||||
});
|
||||
});
|
||||
|
||||
it('verify for promql query type', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
|
||||
query: {
|
||||
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.query,
|
||||
queryType: EQueryType.PROM,
|
||||
},
|
||||
};
|
||||
const props = buildCreateThresholdAlertRulePayload(currentArgs);
|
||||
expect(props).toBeDefined();
|
||||
expect(props.condition.compositeQuery.queryType).toBe('promql');
|
||||
expect(props.ruleType).toBe('promql_rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
20
frontend/src/container/CreateAlertV2/Footer/types.ts
Normal file
20
frontend/src/container/CreateAlertV2/Footer/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
|
||||
export interface BuildCreateAlertRulePayloadArgs {
|
||||
alertType: AlertTypes;
|
||||
basicAlertState: AlertState;
|
||||
thresholdState: AlertThresholdState;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
query: Query;
|
||||
}
|
||||
345
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal file
345
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import {
|
||||
BasicThreshold,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
import { BuildCreateAlertRulePayloadArgs } from './types';
|
||||
|
||||
// Get formatted time/unit pairs for create alert api payload
|
||||
export function getFormattedTimeValue(timeValue: number, unit: string): string {
|
||||
const unitMap: Record<string, string> = {
|
||||
[UniversalYAxisUnit.SECONDS]: 's',
|
||||
[UniversalYAxisUnit.MINUTES]: 'm',
|
||||
[UniversalYAxisUnit.HOURS]: 'h',
|
||||
[UniversalYAxisUnit.DAYS]: 'd',
|
||||
};
|
||||
return `${timeValue}${unitMap[unit]}`;
|
||||
}
|
||||
|
||||
// Validate create alert api payload
|
||||
export function validateCreateAlertState(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): string | null {
|
||||
const { basicAlertState, thresholdState, notificationSettings } = args;
|
||||
|
||||
// Validate alert name
|
||||
if (!basicAlertState.name) {
|
||||
return 'Please enter an alert name';
|
||||
}
|
||||
|
||||
// Validate threshold state if routing policies is not enabled
|
||||
for (let i = 0; i < thresholdState.thresholds.length; i++) {
|
||||
const threshold = thresholdState.thresholds[i];
|
||||
if (!threshold.label) {
|
||||
return 'Please enter a label for each threshold';
|
||||
}
|
||||
if (!notificationSettings.routingPolicies && !threshold.channels.length) {
|
||||
return 'Please select at least one channel for each threshold or enable routing policies';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get notification settings props for create alert api payload
|
||||
export function getNotificationSettingsProps(
|
||||
notificationSettings: NotificationSettingsState,
|
||||
): PostableAlertRuleV2['notificationSettings'] {
|
||||
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
|
||||
groupBy: notificationSettings.multipleNotifications || [],
|
||||
usePolicy: notificationSettings.routingPolicies,
|
||||
renotify: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
interval: getFormattedTimeValue(
|
||||
notificationSettings.reNotification.value,
|
||||
notificationSettings.reNotification.unit,
|
||||
),
|
||||
alertStates: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
};
|
||||
|
||||
return notificationSettingsProps;
|
||||
}
|
||||
|
||||
// Get alert on absent props for create alert api payload
|
||||
export function getAlertOnAbsentProps(
|
||||
advancedOptions: AdvancedOptionsState,
|
||||
): Partial<PostableAlertRuleV2['condition']> {
|
||||
if (advancedOptions.sendNotificationIfDataIsMissing.enabled) {
|
||||
return {
|
||||
alertOnAbsent: true,
|
||||
absentFor: advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
|
||||
};
|
||||
}
|
||||
return {
|
||||
alertOnAbsent: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Get enforce minimum datapoints props for create alert api payload
|
||||
export function getEnforceMinimumDatapointsProps(
|
||||
advancedOptions: AdvancedOptionsState,
|
||||
): Partial<PostableAlertRuleV2['condition']> {
|
||||
if (advancedOptions.enforceMinimumDatapoints.enabled) {
|
||||
return {
|
||||
requireMinPoints: true,
|
||||
requiredNumPoints:
|
||||
advancedOptions.enforceMinimumDatapoints.minimumDatapoints,
|
||||
};
|
||||
}
|
||||
return {
|
||||
requireMinPoints: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Get evaluation props for create alert api payload
|
||||
export function getEvaluationProps(
|
||||
evaluationWindow: EvaluationWindowState,
|
||||
advancedOptions: AdvancedOptionsState,
|
||||
): PostableAlertRuleV2['evaluation'] {
|
||||
const frequency = getFormattedTimeValue(
|
||||
advancedOptions.evaluationCadence.default.value,
|
||||
advancedOptions.evaluationCadence.default.timeUnit,
|
||||
);
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe !== 'custom'
|
||||
) {
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
evalWindow: evaluationWindow.timeframe,
|
||||
frequency,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe === 'custom'
|
||||
) {
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
evalWindow: getFormattedTimeValue(
|
||||
Number(evaluationWindow.startingAt.number),
|
||||
evaluationWindow.startingAt.unit,
|
||||
),
|
||||
frequency,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Only cumulative window type left now
|
||||
if (evaluationWindow.timeframe === 'currentHour') {
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'hourly',
|
||||
minute: Number(evaluationWindow.startingAt.number),
|
||||
},
|
||||
frequency,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (evaluationWindow.timeframe === 'currentDay') {
|
||||
// time is in the format of "HH:MM:SS"
|
||||
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'daily',
|
||||
hour: Number(hour),
|
||||
minute: Number(minute),
|
||||
},
|
||||
frequency,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (evaluationWindow.timeframe === 'currentMonth') {
|
||||
// time is in the format of "HH:MM:SS"
|
||||
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'monthly',
|
||||
day: Number(evaluationWindow.startingAt.number),
|
||||
hour: Number(hour),
|
||||
minute: Number(minute),
|
||||
},
|
||||
frequency,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: evaluationWindow.windowType,
|
||||
spec: {
|
||||
evalWindow: evaluationWindow.timeframe,
|
||||
frequency,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build Create Threshold Alert Rule Payload
|
||||
export function buildCreateThresholdAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
query,
|
||||
} = args;
|
||||
|
||||
const compositeQuery = compositeQueryToQueryEnvelope({
|
||||
builderQueries: {
|
||||
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
|
||||
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
|
||||
},
|
||||
promQueries: mapQueryDataToApi(query.promql, 'name').data,
|
||||
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
|
||||
queryType: query.queryType,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
// Thresholds
|
||||
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
|
||||
(threshold) => ({
|
||||
name: threshold.label,
|
||||
target: parseFloat(threshold.thresholdValue.toString()),
|
||||
matchType: thresholdState.matchType,
|
||||
op: thresholdState.operator,
|
||||
channels: threshold.channels,
|
||||
targetUnit: threshold.unit,
|
||||
}),
|
||||
);
|
||||
|
||||
// Alert on absent data
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
|
||||
// Enforce minimum datapoints
|
||||
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
|
||||
advancedOptions,
|
||||
);
|
||||
|
||||
// Notification settings
|
||||
const notificationSettingsProps = getNotificationSettingsProps(
|
||||
notificationSettings,
|
||||
);
|
||||
|
||||
// Evaluation
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
|
||||
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
|
||||
if (query.queryType === EQueryType.PROM) {
|
||||
ruleType = 'promql_rule';
|
||||
}
|
||||
|
||||
return {
|
||||
alert: basicAlertState.name,
|
||||
ruleType,
|
||||
alertType,
|
||||
condition: {
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholds,
|
||||
},
|
||||
compositeQuery,
|
||||
selectedQueryName: thresholdState.selectedQuery,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
},
|
||||
evaluation: evaluationProps,
|
||||
labels: basicAlertState.labels,
|
||||
annotations: {
|
||||
description: notificationSettings.description,
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build Create Anomaly Alert Rule Payload
|
||||
// TODO: Update this function before enabling anomaly alert rule creation
|
||||
export function buildCreateAnomalyAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
query,
|
||||
notificationSettings,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
} = args;
|
||||
|
||||
const compositeQuery = compositeQueryToQueryEnvelope({
|
||||
builderQueries: {
|
||||
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
|
||||
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
|
||||
},
|
||||
promQueries: mapQueryDataToApi(query.promql, 'name').data,
|
||||
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
|
||||
queryType: query.queryType,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
|
||||
advancedOptions,
|
||||
);
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
const notificationSettingsProps = getNotificationSettingsProps(
|
||||
notificationSettings,
|
||||
);
|
||||
|
||||
return {
|
||||
alert: basicAlertState.name,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType,
|
||||
condition: {
|
||||
compositeQuery,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
},
|
||||
labels: basicAlertState.labels,
|
||||
annotations: {
|
||||
description: notificationSettings.description,
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@@ -10,46 +10,46 @@ function NotificationMessage(): JSX.Element {
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const templateVariables = [
|
||||
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
{
|
||||
variable: '{{value}}',
|
||||
description: 'Current value that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{threshold}}',
|
||||
description: 'Threshold value from alert condition',
|
||||
},
|
||||
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
{
|
||||
variable: '{{severity}}',
|
||||
description: 'Alert severity level (Critical, Warning, Info)',
|
||||
},
|
||||
{
|
||||
variable: '{{queryname}}',
|
||||
description: 'Name of the query that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{labels}}',
|
||||
description: 'All labels associated with the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{timestamp}}',
|
||||
description: 'Timestamp when alert was triggered',
|
||||
},
|
||||
];
|
||||
// const templateVariables = [
|
||||
// { variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
// {
|
||||
// variable: '{{value}}',
|
||||
// description: 'Current value that triggered the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{threshold}}',
|
||||
// description: 'Threshold value from alert condition',
|
||||
// },
|
||||
// { variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
// {
|
||||
// variable: '{{severity}}',
|
||||
// description: 'Alert severity level (Critical, Warning, Info)',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{queryname}}',
|
||||
// description: 'Name of the query that triggered the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{labels}}',
|
||||
// description: 'All labels associated with the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{timestamp}}',
|
||||
// description: 'Timestamp when alert was triggered',
|
||||
// },
|
||||
// ];
|
||||
|
||||
const templateVariableContent = (
|
||||
<div className="template-variable-content">
|
||||
<Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
{templateVariables.map((item) => (
|
||||
<div className="template-variable-content-item" key={item.variable}>
|
||||
<code>{item.variable}</code>
|
||||
<Typography.Text>{item.description}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// const templateVariableContent = (
|
||||
// <div className="template-variable-content">
|
||||
// <Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
// {templateVariables.map((item) => (
|
||||
// <div className="template-variable-content-item" key={item.variable}>
|
||||
// <code>{item.variable}</code>
|
||||
// <Typography.Text>{item.description}</Typography.Text>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className="notification-message-container">
|
||||
@@ -57,23 +57,24 @@ function NotificationMessage(): JSX.Element {
|
||||
<div className="notification-message-header-content">
|
||||
<Typography.Text className="notification-message-header-title">
|
||||
Notification Message
|
||||
{/* <Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
|
||||
<Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
|
||||
<Info size={16} />
|
||||
</Tooltip> */}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="notification-message-header-description">
|
||||
Custom message content for alert notifications. Use template variables to
|
||||
include dynamic information.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/* <div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
<div className="notification-message-header-actions">
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</div> */}
|
||||
</Popover> */}
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
value={notificationSettings.description}
|
||||
|
||||
@@ -103,6 +103,7 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
defaultShowInput={notificationSettings.reNotification.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,11 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('container/CreateAlertV2/utils', () => ({
|
||||
...jest.requireActual('container/CreateAlertV2/utils'),
|
||||
showCondensedLayout: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const initialNotificationSettings = createMockAlertContextState()
|
||||
.notificationSettings;
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
|
||||
@@ -84,12 +84,28 @@
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
width: 120px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-multiple {
|
||||
.ant-select-selector {
|
||||
width: 200px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +218,15 @@
|
||||
flex-shrink: 0;
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +352,15 @@
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import Stepper from '../Stepper';
|
||||
@@ -14,11 +16,20 @@ import ChartPreview from './ChartPreview';
|
||||
import { buildAlertDefForChartPreview } from './utils';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const {
|
||||
currentQuery,
|
||||
handleRunQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
|
||||
|
||||
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
|
||||
|
||||
const onQueryCategoryChange = (val: EQueryType): void => {
|
||||
const query: Query = { ...currentQuery, queryType: val };
|
||||
redirectWithQueryBuilderData(query);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Metrics',
|
||||
@@ -66,7 +77,7 @@ function QuerySection(): JSX.Element {
|
||||
</div>
|
||||
<QuerySectionComponent
|
||||
queryCategory={currentQuery.queryType}
|
||||
setQueryCategory={(): void => {}}
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType}
|
||||
runQuery={handleRunQuery}
|
||||
alertDef={alertDef}
|
||||
|
||||
@@ -134,7 +134,7 @@ const renderChartPreview = (): ReturnType<typeof render> =>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<ChartPreview alertDef={mockAlertDef} />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -104,7 +105,7 @@ const renderQuerySection = (): ReturnType<typeof render> =>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
@@ -186,6 +187,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -200,6 +202,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
|
||||
@@ -77,6 +77,14 @@
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,10 +99,14 @@
|
||||
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.y-axis-unit-selector-component {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +132,7 @@
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
background-color: var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
@@ -143,6 +155,15 @@
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
358
frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx
Normal file
358
frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import { defaultPostableAlertRuleV2 } from '../constants';
|
||||
import { INITIAL_ALERT_STATE } from '../context/constants';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from '../context/types';
|
||||
import {
|
||||
getAdvancedOptionsStateFromAlertDef,
|
||||
getColorForThreshold,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
getEvaluationWindowStateFromAlertDef,
|
||||
getNotificationSettingsStateFromAlertDef,
|
||||
getThresholdStateFromAlertDef,
|
||||
parseGoTime,
|
||||
} from '../utils';
|
||||
|
||||
describe('CreateAlertV2 utils', () => {
|
||||
describe('getColorForThreshold', () => {
|
||||
it('should return the correct color for the pre-defined threshold', () => {
|
||||
expect(getColorForThreshold('critical')).toBe(Color.BG_SAKURA_500);
|
||||
expect(getColorForThreshold('warning')).toBe(Color.BG_AMBER_500);
|
||||
expect(getColorForThreshold('info')).toBe(Color.BG_ROBIN_500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGoTime', () => {
|
||||
it('should return the correct time and unit for the given input', () => {
|
||||
expect(parseGoTime('1h')).toStrictEqual({
|
||||
time: 1,
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
});
|
||||
expect(parseGoTime('1m')).toStrictEqual({
|
||||
time: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
});
|
||||
expect(parseGoTime('1s')).toStrictEqual({
|
||||
time: 1,
|
||||
unit: UniversalYAxisUnit.SECONDS,
|
||||
});
|
||||
expect(parseGoTime('1h0m')).toStrictEqual({
|
||||
time: 1,
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEvaluationWindowStateFromAlertDef', () => {
|
||||
it('for rolling window with non-custom timeframe', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
evaluation: {
|
||||
...defaultPostableAlertRuleV2.evaluation,
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '5m0s',
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getEvaluationWindowStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
});
|
||||
});
|
||||
|
||||
it('for rolling window with custom timeframe', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
evaluation: {
|
||||
...defaultPostableAlertRuleV2.evaluation,
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '13m0s',
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getEvaluationWindowStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
number: '13',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current hour', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
evaluation: {
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'hourly',
|
||||
minute: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getEvaluationWindowStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
number: '14',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current day', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
evaluation: {
|
||||
...defaultPostableAlertRuleV2.evaluation,
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'daily',
|
||||
hour: 14,
|
||||
minute: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getEvaluationWindowStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
time: '14:15:00',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('for cumulative window with current month', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
evaluation: {
|
||||
...defaultPostableAlertRuleV2.evaluation,
|
||||
kind: 'cumulative',
|
||||
spec: {
|
||||
schedule: {
|
||||
type: 'monthly',
|
||||
day: 12,
|
||||
hour: 16,
|
||||
minute: 34,
|
||||
},
|
||||
timezone: 'UTC',
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getEvaluationWindowStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
startingAt: {
|
||||
number: '12',
|
||||
timezone: 'UTC',
|
||||
time: '16:34:00',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotificationSettingsStateFromAlertDef', () => {
|
||||
it('should return the correct notification settings state for the given alert def', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
notificationSettings: {
|
||||
groupBy: ['email'],
|
||||
renotify: {
|
||||
enabled: true,
|
||||
interval: '1m0s',
|
||||
alertStates: ['firing'],
|
||||
},
|
||||
usePolicy: true,
|
||||
},
|
||||
};
|
||||
const props = getNotificationSettingsStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
multipleNotifications: ['email'],
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: ['firing'],
|
||||
},
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
routingPolicies: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('when renotification is not provided', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
notificationSettings: {
|
||||
groupBy: ['email'],
|
||||
usePolicy: false,
|
||||
},
|
||||
};
|
||||
const props = getNotificationSettingsStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
multipleNotifications: ['email'],
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdvancedOptionsStateFromAlertDef', () => {
|
||||
it('should return the correct advanced options state for the given alert def', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
compositeQuery: {
|
||||
...defaultPostableAlertRuleV2.condition.compositeQuery,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
requiredNumPoints: 13,
|
||||
requireMinPoints: true,
|
||||
alertOnAbsent: true,
|
||||
absentFor: 12,
|
||||
},
|
||||
evaluation: {
|
||||
...defaultPostableAlertRuleV2.evaluation,
|
||||
spec: {
|
||||
frequency: '1m0s',
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getAdvancedOptionsStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
sendNotificationIfDataIsMissing: {
|
||||
enabled: true,
|
||||
toleranceLimit: 12,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
enabled: true,
|
||||
minimumDatapoints: 13,
|
||||
},
|
||||
evaluationCadence: {
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 1,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
annotations: {
|
||||
summary: 'test summary',
|
||||
description: 'test description',
|
||||
},
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: 1,
|
||||
targetUnit: UniversalYAxisUnit.MINUTES,
|
||||
channels: ['email'],
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
op: AlertThresholdOperator.IS_ABOVE,
|
||||
},
|
||||
],
|
||||
},
|
||||
selectedQueryName: 'test',
|
||||
},
|
||||
};
|
||||
const props = getThresholdStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
selectedQuery: 'test',
|
||||
operator: AlertThresholdOperator.IS_ABOVE,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
thresholds: [
|
||||
{
|
||||
id: expect.any(String),
|
||||
label: 'critical',
|
||||
thresholdValue: 1,
|
||||
recoveryThresholdValue: null,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
color: Color.BG_SAKURA_500,
|
||||
channels: ['email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCreateAlertLocalStateFromAlertDef', () => {
|
||||
it('should return the correct create alert local state for the given alert def', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
annotations: {
|
||||
summary: 'test summary',
|
||||
description: 'test description',
|
||||
},
|
||||
alert: 'test-alert',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
team: 'test-team',
|
||||
},
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
compositeQuery: {
|
||||
...defaultPostableAlertRuleV2.condition.compositeQuery,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = getCreateAlertLocalStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
basicAlertState: {
|
||||
...INITIAL_ALERT_STATE,
|
||||
name: 'test-alert',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
team: 'test-team',
|
||||
},
|
||||
yAxisUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
// as we have already verified these utils in their respective tests
|
||||
thresholdState: expect.any(Object),
|
||||
advancedOptionsState: expect.any(Object),
|
||||
evaluationWindowState: expect.any(Object),
|
||||
notificationSettingsState: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
74
frontend/src/container/CreateAlertV2/constants.ts
Normal file
74
frontend/src/container/CreateAlertV2/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
const defaultAnnotations = {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
|
||||
};
|
||||
|
||||
const defaultNotificationSettings: PostableAlertRuleV2['notificationSettings'] = {
|
||||
groupBy: [],
|
||||
renotify: {
|
||||
enabled: false,
|
||||
interval: '1m',
|
||||
alertStates: [],
|
||||
},
|
||||
usePolicy: false,
|
||||
};
|
||||
|
||||
const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m',
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
A: initialQueryBuilderFormValuesMap.metrics,
|
||||
},
|
||||
promQueries: { A: initialQueryPromQLData },
|
||||
chQueries: {
|
||||
A: {
|
||||
name: 'A',
|
||||
query: ``,
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: undefined,
|
||||
},
|
||||
selectedQueryName: 'A',
|
||||
alertOnAbsent: true,
|
||||
absentFor: 10,
|
||||
requireMinPoints: false,
|
||||
requiredNumPoints: 0,
|
||||
},
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
notificationSettings: defaultNotificationSettings,
|
||||
alert: 'TEST_ALERT',
|
||||
evaluation: defaultEvaluation,
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const INITIAL_ALERT_STATE: AlertState = {
|
||||
|
||||
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'CRITICAL',
|
||||
label: 'critical',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
@@ -37,7 +37,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
|
||||
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'WARNING',
|
||||
label: 'warning',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
@@ -47,7 +47,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
|
||||
export const INITIAL_INFO_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'INFO',
|
||||
label: 'info',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
@@ -79,9 +79,11 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 15,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
enabled: false,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
enabled: false,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 5,
|
||||
@@ -168,7 +170,6 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
|
||||
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
|
||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||
@@ -176,7 +177,7 @@ export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||
|
||||
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
||||
{ value: 'firing', label: 'Firing' },
|
||||
{ value: 'no-data', label: 'No Data' },
|
||||
{ value: 'nodata', label: 'No Data' },
|
||||
];
|
||||
|
||||
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
@@ -188,4 +189,5 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
routingPolicies: false,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
|
||||
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
|
||||
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import {
|
||||
@@ -47,7 +51,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
|
||||
export function CreateAlertProvider(
|
||||
props: ICreateAlertProviderProps,
|
||||
): JSX.Element {
|
||||
const { children } = props;
|
||||
const { children, initialAlertState, isEditMode, ruleId } = props;
|
||||
|
||||
const [alertState, setAlertState] = useReducer(
|
||||
alertCreationReducer,
|
||||
@@ -72,6 +76,10 @@ export function CreateAlertProvider(
|
||||
currentQueryToRedirect,
|
||||
{
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -107,6 +115,31 @@ export function CreateAlertProvider(
|
||||
});
|
||||
}, [alertType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
setAlertState({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.basicAlertState,
|
||||
});
|
||||
setThresholdState({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.thresholdState,
|
||||
});
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.evaluationWindowState,
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.advancedOptionsState,
|
||||
});
|
||||
setNotificationSettings({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.notificationSettingsState,
|
||||
});
|
||||
}
|
||||
}, [initialAlertState, isEditMode]);
|
||||
|
||||
const discardAlertRule = useCallback(() => {
|
||||
setAlertState({
|
||||
type: 'RESET',
|
||||
@@ -126,6 +159,21 @@ export function CreateAlertProvider(
|
||||
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
|
||||
}, [handleAlertTypeChange]);
|
||||
|
||||
const {
|
||||
mutate: createAlertRule,
|
||||
isLoading: isCreatingAlertRule,
|
||||
} = useCreateAlertRule();
|
||||
|
||||
const {
|
||||
mutate: testAlertRule,
|
||||
isLoading: isTestingAlertRule,
|
||||
} = useTestAlertRule();
|
||||
|
||||
const {
|
||||
mutate: updateAlertRule,
|
||||
isLoading: isUpdatingAlertRule,
|
||||
} = useUpdateAlertRule(ruleId || '');
|
||||
|
||||
const contextValue: ICreateAlertContextProps = useMemo(
|
||||
() => ({
|
||||
alertState,
|
||||
@@ -141,6 +189,13 @@ export function CreateAlertProvider(
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
updateAlertRule,
|
||||
isUpdatingAlertRule,
|
||||
isEditMode: isEditMode || false,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
@@ -151,6 +206,13 @@ export function CreateAlertProvider(
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
updateAlertRule,
|
||||
isUpdatingAlertRule,
|
||||
isEditMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
|
||||
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
||||
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { UseMutateFunction } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
|
||||
|
||||
export interface ICreateAlertContextProps {
|
||||
alertState: AlertState;
|
||||
setAlertState: Dispatch<CreateAlertAction>;
|
||||
@@ -16,11 +24,37 @@ export interface ICreateAlertContextProps {
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: Dispatch<NotificationSettingsAction>;
|
||||
isCreatingAlertRule: boolean;
|
||||
createAlertRule: UseMutateFunction<
|
||||
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2,
|
||||
unknown
|
||||
>;
|
||||
isTestingAlertRule: boolean;
|
||||
testAlertRule: UseMutateFunction<
|
||||
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2,
|
||||
unknown
|
||||
>;
|
||||
discardAlertRule: () => void;
|
||||
isUpdatingAlertRule: boolean;
|
||||
updateAlertRule: UseMutateFunction<
|
||||
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2,
|
||||
unknown
|
||||
>;
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialAlertType: AlertTypes;
|
||||
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
|
||||
isEditMode?: boolean;
|
||||
ruleId?: string;
|
||||
}
|
||||
|
||||
export enum AlertCreationStep {
|
||||
@@ -40,6 +74,7 @@ export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: AlertState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface Threshold {
|
||||
@@ -107,15 +142,18 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_ALGORITHM'; payload: string }
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
@@ -146,10 +184,18 @@ export type AdvancedOptionsAction =
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
@@ -168,6 +214,7 @@ export type AdvancedOptionsAction =
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface EvaluationWindowState {
|
||||
@@ -189,6 +236,7 @@ export type EvaluationWindowAction =
|
||||
payload: { time: string; number: string; timezone: string; unit: string };
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
@@ -199,9 +247,10 @@ export interface NotificationSettingsState {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
conditions: ('firing' | 'nodata')[];
|
||||
};
|
||||
description: string;
|
||||
routingPolicies: boolean;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
@@ -215,8 +264,10 @@ export type NotificationSettingsAction =
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
conditions: ('firing' | 'nodata')[];
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@@ -53,6 +53,8 @@ export const alertCreationReducer = (
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ALERT_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -94,6 +96,10 @@ export function getInitialAlertTypeFromURL(
|
||||
urlSearchParams: URLSearchParams,
|
||||
currentQuery: Query,
|
||||
): AlertTypes {
|
||||
const ruleType = urlSearchParams.get(QueryParams.ruleType);
|
||||
if (ruleType === 'anomaly_rule') {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
|
||||
return alertTypeFromURL
|
||||
? (alertTypeFromURL as AlertTypes)
|
||||
@@ -115,6 +121,8 @@ export const alertThresholdReducer = (
|
||||
return { ...state, thresholds: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_ALERT_THRESHOLD_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -126,9 +134,38 @@ export const advancedOptionsReducer = (
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||
return {
|
||||
...state,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
...state.sendNotificationIfDataIsMissing,
|
||||
toleranceLimit: action.payload.toleranceLimit,
|
||||
timeUnit: action.payload.timeUnit,
|
||||
},
|
||||
};
|
||||
case 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return {
|
||||
...state,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
...state.sendNotificationIfDataIsMissing,
|
||||
enabled: action.payload,
|
||||
},
|
||||
};
|
||||
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||
return {
|
||||
...state,
|
||||
enforceMinimumDatapoints: {
|
||||
...state.enforceMinimumDatapoints,
|
||||
minimumDatapoints: action.payload.minimumDatapoints,
|
||||
},
|
||||
};
|
||||
case 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return {
|
||||
...state,
|
||||
enforceMinimumDatapoints: {
|
||||
...state.enforceMinimumDatapoints,
|
||||
enabled: action.payload,
|
||||
},
|
||||
};
|
||||
case 'SET_DELAY_EVALUATION':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
@@ -141,6 +178,8 @@ export const advancedOptionsReducer = (
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||
};
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
case 'RESET':
|
||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
default:
|
||||
@@ -169,6 +208,8 @@ export const evaluationWindowReducer = (
|
||||
return { ...state, startingAt: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -185,8 +226,12 @@ export const notificationSettingsReducer = (
|
||||
return { ...state, reNotification: action.payload };
|
||||
case 'SET_DESCRIPTION':
|
||||
return { ...state, description: action.payload };
|
||||
case 'SET_ROUTING_POLICIES':
|
||||
return { ...state, routingPolicies: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
21
frontend/src/container/CreateAlertV2/types.ts
Normal file
21
frontend/src/container/CreateAlertV2/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from './context/types';
|
||||
|
||||
export interface CreateAlertV2Props {
|
||||
alertType: AlertTypes;
|
||||
}
|
||||
|
||||
export interface GetCreateAlertLocalStateFromAlertDefReturn {
|
||||
basicAlertState: AlertState;
|
||||
thresholdState: AlertThresholdState;
|
||||
advancedOptionsState: AdvancedOptionsState;
|
||||
evaluationWindowState: EvaluationWindowState;
|
||||
notificationSettingsState: NotificationSettingsState;
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Spin } from 'antd';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from './context';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './context/constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from './context/types';
|
||||
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
|
||||
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
|
||||
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
@@ -6,4 +35,277 @@ export const showNewCreateAlertsPage = (): boolean =>
|
||||
// Layout 1 - Default layout
|
||||
// Layout 2 - Condensed layout
|
||||
export const showCondensedLayout = (): boolean =>
|
||||
localStorage.getItem('showCondensedLayout') === 'true';
|
||||
localStorage.getItem('hideCondensedLayout') !== 'true';
|
||||
|
||||
export function Spinner(): JSX.Element | null {
|
||||
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
|
||||
|
||||
if (!isCreatingAlertRule && !isUpdatingAlertRule) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="sticky-page-spinner">
|
||||
<Spin size="large" spinning />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function getColorForThreshold(thresholdLabel: string): string {
|
||||
if (thresholdLabel === 'critical') {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (thresholdLabel === 'warning') {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
if (thresholdLabel === 'info') {
|
||||
return Color.BG_ROBIN_500;
|
||||
}
|
||||
return getRandomColor();
|
||||
}
|
||||
|
||||
export function parseGoTime(
|
||||
input: string,
|
||||
): { time: number; unit: UniversalYAxisUnit } {
|
||||
const regex = /(\d+)([hms])/g;
|
||||
const matches = [...input.matchAll(regex)];
|
||||
|
||||
const nonZero = matches.find(([, value]) => parseInt(value, 10) > 0);
|
||||
if (!nonZero) {
|
||||
return { time: 1, unit: UniversalYAxisUnit.MINUTES };
|
||||
}
|
||||
|
||||
const time = parseInt(nonZero[1], 10);
|
||||
const unitMap: Record<string, UniversalYAxisUnit> = {
|
||||
h: UniversalYAxisUnit.HOURS,
|
||||
m: UniversalYAxisUnit.MINUTES,
|
||||
s: UniversalYAxisUnit.SECONDS,
|
||||
};
|
||||
|
||||
return { time, unit: unitMap[nonZero[2]] };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function getEvaluationWindowStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): EvaluationWindowState {
|
||||
const windowType = alertDef.evaluation?.kind as 'rolling' | 'cumulative';
|
||||
|
||||
function getRollingWindowTimeframe(): string {
|
||||
if (
|
||||
// Default values for rolling window
|
||||
EVALUATION_WINDOW_TIMEFRAME.rolling
|
||||
.map((option) => option.value)
|
||||
.includes(alertDef.evaluation?.spec?.evalWindow || '')
|
||||
) {
|
||||
return alertDef.evaluation?.spec?.evalWindow || '';
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function getCumulativeWindowTimeframe(): string {
|
||||
switch (alertDef.evaluation?.spec?.schedule?.type) {
|
||||
case 'hourly':
|
||||
return 'currentHour';
|
||||
case 'daily':
|
||||
return 'currentDay';
|
||||
case 'monthly':
|
||||
return 'currentMonth';
|
||||
default:
|
||||
return 'currentHour';
|
||||
}
|
||||
}
|
||||
|
||||
function convertApiFieldToTime(hour: number, minute: number): string {
|
||||
return `${hour.toString().padStart(2, '0')}:${minute
|
||||
.toString()
|
||||
.padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function getCumulativeWindowStartingAt(): EvaluationWindowState['startingAt'] {
|
||||
const timeframe = getCumulativeWindowTimeframe();
|
||||
if (timeframe === 'currentHour') {
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: alertDef.evaluation?.spec?.schedule?.minute?.toString() || '0',
|
||||
};
|
||||
}
|
||||
if (timeframe === 'currentDay') {
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
time: convertApiFieldToTime(
|
||||
alertDef.evaluation?.spec?.schedule?.hour || 0,
|
||||
alertDef.evaluation?.spec?.schedule?.minute || 0,
|
||||
),
|
||||
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
|
||||
};
|
||||
}
|
||||
if (timeframe === 'currentMonth') {
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: alertDef.evaluation?.spec?.schedule?.day?.toString() || '0',
|
||||
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
|
||||
time: convertApiFieldToTime(
|
||||
alertDef.evaluation?.spec?.schedule?.hour || 0,
|
||||
alertDef.evaluation?.spec?.schedule?.minute || 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
return INITIAL_EVALUATION_WINDOW_STATE.startingAt;
|
||||
}
|
||||
|
||||
if (windowType === 'rolling') {
|
||||
const timeframe = getRollingWindowTimeframe();
|
||||
if (timeframe === 'custom') {
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType,
|
||||
timeframe,
|
||||
startingAt: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
number: parseGoTime(
|
||||
alertDef.evaluation?.spec?.evalWindow || '1m',
|
||||
).time.toString(),
|
||||
unit: parseGoTime(alertDef.evaluation?.spec?.evalWindow || '1m').unit,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType,
|
||||
timeframe,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType,
|
||||
timeframe: getCumulativeWindowTimeframe(),
|
||||
startingAt: getCumulativeWindowStartingAt(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNotificationSettingsStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): NotificationSettingsState {
|
||||
const description = alertDef.annotations?.description || '';
|
||||
const multipleNotifications = alertDef.notificationSettings?.groupBy || [];
|
||||
const routingPolicies = alertDef.notificationSettings?.usePolicy || false;
|
||||
|
||||
const reNotificationEnabled =
|
||||
alertDef.notificationSettings?.renotify?.enabled || false;
|
||||
const reNotificationConditions =
|
||||
alertDef.notificationSettings?.renotify?.alertStates?.map(
|
||||
(state) => state as 'firing' | 'nodata',
|
||||
) || [];
|
||||
const reNotificationValue = alertDef.notificationSettings?.renotify
|
||||
? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').time
|
||||
: 1;
|
||||
const reNotificationUnit = alertDef.notificationSettings?.renotify
|
||||
? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').unit
|
||||
: UniversalYAxisUnit.MINUTES;
|
||||
|
||||
return {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
description,
|
||||
multipleNotifications,
|
||||
routingPolicies,
|
||||
reNotification: {
|
||||
enabled: reNotificationEnabled,
|
||||
conditions: reNotificationConditions,
|
||||
value: reNotificationValue,
|
||||
unit: reNotificationUnit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdvancedOptionsStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AdvancedOptionsState {
|
||||
return {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
|
||||
toleranceLimit: alertDef.condition.absentFor || 0,
|
||||
enabled: alertDef.condition.alertOnAbsent || false,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
|
||||
minimumDatapoints: alertDef.condition.requiredNumPoints || 0,
|
||||
enabled: alertDef.condition.requireMinPoints || false,
|
||||
},
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'default',
|
||||
default: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence.default,
|
||||
value: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').time,
|
||||
timeUnit: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').unit,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
alertDef.condition.thresholds?.spec.map((threshold) => ({
|
||||
id: v4(),
|
||||
label: threshold.name,
|
||||
thresholdValue: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
channels: threshold.channels,
|
||||
})) || [],
|
||||
selectedQuery: alertDef.condition.selectedQueryName || '',
|
||||
operator:
|
||||
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
|
||||
AlertThresholdOperator.IS_ABOVE,
|
||||
matchType:
|
||||
(alertDef.condition.thresholds?.spec[0]
|
||||
.matchType as AlertThresholdMatchType) ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): GetCreateAlertLocalStateFromAlertDefReturn {
|
||||
if (!alertDef) {
|
||||
return {
|
||||
basicAlertState: INITIAL_ALERT_STATE,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
};
|
||||
}
|
||||
// Basic alert state
|
||||
const basicAlertState: AlertState = {
|
||||
...INITIAL_ALERT_STATE,
|
||||
name: alertDef.alert,
|
||||
labels: alertDef.labels || {},
|
||||
yAxisUnit: alertDef.condition.compositeQuery.unit,
|
||||
};
|
||||
|
||||
const thresholdState = getThresholdStateFromAlertDef(alertDef);
|
||||
|
||||
const advancedOptionsState = getAdvancedOptionsStateFromAlertDef(alertDef);
|
||||
|
||||
const evaluationWindowState = getEvaluationWindowStateFromAlertDef(alertDef);
|
||||
|
||||
const notificationSettingsState = getNotificationSettingsStateFromAlertDef(
|
||||
alertDef,
|
||||
);
|
||||
|
||||
return {
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptionsState,
|
||||
evaluationWindowState,
|
||||
notificationSettingsState,
|
||||
};
|
||||
}
|
||||
|
||||
56
frontend/src/container/EditAlertV2/EditAlertV2.tsx
Normal file
56
frontend/src/container/EditAlertV2/EditAlertV2.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import '../CreateAlertV2/CreateAlertV2.styles.scss';
|
||||
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { useMemo } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import AlertCondition from '../CreateAlertV2/AlertCondition';
|
||||
import { buildInitialAlertDef } from '../CreateAlertV2/context/utils';
|
||||
import EvaluationSettings from '../CreateAlertV2/EvaluationSettings';
|
||||
import Footer from '../CreateAlertV2/Footer';
|
||||
import NotificationSettings from '../CreateAlertV2/NotificationSettings';
|
||||
import QuerySection from '../CreateAlertV2/QuerySection';
|
||||
import { showCondensedLayout, Spinner } from '../CreateAlertV2/utils';
|
||||
|
||||
interface EditAlertV2Props {
|
||||
alertType?: AlertTypes;
|
||||
initialAlert: PostableAlertRuleV2;
|
||||
}
|
||||
|
||||
function EditAlertV2({
|
||||
alertType = AlertTypes.METRICS_BASED_ALERT,
|
||||
initialAlert,
|
||||
}: EditAlertV2Props): JSX.Element {
|
||||
const currentQueryToRedirect = useMemo(() => {
|
||||
const basicAlertDef = buildInitialAlertDef(alertType);
|
||||
return mapQueryDataFromApi(
|
||||
initialAlert?.condition.compositeQuery ||
|
||||
basicAlertDef.condition.compositeQuery,
|
||||
);
|
||||
}, [initialAlert, alertType]);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spinner />
|
||||
<div className="create-alert-v2-container">
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
EditAlertV2.defaultProps = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
};
|
||||
|
||||
export default EditAlertV2;
|
||||
3
frontend/src/container/EditAlertV2/index.ts
Normal file
3
frontend/src/container/EditAlertV2/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import EditAlertV2 from './EditAlertV2';
|
||||
|
||||
export default EditAlertV2;
|
||||
@@ -1,11 +1,32 @@
|
||||
import { Form } from 'antd';
|
||||
import EditAlertV2 from 'container/EditAlertV2';
|
||||
import FormAlertRules from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
|
||||
function EditRules({
|
||||
initialValue,
|
||||
ruleId,
|
||||
initialV2AlertValue,
|
||||
}: EditRulesProps): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
if (
|
||||
initialV2AlertValue !== null &&
|
||||
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
|
||||
) {
|
||||
return (
|
||||
<EditAlertV2
|
||||
initialAlert={initialV2AlertValue}
|
||||
alertType={initialValue.alertType as AlertTypes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={
|
||||
@@ -23,6 +44,7 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
|
||||
interface EditRulesProps {
|
||||
initialValue: AlertDef;
|
||||
ruleId: string;
|
||||
initialV2AlertValue: PostableAlertRuleV2 | null;
|
||||
}
|
||||
|
||||
export default EditRules;
|
||||
|
||||
@@ -71,6 +71,7 @@ function QuerySection({
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -81,6 +82,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Flex, Input, Typography } from 'antd';
|
||||
import { Button, Dropdown, Flex, Input, MenuProps, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import saveAlertApi from 'api/alerts/save';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -31,7 +31,7 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GettableAlert } from 'types/api/alerts/get';
|
||||
|
||||
import DeleteAlert from './DeleteAlert';
|
||||
import { Button, ColumnButton, SearchContainer } from './styles';
|
||||
import { ColumnButton, SearchContainer } from './styles';
|
||||
import Status from './TableComponents/Status';
|
||||
import ToggleAlertState from './ToggleAlertState';
|
||||
import { alertActionLogEvent, filterAlerts } from './utils';
|
||||
@@ -97,14 +97,37 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
});
|
||||
}, [notificationsApi, t]);
|
||||
|
||||
const onClickNewAlertHandler = useCallback(() => {
|
||||
const onClickNewAlertV2Handler = useCallback(() => {
|
||||
logEvent('Alert: New alert button clicked', {
|
||||
number: allAlertRules?.length,
|
||||
layout: 'new',
|
||||
});
|
||||
history.push(`${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onClickNewClassicAlertHandler = useCallback(() => {
|
||||
logEvent('Alert: New alert button clicked', {
|
||||
number: allAlertRules?.length,
|
||||
layout: 'classic',
|
||||
});
|
||||
history.push(ROUTES.ALERTS_NEW);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const newAlertMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'new',
|
||||
label: 'Try the new experience',
|
||||
onClick: onClickNewAlertV2Handler,
|
||||
},
|
||||
{
|
||||
key: 'classic',
|
||||
label: 'Continue with the current experience',
|
||||
onClick: onClickNewClassicAlertHandler,
|
||||
},
|
||||
];
|
||||
|
||||
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
|
||||
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
|
||||
params.set(
|
||||
@@ -368,13 +391,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
/>
|
||||
<Flex gap={12}>
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClickNewAlertHandler}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
<Dropdown menu={{ items: newAlertMenuItems }} trigger={['click']}>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
New Alert
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
<TextToolTip
|
||||
{...{
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -171,6 +172,11 @@ function LogsExplorerViewsContainer({
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedFilterExpression = listQuery.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
const modifiedQueryData: IBuilderQuery = {
|
||||
...listQuery,
|
||||
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||
@@ -183,6 +189,10 @@ function LogsExplorerViewsContainer({
|
||||
},
|
||||
],
|
||||
legend: '{{severity_text}}',
|
||||
filter: {
|
||||
...listQuery?.filter,
|
||||
expression: updatedFilterExpression || '',
|
||||
},
|
||||
...(activeLogId && {
|
||||
filters: {
|
||||
...listQuery?.filters,
|
||||
@@ -286,6 +296,7 @@ function LogsExplorerViewsContainer({
|
||||
page: number;
|
||||
pageSize: number;
|
||||
filters: TagFilter;
|
||||
filter: Filter;
|
||||
},
|
||||
): Query | null => {
|
||||
if (!query) return null;
|
||||
@@ -297,6 +308,7 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
// Add filter for activeLogId if present
|
||||
let updatedFilters = params.filters;
|
||||
let updatedFilterExpression = params.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilters = {
|
||||
...params.filters,
|
||||
@@ -315,6 +327,7 @@ function LogsExplorerViewsContainer({
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
// Create orderBy array based on orderDirection
|
||||
@@ -336,6 +349,9 @@ function LogsExplorerViewsContainer({
|
||||
...(listQuery || initialQueryBuilderFormValues),
|
||||
...paginateData,
|
||||
...(updatedFilters ? { filters: updatedFilters } : {}),
|
||||
filter: {
|
||||
expression: updatedFilterExpression || '',
|
||||
},
|
||||
...(selectedView === ExplorerViews.LIST
|
||||
? { order: newOrderBy, orderBy: newOrderBy }
|
||||
: { order: [] }),
|
||||
@@ -368,7 +384,7 @@ function LogsExplorerViewsContainer({
|
||||
if (isLimit) return;
|
||||
if (logs.length < pageSize) return;
|
||||
|
||||
const { limit, filters } = listQuery;
|
||||
const { limit, filters, filter } = listQuery;
|
||||
|
||||
const nextLogsLength = logs.length + pageSize;
|
||||
|
||||
@@ -379,6 +395,7 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
const newRequestData = getRequestData(stagedQuery, {
|
||||
filters: filters || { items: [], op: 'AND' },
|
||||
filter: filter || { expression: '' },
|
||||
page: page + 1,
|
||||
pageSize: nextPageSize,
|
||||
});
|
||||
@@ -526,6 +543,7 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
const newRequestData = getRequestData(stagedQuery, {
|
||||
filters: listQuery?.filters || initialFilters,
|
||||
filter: listQuery?.filter || { expression: '' },
|
||||
page: 1,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
@@ -261,6 +262,68 @@ describe('LogsExplorerViews -', () => {
|
||||
|
||||
// Verify the total number of filters (original + 1 new activeLogId filter)
|
||||
expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength);
|
||||
|
||||
// Verify the filter expression
|
||||
expect(firstQuery.filter?.expression).toBe(`id <= '${ACTIVE_LOG_ID}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should update filter expression with activeLogId when present with existing filter expression', async () => {
|
||||
// Mock useCopyLogLink to return an activeLogId
|
||||
(useCopyLogLink as jest.Mock).mockReturnValue({
|
||||
activeLogId: ACTIVE_LOG_ID,
|
||||
});
|
||||
|
||||
// Create a custom QueryBuilderContext with an existing filter expression
|
||||
const customContext = {
|
||||
...mockQueryBuilderContextValue,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: {
|
||||
...mockQueryBuilderContextValue.stagedQuery,
|
||||
builder: {
|
||||
...mockQueryBuilderContextValue.stagedQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQueryBuilderContextValue.stagedQuery.builder.queryData[0],
|
||||
filter: { expression: "service = 'frontend'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
lodsQueryServerRequest();
|
||||
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={customContext as any}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the call made for LIST panel type (main logs list request)
|
||||
const listCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
|
||||
(call) => call[1] === PANEL_TYPES.LIST && call[0],
|
||||
);
|
||||
|
||||
expect(listCall).toBeDefined();
|
||||
if (listCall) {
|
||||
const queryArg = listCall[0];
|
||||
const firstQuery = queryArg.builder.queryData[0];
|
||||
// It should append the activeLogId condition to existing expression
|
||||
expect(firstQuery.filter?.expression).toBe(
|
||||
"service = 'frontend' id <= 'test-log-id'",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
@@ -7,6 +6,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import store from 'store';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import Summary from '../Summary';
|
||||
import { TreemapViewType } from '../types';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
|
||||
import { DeleteRoutingPolicyProps } from './types';
|
||||
|
||||
function DeleteRoutingPolicy({
|
||||
handleClose,
|
||||
handleDelete,
|
||||
routingPolicy,
|
||||
isDeletingRoutingPolicy,
|
||||
}: DeleteRoutingPolicyProps): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
className="delete-policy-modal"
|
||||
title={<span className="title">Delete Routing Policy</span>}
|
||||
open
|
||||
closable={false}
|
||||
onCancel={handleClose}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={handleClose}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
disabled={isDeletingRoutingPolicy}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={handleDelete}
|
||||
className="delete-btn"
|
||||
disabled={isDeletingRoutingPolicy}
|
||||
>
|
||||
Delete Routing Policy
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="delete-text">
|
||||
{`Are you sure you want to delete ${routingPolicy?.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`}
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteRoutingPolicy;
|
||||
118
frontend/src/container/RoutingPolicies/RoutingPolicies.tsx
Normal file
118
frontend/src/container/RoutingPolicies/RoutingPolicies.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Flex, Input, Tooltip, Typography } from 'antd';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import DeleteRoutingPolicy from './DeleteRoutingPolicy';
|
||||
import RoutingPolicyDetails from './RoutingPolicyDetails';
|
||||
import RoutingPolicyList from './RoutingPolicyList';
|
||||
import useRoutingPolicies from './useRoutingPolicies';
|
||||
|
||||
function RoutingPolicies(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const {
|
||||
// Routing Policies
|
||||
selectedRoutingPolicy,
|
||||
routingPoliciesData,
|
||||
isLoadingRoutingPolicies,
|
||||
isErrorRoutingPolicies,
|
||||
// Channels
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
// Search
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
// Delete Modal
|
||||
isDeleteModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
handleDeleteModalClose,
|
||||
handleDeleteRoutingPolicy,
|
||||
isDeletingRoutingPolicy,
|
||||
// Policy Details Modal
|
||||
policyDetailsModalState,
|
||||
handlePolicyDetailsModalClose,
|
||||
handlePolicyDetailsModalOpen,
|
||||
handlePolicyDetailsModalAction,
|
||||
isPolicyDetailsModalActionLoading,
|
||||
} = useRoutingPolicies();
|
||||
|
||||
const disableCreateButton = user?.role === USER_ROLES.VIEWER;
|
||||
|
||||
const tooltipTitle = useMemo(() => {
|
||||
if (user?.role === USER_ROLES.VIEWER) {
|
||||
return 'You need edit permissions to create a routing policy';
|
||||
}
|
||||
return '';
|
||||
}, [user?.role]);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchTerm(e.target.value || '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="routing-policies-container">
|
||||
<div className="routing-policies-content">
|
||||
<Typography.Title className="title">Routing Policies</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage routing policies.
|
||||
</Typography.Text>
|
||||
<Flex className="toolbar">
|
||||
<Input
|
||||
placeholder="Search for a routing policy..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => handlePolicyDetailsModalOpen('create', null)}
|
||||
disabled={disableCreateButton}
|
||||
>
|
||||
New routing policy
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<br />
|
||||
<RoutingPolicyList
|
||||
routingPolicies={routingPoliciesData}
|
||||
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
|
||||
isRoutingPoliciesError={isErrorRoutingPolicies}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||
/>
|
||||
{policyDetailsModalState.isOpen && (
|
||||
<RoutingPolicyDetails
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
closeModal={handlePolicyDetailsModalClose}
|
||||
mode={policyDetailsModalState.mode}
|
||||
channels={channels}
|
||||
isErrorChannels={isErrorChannels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
handlePolicyDetailsModalAction={handlePolicyDetailsModalAction}
|
||||
isPolicyDetailsModalActionLoading={isPolicyDetailsModalActionLoading}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
)}
|
||||
{isDeleteModalOpen && (
|
||||
<DeleteRoutingPolicy
|
||||
isDeletingRoutingPolicy={isDeletingRoutingPolicy}
|
||||
handleDelete={handleDeleteRoutingPolicy}
|
||||
handleClose={handleDeleteModalClose}
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicies;
|
||||
208
frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx
Normal file
208
frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useForm } from 'antd/lib/form/Form';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
|
||||
import {
|
||||
RoutingPolicyDetailsFormState,
|
||||
RoutingPolicyDetailsProps,
|
||||
} from './types';
|
||||
|
||||
function RoutingPolicyDetails({
|
||||
closeModal,
|
||||
mode,
|
||||
channels,
|
||||
isErrorChannels,
|
||||
isLoadingChannels,
|
||||
routingPolicy,
|
||||
handlePolicyDetailsModalAction,
|
||||
isPolicyDetailsModalActionLoading,
|
||||
refreshChannels,
|
||||
}: RoutingPolicyDetailsProps): JSX.Element {
|
||||
const [form] = useForm();
|
||||
const { user } = useAppContext();
|
||||
|
||||
const initialFormState = useMemo(() => {
|
||||
if (mode === 'edit') {
|
||||
return {
|
||||
name: routingPolicy?.name || '',
|
||||
expression: routingPolicy?.expression || '',
|
||||
channels: routingPolicy?.channels || [],
|
||||
description: routingPolicy?.description || '',
|
||||
};
|
||||
}
|
||||
return INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE;
|
||||
}, [routingPolicy, mode]);
|
||||
|
||||
const modalTitle =
|
||||
mode === 'edit' ? 'Edit routing policy' : 'Create routing policy';
|
||||
|
||||
const handleSave = (): void => {
|
||||
handlePolicyDetailsModalAction(mode, {
|
||||
name: form.getFieldValue('name'),
|
||||
expression: form.getFieldValue('expression'),
|
||||
channels: form.getFieldValue('channels'),
|
||||
description: form.getFieldValue('description'),
|
||||
});
|
||||
};
|
||||
|
||||
const notificationChannelsNotFoundContent = (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<ModalTitle level={4}>{modalTitle}</ModalTitle>}
|
||||
centered
|
||||
open
|
||||
className="create-policy-modal"
|
||||
width={600}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Divider plain />
|
||||
<Form<RoutingPolicyDetailsFormState>
|
||||
form={form}
|
||||
initialValues={initialFormState}
|
||||
onFinish={handleSave}
|
||||
>
|
||||
<div className="create-policy-container">
|
||||
<div className="input-group">
|
||||
<Typography.Text>Routing Policy Name</Typography.Text>
|
||||
<Form.Item
|
||||
name="name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please provide a name for the routing policy',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g. Base routing policy..." />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<Typography.Text>Description</Typography.Text>
|
||||
<Form.Item
|
||||
name="description"
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="e.g. This is a routing policy that..."
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<Typography.Text>Expression</Typography.Text>
|
||||
<Form.Item
|
||||
name="expression"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please provide an expression for the routing policy',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder='e.g. service.name == "payment" && threshold.name == "critical"'
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<Typography.Text>Notification Channels</Typography.Text>
|
||||
<Form.Item
|
||||
name="channels"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select at least one notification channel',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.name,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={3}
|
||||
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}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<Flex className="create-policy-footer" justify="space-between">
|
||||
<Button onClick={closeModal} disabled={isPolicyDetailsModalActionLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isPolicyDetailsModalActionLoading}
|
||||
disabled={isPolicyDetailsModalActionLoading}
|
||||
>
|
||||
Save Routing Policy
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicyDetails;
|
||||
73
frontend/src/container/RoutingPolicies/RoutingPolicyList.tsx
Normal file
73
frontend/src/container/RoutingPolicies/RoutingPolicyList.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Table, TableProps, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import RoutingPolicyListItem from './RoutingPolicyListItem';
|
||||
import { RoutingPolicy, RoutingPolicyListProps } from './types';
|
||||
|
||||
function RoutingPolicyList({
|
||||
routingPolicies,
|
||||
isRoutingPoliciesLoading,
|
||||
isRoutingPoliciesError,
|
||||
handlePolicyDetailsModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
}: RoutingPolicyListProps): JSX.Element {
|
||||
const columns: TableProps<RoutingPolicy>['columns'] = [
|
||||
{
|
||||
title: 'Routing Policy',
|
||||
key: 'routingPolicy',
|
||||
render: (data: RoutingPolicy): JSX.Element => (
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={data}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const localeEmptyState = useMemo(
|
||||
() => (
|
||||
<div className="no-routing-policies-message-container">
|
||||
{isRoutingPoliciesError ? (
|
||||
<img src="/Icons/awwSnap.svg" alt="aww-snap" className="error-state-svg" />
|
||||
) : (
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
)}
|
||||
{isRoutingPoliciesError ? (
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching routing policies.
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text>No routing policies found.</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[isRoutingPoliciesError],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<RoutingPolicy>
|
||||
columns={columns}
|
||||
className="routing-policies-table"
|
||||
bordered={false}
|
||||
dataSource={routingPolicies}
|
||||
loading={isRoutingPoliciesLoading}
|
||||
showHeader={false}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: isRoutingPoliciesLoading ? null : localeEmptyState,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicyList;
|
||||
137
frontend/src/container/RoutingPolicies/RoutingPolicyListItem.tsx
Normal file
137
frontend/src/container/RoutingPolicies/RoutingPolicyListItem.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Collapse, Flex, Tag, Typography } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PenLine, Trash2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import {
|
||||
PolicyListItemContentProps,
|
||||
PolicyListItemHeaderProps,
|
||||
RoutingPolicyListItemProps,
|
||||
} from './types';
|
||||
|
||||
function PolicyListItemHeader({
|
||||
name,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}: PolicyListItemHeaderProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
const isEditEnabled = user?.role !== USER_ROLES.VIEWER;
|
||||
|
||||
return (
|
||||
<Flex className="policy-list-item-header" justify="space-between">
|
||||
<Typography>{name}</Typography>
|
||||
|
||||
{isEditEnabled && (
|
||||
<div className="action-btn">
|
||||
<PenLine
|
||||
size={14}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
data-testid="edit-routing-policy"
|
||||
/>
|
||||
<Trash2
|
||||
size={14}
|
||||
color={Color.BG_CHERRY_500}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
data-testid="delete-routing-policy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyListItemContent({
|
||||
routingPolicy,
|
||||
}: PolicyListItemContentProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<div className="policy-list-item-content">
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Created by</Typography>
|
||||
<Typography>{routingPolicy.createdBy}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Created on</Typography>
|
||||
<Typography>
|
||||
{routingPolicy.createdAt
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
routingPolicy.createdAt,
|
||||
DATE_TIME_FORMATS.MONTH_DATETIME,
|
||||
)
|
||||
: '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Updated by</Typography>
|
||||
<Typography>{routingPolicy.updatedBy || '-'}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Updated on</Typography>
|
||||
<Typography>
|
||||
{routingPolicy.updatedAt
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
routingPolicy.updatedAt,
|
||||
DATE_TIME_FORMATS.MONTH_DATETIME,
|
||||
)
|
||||
: '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Expression</Typography>
|
||||
<Typography>{routingPolicy.expression}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Description</Typography>
|
||||
<Typography>{routingPolicy.description || '-'}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Channels</Typography>
|
||||
<div>
|
||||
{routingPolicy.channels.map((channel) => (
|
||||
<Tag key={channel}>{channel}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingPolicyListItem({
|
||||
routingPolicy,
|
||||
handlePolicyDetailsModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
}: RoutingPolicyListItemProps): JSX.Element {
|
||||
return (
|
||||
<Collapse accordion className="policy-list-item">
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<PolicyListItemHeader
|
||||
name={routingPolicy.name}
|
||||
handleEdit={(): void =>
|
||||
handlePolicyDetailsModalOpen('edit', routingPolicy)
|
||||
}
|
||||
handleDelete={(): void => handleDeleteModalOpen(routingPolicy)}
|
||||
/>
|
||||
}
|
||||
key={routingPolicy.id}
|
||||
>
|
||||
<PolicyListItemContent routingPolicy={routingPolicy} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicyListItem;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import DeleteRoutingPolicy from '../DeleteRoutingPolicy';
|
||||
import { MOCK_ROUTING_POLICY_1 } from './testUtils';
|
||||
|
||||
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
|
||||
const mockHandleDelete = jest.fn();
|
||||
const mockHandleClose = jest.fn();
|
||||
|
||||
const DELETE_BUTTON_TEXT = 'Delete Routing Policy';
|
||||
const CANCEL_BUTTON_TEXT = 'Cancel';
|
||||
|
||||
describe('DeleteRoutingPolicy', () => {
|
||||
it('renders base layout with routing policy', () => {
|
||||
render(
|
||||
<DeleteRoutingPolicy
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
isDeletingRoutingPolicy={false}
|
||||
handleDelete={mockHandleDelete}
|
||||
handleClose={mockHandleClose}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: DELETE_BUTTON_TEXT }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Are you sure you want to delete ${mockRoutingPolicy.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleDelete when delete button is clicked', () => {
|
||||
render(
|
||||
<DeleteRoutingPolicy
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
isDeletingRoutingPolicy={false}
|
||||
handleDelete={mockHandleDelete}
|
||||
handleClose={mockHandleClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: DELETE_BUTTON_TEXT }));
|
||||
expect(mockHandleDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call handleClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteRoutingPolicy
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
isDeletingRoutingPolicy={false}
|
||||
handleDelete={mockHandleDelete}
|
||||
handleClose={mockHandleClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }));
|
||||
expect(mockHandleClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be disabled when deleting routing policy', () => {
|
||||
render(
|
||||
<DeleteRoutingPolicy
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
isDeletingRoutingPolicy
|
||||
handleDelete={mockHandleDelete}
|
||||
handleClose={mockHandleClose}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
|
||||
import RoutingPolicies from '../RoutingPolicies';
|
||||
import * as routingPoliciesHooks from '../useRoutingPolicies';
|
||||
import {
|
||||
getAppContextMockState,
|
||||
getUseRoutingPoliciesMockData,
|
||||
MOCK_ROUTING_POLICY_1,
|
||||
} from './testUtils';
|
||||
|
||||
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('../RoutingPolicyList', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => (
|
||||
<div data-testid="routing-policy-list">RoutingPolicyList</div>
|
||||
)),
|
||||
}));
|
||||
jest.mock('../RoutingPolicyDetails', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => (
|
||||
<div data-testid="routing-policy-details">RoutingPolicyDetails</div>
|
||||
)),
|
||||
}));
|
||||
jest.mock('../DeleteRoutingPolicy', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => (
|
||||
<div data-testid="delete-routing-policy">DeleteRoutingPolicy</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockHandleSearch = jest.fn();
|
||||
const mockHandlePolicyDetailsModalOpen = jest.fn();
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
setSearchTerm: mockHandleSearch,
|
||||
handlePolicyDetailsModalOpen: mockHandlePolicyDetailsModalOpen,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('RoutingPolicies', () => {
|
||||
it('should render components properly', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage routing policies.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for a routing policy...'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('delete-routing-policy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should enable the "New routing policy" button for users with ADMIN role', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should disable the "New routing policy" button for users with VIEWER role', () => {
|
||||
jest
|
||||
.spyOn(appHooks, 'useAppContext')
|
||||
.mockReturnValueOnce(getAppContextMockState({ role: 'VIEWER' }));
|
||||
render(<RoutingPolicies />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('filters routing policies by search term', () => {
|
||||
render(<RoutingPolicies />);
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Search for a routing policy...',
|
||||
);
|
||||
fireEvent.change(searchInput, {
|
||||
target: { value: MOCK_ROUTING_POLICY_1.name },
|
||||
});
|
||||
|
||||
expect(mockHandleSearch).toHaveBeenCalledWith(MOCK_ROUTING_POLICY_1.name);
|
||||
});
|
||||
|
||||
it('clicking on the "New routing policy" button opens the policy details modal', () => {
|
||||
render(<RoutingPolicies />);
|
||||
const newRoutingPolicyButton = screen.getByRole('button', {
|
||||
name: /New routing policy/,
|
||||
});
|
||||
fireEvent.click(newRoutingPolicyButton);
|
||||
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith('create', null);
|
||||
});
|
||||
|
||||
it('policy details modal is open based on modal state', () => {
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
policyDetailsModalState: {
|
||||
mode: 'create',
|
||||
isOpen: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<RoutingPolicies />);
|
||||
expect(
|
||||
screen.getByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('delete modal is open based on modal state', () => {
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
isDeleteModalOpen: true,
|
||||
}),
|
||||
);
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user