mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
chore: create alerts ux improvements and api integration (#9165)
This commit is contained in:
parent
8b21ba5db9
commit
1a1ef5aff8
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;
|
||||
@ -2,6 +2,8 @@ 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 { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
@ -125,6 +127,15 @@ function CreateRules(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
|
||||
|
||||
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 />
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
@ -21,27 +17,30 @@ 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();
|
||||
|
||||
@ -85,6 +84,65 @@ function AlertThreshold(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const onTooltipOpenChange = (open: boolean): void => {
|
||||
// Stop propagation of click events on tooltip text to dropdown
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
const tooltipElement = document.querySelector(
|
||||
'.copyable-tooltip .ant-tooltip-inner',
|
||||
);
|
||||
if (tooltipElement) {
|
||||
tooltipElement.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
true,
|
||||
);
|
||||
tooltipElement.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
|
||||
(option) => ({
|
||||
...option,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
|
||||
placement="left"
|
||||
overlayClassName="copyable-tooltip"
|
||||
overlayStyle={{
|
||||
maxWidth: '450px',
|
||||
minWidth: '400px',
|
||||
}}
|
||||
overlayInnerStyle={{
|
||||
padding: '12px 16px',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
MozUserSelect: 'text',
|
||||
msUserSelect: 'text',
|
||||
}}
|
||||
mouseEnterDelay={0.2}
|
||||
trigger={['hover', 'click']}
|
||||
destroyTooltipOnHide={false}
|
||||
onOpenChange={onTooltipOpenChange}
|
||||
>
|
||||
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
@ -114,8 +172,7 @@ function AlertThreshold(): JSX.Element {
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.operator}
|
||||
onChange={(value): void => {
|
||||
@ -124,7 +181,7 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
@ -138,8 +195,8 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
options={THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the {evaluationWindowContext}
|
||||
@ -158,6 +215,8 @@ function AlertThreshold(): JSX.Element {
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={categorySelectOptions}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
@ -169,6 +228,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}
|
||||
/>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
seasonality to
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].channels}
|
||||
onChange={(value): void =>
|
||||
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
|
||||
}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
status={isErrorChannels ? 'error' : undefined}
|
||||
disabled={isLoadingChannels}
|
||||
notFoundContent={
|
||||
<NotificationChannelsNotFoundContent
|
||||
user={user}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX } from 'lucide-react';
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { CircleX, Trash } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { AlertThresholdOperator } from '../context/types';
|
||||
import { ThresholdItemProps } from './types';
|
||||
import { NotificationChannelsNotFoundContent } from './utils';
|
||||
|
||||
function ThresholdItem({
|
||||
threshold,
|
||||
@ -11,7 +15,12 @@ function ThresholdItem({
|
||||
showRemoveButton,
|
||||
channels,
|
||||
units,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
isLoadingChannels,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { thresholdState, notificationSettings } = useCreateAlertState();
|
||||
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
|
||||
|
||||
const yAxisUnitSelect = useMemo(() => {
|
||||
@ -45,6 +54,31 @@ function ThresholdItem({
|
||||
return component;
|
||||
}, [units, threshold.unit, updateThreshold, threshold.id]);
|
||||
|
||||
const getOperatorSymbol = (): string => {
|
||||
switch (thresholdState.operator) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return '>';
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return '<';
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return '=';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return '!=';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// const addRecoveryThreshold = (): void => {
|
||||
// setShowRecoveryThreshold(true);
|
||||
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
|
||||
// };
|
||||
|
||||
const removeRecoveryThreshold = (): void => {
|
||||
setShowRecoveryThreshold(false);
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={threshold.id} className="threshold-item">
|
||||
<div className="threshold-row">
|
||||
@ -54,80 +88,111 @@ function ThresholdItem({
|
||||
style={{ backgroundColor: threshold.color }}
|
||||
/>
|
||||
</div>
|
||||
<Space className="threshold-controls">
|
||||
<div className="threshold-inputs">
|
||||
<Input.Group>
|
||||
<div className="threshold-controls">
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
</Input.Group>
|
||||
</div>
|
||||
<Typography.Text className="sentence-text">to</Typography.Text>
|
||||
{!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: 260 }}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
value: channel.name,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
status={isErrorChannels ? 'error' : undefined}
|
||||
disabled={isLoadingChannels}
|
||||
notFoundContent={
|
||||
<NotificationChannelsNotFoundContent
|
||||
user={user}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showRecoveryThreshold && (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">recover on</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue ?? ''}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
<Tooltip title="Remove recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Trash size={16} />}
|
||||
onClick={removeRecoveryThreshold}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Button.Group>
|
||||
{!showRecoveryThreshold && (
|
||||
{/* TODO: Add recovery threshold back once the functionality is implemented */}
|
||||
{/* {!showRecoveryThreshold && (
|
||||
<Tooltip title="Add recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={(): void => setShowRecoveryThreshold(true)}
|
||||
onClick={addRecoveryThreshold}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
)} */}
|
||||
{showRemoveButton && (
|
||||
<Tooltip title="Remove threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
</Space>
|
||||
</div>
|
||||
{showRecoveryThreshold && (
|
||||
<Input.Group className="recovery-threshold-input-group">
|
||||
<Input
|
||||
placeholder="Recovery threshold"
|
||||
disabled
|
||||
style={{ width: 260 }}
|
||||
className="recovery-threshold-label"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
</Input.Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertCondition from '../AlertCondition';
|
||||
@ -105,7 +106,7 @@ const renderAlertCondition = (
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
@ -126,9 +127,10 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@ -136,7 +138,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@ -151,7 +154,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
@ -165,7 +169,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
@ -176,7 +181,8 @@ describe('AlertCondition', () => {
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches between threshold and anomaly tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
@ -201,7 +207,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@ -222,21 +229,21 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
// Both tabs should be visible
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
renderAlertCondition('ANOMALY_BASED_ALERT');
|
||||
|
||||
// Both tabs should be visible
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@ -3,11 +3,23 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertThreshold from '../AlertThreshold';
|
||||
|
||||
const mockChannels: Channels[] = [];
|
||||
const mockRefreshChannels = jest.fn();
|
||||
const mockIsLoadingChannels = false;
|
||||
const mockIsErrorChannels = false;
|
||||
const mockProps = {
|
||||
channels: mockChannels,
|
||||
isLoadingChannels: mockIsLoadingChannels,
|
||||
isErrorChannels: mockIsErrorChannels,
|
||||
refreshChannels: mockRefreshChannels,
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@ -99,7 +111,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
|
||||
const TEST_STRINGS = {
|
||||
ADD_THRESHOLD: 'Add Threshold',
|
||||
AT_LEAST_ONCE: 'AT LEAST ONCE',
|
||||
IS_ABOVE: 'IS ABOVE',
|
||||
IS_ABOVE: 'ABOVE',
|
||||
} as const;
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
@ -116,8 +128,8 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider>
|
||||
<AlertThreshold />
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertThreshold {...mockProps} />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
@ -125,7 +137,10 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
};
|
||||
|
||||
const verifySelectRenders = (title: string): void => {
|
||||
const select = screen.getByTitle(title);
|
||||
let select = screen.queryByTitle(title);
|
||||
if (!select) {
|
||||
select = screen.getByText(title);
|
||||
}
|
||||
expect(select).toBeInTheDocument();
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
} as any);
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@ -54,7 +55,14 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
|
||||
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
render(<AnomalyThreshold />);
|
||||
render(
|
||||
<AnomalyThreshold
|
||||
channels={[]}
|
||||
isLoadingChannels={false}
|
||||
isErrorChannels={false}
|
||||
refreshChannels={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('AnomalyThreshold', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -2,15 +2,37 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
import { ThresholdItemProps } from '../types';
|
||||
|
||||
// Mock the enableRecoveryThreshold utility
|
||||
jest.mock('../../utils', () => ({
|
||||
enableRecoveryThreshold: jest.fn(() => true),
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock: any = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
uplotMock.paths = paths;
|
||||
return uplotMock;
|
||||
});
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
|
||||
const TEST_CONSTANTS = {
|
||||
THRESHOLD_ID: 'test-threshold-1',
|
||||
@ -21,6 +43,7 @@ const TEST_CONSTANTS = {
|
||||
CHANNEL_2: 'channel-2',
|
||||
CHANNEL_3: 'channel-3',
|
||||
EMAIL_CHANNEL_NAME: 'Email Channel',
|
||||
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
|
||||
ENTER_THRESHOLD_NAME: 'Enter threshold name',
|
||||
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
|
||||
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
|
||||
@ -59,6 +82,8 @@ const defaultProps: ThresholdItemProps = {
|
||||
channels: mockChannels,
|
||||
isLoadingChannels: false,
|
||||
units: mockUnits,
|
||||
isErrorChannels: false,
|
||||
refreshChannels: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThresholdItem = (
|
||||
@ -77,10 +102,11 @@ const verifySelectorWidth = (
|
||||
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
|
||||
};
|
||||
|
||||
const showRecoveryThreshold = (): void => {
|
||||
const recoveryButton = screen.getByRole('button', { name: '' });
|
||||
fireEvent.click(recoveryButton);
|
||||
};
|
||||
// TODO: Unskip this when recovery threshold is implemented
|
||||
// const showRecoveryThreshold = (): void => {
|
||||
// const recoveryButton = screen.getByRole('button', { name: '' });
|
||||
// fireEvent.click(recoveryButton);
|
||||
// };
|
||||
|
||||
const verifyComponentRendersWithLoading = (): void => {
|
||||
expect(
|
||||
@ -122,7 +148,7 @@ describe('ThresholdItem', () => {
|
||||
const valueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(valueInput).toHaveValue('100');
|
||||
expect(valueInput).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('renders unit selector with correct value', () => {
|
||||
@ -132,15 +158,6 @@ describe('ThresholdItem', () => {
|
||||
expect(screen.getByText('Bytes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channels selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check for the channels selector by looking for the displayed text
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates threshold label when label input changes', () => {
|
||||
const updateThreshold = jest.fn();
|
||||
renderThresholdItem({ updateThreshold });
|
||||
@ -212,38 +229,31 @@ describe('ThresholdItem', () => {
|
||||
|
||||
// The remove button is the second button (with circle-x icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2); // Recovery button + remove button
|
||||
expect(buttons).toHaveLength(1); // remove button
|
||||
});
|
||||
|
||||
it('does not show remove button when showRemoveButton is false', () => {
|
||||
renderThresholdItem({ showRemoveButton: false });
|
||||
|
||||
// Only the recovery button should be present
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1); // Only recovery button
|
||||
// No buttons should be present
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
expect(buttons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls removeThreshold when remove button is clicked', () => {
|
||||
const removeThreshold = jest.fn();
|
||||
renderThresholdItem({ showRemoveButton: true, removeThreshold });
|
||||
|
||||
// The remove button is the second button (with circle-x icon)
|
||||
// The remove button is the first button (with circle-x icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const removeButton = buttons[1]; // Second button is the remove button
|
||||
const removeButton = buttons[0];
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID);
|
||||
});
|
||||
|
||||
it('shows recovery threshold button when recovery threshold is enabled', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// The recovery button is the first button (with chart-line icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1); // Recovery button
|
||||
});
|
||||
|
||||
it('shows recovery threshold inputs when recovery button is clicked', () => {
|
||||
// TODO: Unskip this when recovery threshold is implemented
|
||||
it.skip('shows recovery threshold inputs when recovery button is clicked', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// The recovery button is the first button (with chart-line icon)
|
||||
@ -251,13 +261,16 @@ describe('ThresholdItem', () => {
|
||||
const recoveryButton = buttons[0]; // First button is the recovery button
|
||||
fireEvent.click(recoveryButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter recovery threshold value'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates recovery threshold value when input changes', () => {
|
||||
// TODO: Unskip this when recovery threshold is implemented
|
||||
it.skip('updates recovery threshold value when input changes', () => {
|
||||
const updateThreshold = jest.fn();
|
||||
renderThresholdItem({ updateThreshold });
|
||||
|
||||
@ -290,22 +303,6 @@ describe('ThresholdItem', () => {
|
||||
verifyUnitSelectorDisabled();
|
||||
});
|
||||
|
||||
it('renders channels as multiple select options', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check that channels are rendered as multiple select
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select multiple channels
|
||||
const channelSelectors = screen.getAllByRole('combobox');
|
||||
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
|
||||
fireEvent.change(channelSelector, {
|
||||
target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty threshold values correctly', () => {
|
||||
const emptyThreshold = {
|
||||
...mockThreshold,
|
||||
@ -318,7 +315,7 @@ describe('ThresholdItem', () => {
|
||||
renderThresholdItem({ threshold: emptyThreshold });
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
|
||||
});
|
||||
|
||||
it('renders with correct input widths', () => {
|
||||
@ -331,13 +328,13 @@ describe('ThresholdItem', () => {
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
|
||||
expect(labelInput).toHaveStyle('width: 260px');
|
||||
expect(valueInput).toHaveStyle('width: 210px');
|
||||
expect(labelInput).toHaveStyle('width: 200px');
|
||||
expect(valueInput).toHaveStyle('width: 100px');
|
||||
});
|
||||
|
||||
it('renders channels selector with correct width', () => {
|
||||
renderThresholdItem();
|
||||
verifySelectorWidth(1, '260px');
|
||||
verifySelectorWidth(1, '350px');
|
||||
});
|
||||
|
||||
it('renders unit selector with correct width', () => {
|
||||
@ -350,37 +347,14 @@ describe('ThresholdItem', () => {
|
||||
verifyComponentRendersWithLoading();
|
||||
});
|
||||
|
||||
it('renders recovery threshold with correct initial value', () => {
|
||||
it.skip('renders recovery threshold with correct initial value', () => {
|
||||
renderThresholdItem();
|
||||
showRecoveryThreshold();
|
||||
// showRecoveryThreshold();
|
||||
|
||||
const recoveryValueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(recoveryValueInput).toHaveValue('80');
|
||||
});
|
||||
|
||||
it('renders recovery threshold label as disabled', () => {
|
||||
renderThresholdItem();
|
||||
showRecoveryThreshold();
|
||||
|
||||
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
|
||||
expect(recoveryLabelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders correct channel options', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check that channels are rendered
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select different channels
|
||||
const channelSelectors = screen.getAllByRole('combobox');
|
||||
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
|
||||
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
|
||||
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
|
||||
expect(recoveryValueInput).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('handles threshold without channels', () => {
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
padding-right: 72px;
|
||||
background-color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
|
||||
.alert-condition-sentences {
|
||||
display: flex;
|
||||
@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px !important;
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
@ -148,6 +148,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
@ -277,6 +278,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-info-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
background-color: #4568dc1a;
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
padding: 8px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anomaly-threshold-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
@ -293,7 +317,8 @@
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
@ -301,6 +326,7 @@
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
@ -308,6 +334,7 @@
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
@ -318,3 +345,229 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-condition-container {
|
||||
.alert-condition {
|
||||
.alert-condition-tabs {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.explorer-view-option {
|
||||
border-left: 0.5px solid var(--bg-vanilla-300);
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-threshold-container,
|
||||
.anomaly-threshold-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.alert-condition-sentences {
|
||||
.alert-condition-sentence {
|
||||
.sentence-text {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
.threshold-item {
|
||||
.threshold-row {
|
||||
.threshold-controls {
|
||||
.threshold-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recovery-threshold-input-group {
|
||||
.recovery-threshold-btn {
|
||||
color: var(--bg-ink-400);
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-evaluation-settings-container {
|
||||
.ant-btn {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-ink-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
color: var(--bg-ink-400);
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlighted-text {
|
||||
font-weight: bold;
|
||||
color: var(--bg-robin-400);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
// Tooltip styles
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.tooltip-description {
|
||||
margin-bottom: 8px;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-example {
|
||||
margin-bottom: 8px;
|
||||
color: #8b92a0;
|
||||
}
|
||||
|
||||
.tooltip-link {
|
||||
.tooltip-link-text {
|
||||
color: #1890ff;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { Threshold } from '../context/types';
|
||||
import {
|
||||
NotificationSettingsAction,
|
||||
NotificationSettingsState,
|
||||
Threshold,
|
||||
} from '../context/types';
|
||||
|
||||
export type UpdateThreshold = {
|
||||
(thresholdId: string, field: 'channels', value: string[]): void;
|
||||
(
|
||||
thresholdId: string,
|
||||
field: Exclude<keyof Threshold, 'channels'>,
|
||||
value: string,
|
||||
value: string | number | null,
|
||||
): void;
|
||||
};
|
||||
|
||||
@ -20,4 +24,20 @@ export interface ThresholdItemProps {
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
}
|
||||
|
||||
export interface RoutingPolicyBannerProps {
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: (
|
||||
notificationSettings: NotificationSettingsAction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import { Button, Flex, Switch, Typography } from 'antd';
|
||||
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
|
||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
|
||||
@ -44,3 +54,360 @@ export function getCategorySelectOptionByName(
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
const getOperatorWord = (op: AlertThresholdOperator): string => {
|
||||
switch (op) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return 'exceed';
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return 'fall below';
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return 'equal';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 'not equal';
|
||||
default:
|
||||
return 'exceed';
|
||||
}
|
||||
};
|
||||
|
||||
const getThresholdValue = (op: AlertThresholdOperator): number => {
|
||||
switch (op) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return 80;
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return 50;
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return 100;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 0;
|
||||
default:
|
||||
return 80;
|
||||
}
|
||||
};
|
||||
|
||||
const getDataPoints = (
|
||||
matchType: AlertThresholdMatchType,
|
||||
op: AlertThresholdOperator,
|
||||
): number[] => {
|
||||
const dataPointMap: Record<
|
||||
AlertThresholdMatchType,
|
||||
Record<AlertThresholdOperator, number[]>
|
||||
> = {
|
||||
[AlertThresholdMatchType.AT_LEAST_ONCE]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ALL_THE_TIME]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ON_AVERAGE]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.IN_TOTAL]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
|
||||
},
|
||||
[AlertThresholdMatchType.LAST]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
};
|
||||
|
||||
return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95];
|
||||
};
|
||||
|
||||
const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
|
||||
const symbolMap: Record<AlertThresholdOperator, string> = {
|
||||
[AlertThresholdOperator.IS_ABOVE]: '>',
|
||||
[AlertThresholdOperator.IS_BELOW]: '<',
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: '>',
|
||||
};
|
||||
return symbolMap[op] || '>';
|
||||
};
|
||||
|
||||
const handleTooltipClick = (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
function TooltipContent({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleTooltipClick}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleTooltipClick(e);
|
||||
}
|
||||
}}
|
||||
className="tooltip-content"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipExample({
|
||||
children,
|
||||
dataPoints,
|
||||
operatorSymbol,
|
||||
thresholdValue,
|
||||
matchType,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
dataPoints: number[];
|
||||
operatorSymbol: string;
|
||||
thresholdValue: number;
|
||||
matchType: AlertThresholdMatchType;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="tooltip-example">
|
||||
<strong>Example:</strong>
|
||||
<br />
|
||||
Say, For a 5-minute window (configured in Evaluation settings), 1 min
|
||||
aggregation interval (set up in query) → 5{' '}
|
||||
{matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points'}
|
||||
: [{dataPoints.join(', ')}]<br />
|
||||
With threshold {operatorSymbol} {thresholdValue}: {children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipLink(): JSX.Element {
|
||||
return (
|
||||
<div className="tooltip-link">
|
||||
<a
|
||||
href="https://signoz.io/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tooltip-link-text"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getMatchTypeTooltip = (
|
||||
matchType: AlertThresholdMatchType,
|
||||
operator: AlertThresholdOperator,
|
||||
): React.ReactNode => {
|
||||
const operatorSymbol = getTooltipOperatorSymbol(operator);
|
||||
const operatorWord = getOperatorWord(operator);
|
||||
const thresholdValue = getThresholdValue(operator);
|
||||
const dataPoints = getDataPoints(matchType, operator);
|
||||
const getMatchingPointsCount = (): number =>
|
||||
dataPoints.filter((p) => {
|
||||
switch (operator) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return p > thresholdValue;
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return p < thresholdValue;
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return p === thresholdValue;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return p !== thresholdValue;
|
||||
default:
|
||||
return p > thresholdValue;
|
||||
}
|
||||
}).length;
|
||||
|
||||
switch (matchType) {
|
||||
case AlertThresholdMatchType.AT_LEAST_ONCE:
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if <span>ANY</span> of
|
||||
those aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '}
|
||||
{thresholdValue})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
|
||||
case AlertThresholdMatchType.ALL_THE_TIME:
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if <span>ALL</span>{' '}
|
||||
aggregated data points cross the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (all points {operatorWord} {thresholdValue})<br />
|
||||
If any point was {thresholdValue}, no alert would fire
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
|
||||
case AlertThresholdMatchType.ON_AVERAGE: {
|
||||
const average = (
|
||||
dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length
|
||||
).toFixed(1);
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if the{' '}
|
||||
<span>AVERAGE</span> of all aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (average = {average})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
case AlertThresholdMatchType.IN_TOTAL: {
|
||||
const total = dataPoints.reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if the{' '}
|
||||
<span>SUM</span> of all aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (total = {total})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
case AlertThresholdMatchType.LAST: {
|
||||
const lastPoint = dataPoints[dataPoints.length - 1];
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers based on the{' '}
|
||||
<span>MOST RECENT</span> aggregated data point only.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (last point = {lastPoint})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export function NotificationChannelsNotFoundContent({
|
||||
user,
|
||||
refreshChannels,
|
||||
}: {
|
||||
user: IUser;
|
||||
refreshChannels: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Flex gap={4} align="center">
|
||||
<Typography.Text>No channels yet.</Typography.Text>
|
||||
{user?.role === USER_ROLES.ADMIN ? (
|
||||
<Typography.Text>
|
||||
Create one
|
||||
<Button
|
||||
style={{ padding: '0 4px' }}
|
||||
type="link"
|
||||
onClick={(): void => {
|
||||
window.open(ROUTES.CHANNELS_NEW, '_blank');
|
||||
}}
|
||||
>
|
||||
here.
|
||||
</Button>
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text>Please ask your admin to create one.</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Button type="text" onClick={refreshChannels}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingPolicyBanner({
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
}: RoutingPolicyBannerProps): JSX.Element {
|
||||
return (
|
||||
<div className="routing-policies-info-banner">
|
||||
<Typography.Text>
|
||||
Use <strong>Routing Policies</strong> for dynamic routing
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -38,7 +38,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-header__content">
|
||||
<input
|
||||
type="text"
|
||||
@ -49,15 +48,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={alertState.description}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input description"
|
||||
placeholder="Click to add description..."
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
onLabelsChange={(labels: Labels): void =>
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
|
||||
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
|
||||
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.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@ -27,7 +39,7 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
@ -44,14 +56,6 @@ describe('CreateAlertHeader', () => {
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LabelsInput component', () => {
|
||||
renderCreateAlertHeader();
|
||||
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
|
||||
@ -65,13 +69,4 @@ describe('CreateAlertHeader', () => {
|
||||
|
||||
expect(nameInput).toHaveValue('Test Alert');
|
||||
});
|
||||
|
||||
it('updates description when typing in description input', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
|
||||
expect(descriptionInput).toHaveValue('Test Description');
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,21 +3,6 @@
|
||||
font-family: inherit;
|
||||
color: var(--text-vanilla-100);
|
||||
|
||||
/* Top bar with diagonal stripes */
|
||||
&__tab-bar {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#0f0f0f,
|
||||
#0f0f0f 10px,
|
||||
#101010 10px,
|
||||
#101010 20px
|
||||
);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Tab block visuals */
|
||||
&__tab {
|
||||
display: flex;
|
||||
@ -44,6 +29,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__input.title {
|
||||
@ -51,6 +38,8 @@
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-100);
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
&__input:focus,
|
||||
@ -64,6 +53,15 @@
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
color: var(--text-vanilla-100);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-input {
|
||||
@ -149,3 +147,65 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-header {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
|
||||
&__tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
&__tab::before {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
&__content {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__input.title {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
&__input.description {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.labels-input {
|
||||
&__add-button {
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__label-pill {
|
||||
background-color: #ad7f581a;
|
||||
color: var(--bg-sienna-400);
|
||||
border: 1px solid var(--bg-sienna-500);
|
||||
}
|
||||
|
||||
&__remove-button {
|
||||
color: var(--bg-sienna-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
$top-nav-background-1: #0f0f0f;
|
||||
$top-nav-background-2: #101010;
|
||||
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-ink-500);
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$top-nav-background-1,
|
||||
$top-nav-background-1 10px,
|
||||
$top-nav-background-2 10px,
|
||||
$top-nav-background-2 20px
|
||||
);
|
||||
margin-bottom: 0;
|
||||
.lightMode {
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-page-spinner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 10000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@ -2,27 +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 />
|
||||
@ -30,6 +35,7 @@ function CreateAlertV2({
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,15 @@ 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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
@ -80,8 +66,15 @@ function AdvancedOptions(): JSX.Element {
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
onToggle={(): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <AdvancedOptionItem
|
||||
title="Account for data delay"
|
||||
description="Shift the evaluation window backwards to account for data processing delays."
|
||||
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
|
||||
@ -119,7 +112,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import './styles.scss';
|
||||
import '../AdvancedOptionItem/styles.scss';
|
||||
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../../context';
|
||||
@ -36,10 +36,10 @@ function EvaluationCadence(): JSX.Element {
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence.mode]);
|
||||
|
||||
const showCustomSchedule = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
setIsCustomScheduleButtonVisible(false);
|
||||
};
|
||||
// const showCustomSchedule = (): void => {
|
||||
// setIsEvaluationCadenceDetailsVisible(true);
|
||||
// setIsCustomScheduleButtonVisible(false);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-container">
|
||||
@ -98,13 +98,14 @@ function EvaluationCadence(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
<Button
|
||||
{/* TODO: Add custom schedule back once the functionality is implemented */}
|
||||
{/* <Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Typography.Text>Add custom schedule</Typography.Text>
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -164,6 +164,14 @@
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -529,6 +537,15 @@
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import { useMemo } from 'react';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
@ -116,7 +116,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
@ -134,7 +136,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
@ -159,7 +163,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
@ -192,7 +198,9 @@ function EvaluationWindowDetails({
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
|
||||
@ -3,10 +3,10 @@ import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
@ -96,7 +96,9 @@ function EvaluationWindowPopover({
|
||||
}
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
@ -108,7 +110,9 @@ function EvaluationWindowPopover({
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -26,6 +26,11 @@ export const createMockAlertContextState = (
|
||||
setEvaluationWindow: jest.fn(),
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
setNotificationSettings: jest.fn(),
|
||||
discardAlertRule: jest.fn(),
|
||||
testAlertRule: jest.fn(),
|
||||
isCreatingAlertRule: false,
|
||||
isTestingAlertRule: false,
|
||||
createAlertRule: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@ -62,8 +62,87 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export const CUMULATIVE_WINDOW_DESCRIPTION =
|
||||
'A Cumulative Window has a fixed starting point and expands over time.';
|
||||
export const getCumulativeWindowDescription = (timeframe?: string): string => {
|
||||
let example = '';
|
||||
switch (timeframe) {
|
||||
case 'currentHour':
|
||||
example =
|
||||
'An hourly cumulative window for error count alerts when errors exceed 100. Starting at the top of the hour, it tracks: 20 errors by :15, 55 by :30, 105 by :45 (alert fires).';
|
||||
break;
|
||||
case 'currentDay':
|
||||
example =
|
||||
'A daily cumulative window for sales alerts when total revenue exceeds $10,000. Starting at midnight, it tracks: $2,000 by 9 AM, $5,500 by noon, $11,000 by 3 PM (alert fires).';
|
||||
break;
|
||||
case 'currentMonth':
|
||||
example =
|
||||
'A monthly cumulative window for expense alerts when spending exceeds $50,000. Starting on the 1st, it tracks: $15,000 by the 7th, $32,000 by the 15th, $51,000 by the 22nd (alert fires).';
|
||||
break;
|
||||
default:
|
||||
example = '';
|
||||
}
|
||||
return `Monitors data accumulated since a fixed starting point. The window grows over time, keeping all historical data from the start.\n\nExample: ${example}`;
|
||||
};
|
||||
|
||||
export const ROLLING_WINDOW_DESCRIPTION =
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export const getRollingWindowDescription = (duration?: string): string => {
|
||||
let timeWindow = '5-minute';
|
||||
let examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
|
||||
|
||||
if (duration) {
|
||||
const match = duration.match(/^(\d+)([mhs])/);
|
||||
if (match) {
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 'm' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-minute`;
|
||||
const endMinutes1 = 1 + value;
|
||||
const endMinutes2 = 2 + value;
|
||||
examples = `14:01:00-14:${String(endMinutes1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
|
||||
} else if (unit === 'h' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-hour`;
|
||||
const endHour1 = 14 + value;
|
||||
const endHour2 = 14 + value;
|
||||
examples = `14:00:00-${String(endHour1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00:00, 14:01:00-${String(endHour2).padStart(2, '0')}:01:00`;
|
||||
} else if (unit === 's' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-second`;
|
||||
examples = `14:01:00-14:01:${String(value).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}, 14:01:01-14:01:${String(1 + value).padStart(2, '0')}`;
|
||||
}
|
||||
} else if (duration === 'custom') {
|
||||
timeWindow = '5-minute';
|
||||
examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
|
||||
} else if (duration.includes('h')) {
|
||||
const hours = parseInt(duration, 10);
|
||||
if (!Number.isNaN(hours)) {
|
||||
timeWindow = `${hours}-hour`;
|
||||
const endHour = 14 + hours;
|
||||
examples = `14:00:00-${String(endHour).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00:00, 14:01:00-${String(endHour).padStart(2, '0')}:01:00`;
|
||||
}
|
||||
} else if (duration.includes('m')) {
|
||||
const minutes = parseInt(duration, 10);
|
||||
if (!Number.isNaN(minutes)) {
|
||||
timeWindow = `${minutes}-minute`;
|
||||
const endMinutes1 = 1 + minutes;
|
||||
const endMinutes2 = 2 + minutes;
|
||||
examples = `14:01:00-14:${String(endMinutes1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `Monitors data over a fixed time period that moves forward continuously.\n\nExample: A ${timeWindow} rolling window for error rate alerts with 1 minute evaluation cadence. Unlike fixed windows, this checks continuously: ${examples}, etc.`;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
169
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal file
169
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Check, Send, X } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
const {
|
||||
alertType,
|
||||
alertState: basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
} = useCreateAlertState();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleDiscard = (): void => discardAlertRule();
|
||||
|
||||
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,
|
||||
});
|
||||
createAlertRule(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Alert rule created successfully');
|
||||
safeNavigate('/alerts');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptions,
|
||||
evaluationWindow,
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
createAlertRule,
|
||||
safeNavigate,
|
||||
]);
|
||||
|
||||
const disableButtons =
|
||||
isCreatingAlertRule || isTestingAlertRule || !!alertValidationMessage;
|
||||
|
||||
const saveAlertButton = useMemo(() => {
|
||||
let button = (
|
||||
<Button type="primary" onClick={handleSaveAlert} disabled={disableButtons}>
|
||||
<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}
|
||||
>
|
||||
<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} disabled={disableButtons}>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
<div className="button-group">
|
||||
{testAlertButton}
|
||||
{saveAlertButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
3
frontend/src/container/CreateAlertV2/Footer/index.ts
Normal file
3
frontend/src/container/CreateAlertV2/Footer/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Footer from './Footer';
|
||||
|
||||
export default Footer;
|
||||
51
frontend/src/container/CreateAlertV2/Footer/styles.scss
Normal file
51
frontend/src/container/CreateAlertV2/Footer/styles.scss
Normal file
@ -0,0 +1,51 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--bg-ink-500);
|
||||
height: 70px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-btn-default {
|
||||
background-color: var(--bg-slate-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-alert-v2-footer {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-btn-default {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-btn-primary {
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
347
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal file
347
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
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
|
||||
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'] = {
|
||||
notificationGroupBy: notificationSettings.multipleNotifications || [],
|
||||
alertStates: notificationSettings.reNotification.enabled
|
||||
? notificationSettings.reNotification.conditions
|
||||
: [],
|
||||
notificationPolicy: notificationSettings.routingPolicies,
|
||||
};
|
||||
|
||||
if (notificationSettings.reNotification.enabled) {
|
||||
notificationSettingsProps.renotify = getFormattedTimeValue(
|
||||
notificationSettings.reNotification.value,
|
||||
notificationSettings.reNotification.unit,
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@ -67,12 +67,13 @@ function NotificationMessage(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</Popover> */}
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
@ -16,7 +17,7 @@ export interface ChartPreviewProps {
|
||||
|
||||
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const { thresholdState, alertState } = useCreateAlertState();
|
||||
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@ -25,14 +26,24 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const yAxisUnit = alertState.yAxisUnit || '';
|
||||
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={
|
||||
const headline = (
|
||||
<div className="chart-preview-headline">
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
}
|
||||
<YAxisUnitSelector
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={headline}
|
||||
name=""
|
||||
query={stagedQuery}
|
||||
selectedInterval={globalSelectedInterval}
|
||||
@ -47,12 +58,7 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const renderPromAndChQueryChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
}
|
||||
headline={headline}
|
||||
name="Chart Preview"
|
||||
query={stagedQuery}
|
||||
alertDef={alertDef}
|
||||
|
||||
@ -2,12 +2,13 @@ import './styles.scss';
|
||||
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import Stepper from '../Stepper';
|
||||
@ -15,17 +16,20 @@ import ChartPreview from './ChartPreview';
|
||||
import { buildAlertDefForChartPreview } from './utils';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertState,
|
||||
setAlertState,
|
||||
alertType,
|
||||
setAlertType,
|
||||
thresholdState,
|
||||
} = useCreateAlertState();
|
||||
currentQuery,
|
||||
handleRunQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
|
||||
|
||||
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
|
||||
|
||||
const onQueryCategoryChange = (val: EQueryType): void => {
|
||||
const query: Query = { ...currentQuery, queryType: val };
|
||||
redirectWithQueryBuilderData(query);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Metrics',
|
||||
@ -51,17 +55,8 @@ function QuerySection(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper
|
||||
stepNumber={1}
|
||||
label="Define the query you want to set an alert on"
|
||||
/>
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
<ChartPreview alertDef={alertDef} />
|
||||
<YAxisUnitSelector
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
/>
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
@ -82,7 +77,7 @@ function QuerySection(): JSX.Element {
|
||||
</div>
|
||||
<QuerySectionComponent
|
||||
queryCategory={currentQuery.queryType}
|
||||
setQueryCategory={(): void => {}}
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType}
|
||||
runQuery={handleRunQuery}
|
||||
alertDef={alertDef}
|
||||
|
||||
@ -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>
|
||||
@ -135,7 +136,7 @@ describe('QuerySection', () => {
|
||||
expect(screen.getByTestId('stepper')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('step-number')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('step-label')).toHaveTextContent(
|
||||
'Define the query you want to set an alert on',
|
||||
'Define the query',
|
||||
);
|
||||
|
||||
// Check if ChartPreview is rendered
|
||||
@ -186,6 +187,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@ -200,6 +202,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
|
||||
@ -77,6 +77,14 @@
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -88,6 +96,18 @@
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
.ant-card-body {
|
||||
background-color: var(--bg-ink-500);
|
||||
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.y-axis-unit-selector-component {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,3 +119,78 @@
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.query-section {
|
||||
.query-section-tabs {
|
||||
.query-section-query-actions {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.explorer-view-option {
|
||||
border-left: 0.5px solid var(--bg-vanilla-300);
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.y-axis-unit-selector-component {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-preview-container {
|
||||
.alert-chart-container {
|
||||
.ant-card {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
.ant-card-body {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
.chart-preview-header {
|
||||
.plot-tag {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-slate-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-query-section-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,3 +42,22 @@
|
||||
background-position: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.step-number {
|
||||
background-color: var(--bg-robin-400);
|
||||
color: var(--text-slate-400);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.dotted-line {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--bg-ink-200) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
name: '',
|
||||
description: '',
|
||||
labels: {},
|
||||
yAxisUnit: undefined,
|
||||
};
|
||||
@ -30,7 +29,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'CRITICAL',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_SAKURA_500,
|
||||
@ -40,7 +39,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'WARNING',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_AMBER_500,
|
||||
@ -50,7 +49,7 @@ export const INITIAL_INFO_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'INFO',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_ROBIN_500,
|
||||
@ -60,7 +59,7 @@ export const INITIAL_RANDOM_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: '',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: getRandomColor(),
|
||||
@ -80,9 +79,11 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 15,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
enabled: false,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
enabled: false,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 5,
|
||||
@ -120,10 +121,10 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
};
|
||||
|
||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'IS EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'IS NOT EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
|
||||
];
|
||||
|
||||
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
|
||||
@ -169,7 +170,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 =
|
||||
@ -189,4 +189,5 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
routingPolicies: false,
|
||||
};
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
|
||||
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import {
|
||||
@ -72,6 +75,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 +114,35 @@ export function CreateAlertProvider(
|
||||
});
|
||||
}, [alertType]);
|
||||
|
||||
const discardAlertRule = useCallback(() => {
|
||||
setAlertState({
|
||||
type: 'RESET',
|
||||
});
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
});
|
||||
setEvaluationWindow({
|
||||
type: 'RESET',
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'RESET',
|
||||
});
|
||||
setNotificationSettings({
|
||||
type: 'RESET',
|
||||
});
|
||||
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
|
||||
}, [handleAlertTypeChange]);
|
||||
|
||||
const {
|
||||
mutate: createAlertRule,
|
||||
isLoading: isCreatingAlertRule,
|
||||
} = useCreateAlertRule();
|
||||
|
||||
const {
|
||||
mutate: testAlertRule,
|
||||
isLoading: isTestingAlertRule,
|
||||
} = useTestAlertRule();
|
||||
|
||||
const contextValue: ICreateAlertContextProps = useMemo(
|
||||
() => ({
|
||||
alertState,
|
||||
@ -121,6 +157,11 @@ export function CreateAlertProvider(
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
@ -130,6 +171,11 @@ export function CreateAlertProvider(
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
|
||||
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
||||
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';
|
||||
|
||||
export interface ICreateAlertContextProps {
|
||||
@ -16,10 +21,26 @@ 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;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialAlertType: AlertTypes;
|
||||
}
|
||||
|
||||
export enum AlertCreationStep {
|
||||
@ -31,14 +52,12 @@ export enum AlertCreationStep {
|
||||
|
||||
export interface AlertState {
|
||||
name: string;
|
||||
description: string;
|
||||
labels: Labels;
|
||||
yAxisUnit: string | undefined;
|
||||
}
|
||||
|
||||
export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'RESET' };
|
||||
@ -47,7 +66,7 @@ export interface Threshold {
|
||||
id: string;
|
||||
label: string;
|
||||
thresholdValue: number;
|
||||
recoveryThresholdValue: number;
|
||||
recoveryThresholdValue: number | null;
|
||||
unit: string;
|
||||
channels: string[];
|
||||
color: string;
|
||||
@ -114,9 +133,11 @@ export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
@ -147,10 +168,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 };
|
||||
@ -203,6 +232,7 @@ export interface NotificationSettingsState {
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
description: string;
|
||||
routingPolicies: boolean;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
@ -220,4 +250,5 @@ export type NotificationSettingsAction =
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@ -41,11 +41,6 @@ export const alertCreationReducer = (
|
||||
...state,
|
||||
name: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_DESCRIPTION':
|
||||
return {
|
||||
...state,
|
||||
description: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_LABELS':
|
||||
return {
|
||||
...state,
|
||||
@ -99,6 +94,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)
|
||||
@ -131,9 +130,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':
|
||||
@ -190,6 +218,8 @@ 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;
|
||||
default:
|
||||
|
||||
5
frontend/src/container/CreateAlertV2/types.ts
Normal file
5
frontend/src/container/CreateAlertV2/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
export interface CreateAlertV2Props {
|
||||
alertType: AlertTypes;
|
||||
}
|
||||
@ -1,3 +1,8 @@
|
||||
import { Spin } from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useCreateAlertState } from './context';
|
||||
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
@ -7,3 +12,16 @@ export const showNewCreateAlertsPage = (): boolean =>
|
||||
// Layout 2 - Condensed layout
|
||||
export const showCondensedLayout = (): boolean =>
|
||||
localStorage.getItem('showCondensedLayout') === 'true';
|
||||
|
||||
export function Spinner(): JSX.Element | null {
|
||||
const { isCreatingAlertRule } = useCreateAlertState();
|
||||
|
||||
if (!isCreatingAlertRule) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="sticky-page-spinner">
|
||||
<Spin size="large" spinning />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.prom-ql-icon {
|
||||
height: 14px;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import './QuerySection.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Tabs, Tooltip } from 'antd';
|
||||
import { Button, Tabs, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
@ -71,6 +71,7 @@ function QuerySection({
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -81,6 +82,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -95,6 +97,7 @@ function QuerySection({
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -105,6 +108,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -117,6 +121,7 @@ function QuerySection({
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
<Typography.Text>PromQL</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
20
frontend/src/hooks/alerts/useCreateAlertRule.ts
Normal file
20
frontend/src/hooks/alerts/useCreateAlertRule.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import createAlertRule, {
|
||||
CreateAlertRuleResponse,
|
||||
} from 'api/alerts/createAlertRule';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
export function useCreateAlertRule(): UseMutationResult<
|
||||
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2
|
||||
> {
|
||||
return useMutation<
|
||||
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2
|
||||
>({
|
||||
mutationFn: (alertData) => createAlertRule(alertData),
|
||||
});
|
||||
}
|
||||
18
frontend/src/hooks/alerts/useTestAlertRule.ts
Normal file
18
frontend/src/hooks/alerts/useTestAlertRule.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import testAlertRule, { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
export function useTestAlertRule(): UseMutationResult<
|
||||
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2
|
||||
> {
|
||||
return useMutation<
|
||||
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2
|
||||
>({
|
||||
mutationFn: (alertData) => testAlertRule(alertData),
|
||||
});
|
||||
}
|
||||
@ -1,16 +1,6 @@
|
||||
import CreateAlertRule from 'container/CreateAlertRule';
|
||||
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const CreateAlertV2 = lazy(() => import('container/CreateAlertV2'));
|
||||
|
||||
function CreateAlertPage(): JSX.Element {
|
||||
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
|
||||
|
||||
if (showNewCreateAlertsPageFlag) {
|
||||
return <CreateAlertV2 />;
|
||||
}
|
||||
|
||||
return <CreateAlertRule />;
|
||||
}
|
||||
|
||||
|
||||
68
frontend/src/types/api/alerts/alertTypesV2.ts
Normal file
68
frontend/src/types/api/alerts/alertTypesV2.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { AlertTypes } from './alertTypes';
|
||||
import { ICompositeMetricQuery } from './compositeQuery';
|
||||
import { Labels } from './def';
|
||||
|
||||
export interface BasicThreshold {
|
||||
name: string;
|
||||
target: number;
|
||||
matchType: string;
|
||||
op: string;
|
||||
channels: string[];
|
||||
targetUnit: string;
|
||||
}
|
||||
|
||||
export interface PostableAlertRuleV2 {
|
||||
schemaVersion: string;
|
||||
alert: string;
|
||||
alertType: AlertTypes;
|
||||
ruleType: string;
|
||||
condition: {
|
||||
thresholds?: {
|
||||
kind: string;
|
||||
spec: BasicThreshold[];
|
||||
};
|
||||
compositeQuery: ICompositeMetricQuery;
|
||||
selectedQueryName?: string;
|
||||
alertOnAbsent?: boolean;
|
||||
absentFor?: number;
|
||||
requireMinPoints?: boolean;
|
||||
requiredNumPoints?: number;
|
||||
};
|
||||
evaluation: {
|
||||
kind: 'rolling' | 'cumulative';
|
||||
spec: {
|
||||
evalWindow?: string;
|
||||
frequency: string;
|
||||
schedule?: {
|
||||
type: 'hourly' | 'daily' | 'monthly';
|
||||
minute?: number;
|
||||
hour?: number;
|
||||
day?: number;
|
||||
};
|
||||
timezone?: string;
|
||||
};
|
||||
};
|
||||
labels: Labels;
|
||||
annotations: {
|
||||
description: string;
|
||||
summary: string;
|
||||
};
|
||||
notificationSettings: {
|
||||
notificationGroupBy: string[];
|
||||
renotify?: string;
|
||||
alertStates: string[];
|
||||
notificationPolicy: boolean;
|
||||
};
|
||||
version: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleV2 extends PostableAlertRuleV2 {
|
||||
schemaVersion: string;
|
||||
state: string;
|
||||
disabled: boolean;
|
||||
createAt: string;
|
||||
createBy: string;
|
||||
updateAt: string;
|
||||
updateBy: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user