Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9e90aff1 | ||
|
|
57ec4e9a7a | ||
|
|
fdad0663e9 | ||
|
|
b54ecedac3 | ||
|
|
834ff461ce | ||
|
|
39245b39cc | ||
|
|
78ceea4f43 | ||
|
|
191f72818a | ||
|
|
de1b7deb8d | ||
|
|
53aa06bff0 | ||
|
|
9c5e4c86c0 | ||
|
|
58e802af93 | ||
|
|
7c1b694679 | ||
|
|
a63d490dc5 | ||
|
|
9daa4009bd | ||
|
|
954d1f2641 | ||
|
|
dcac7ec8c7 | ||
|
|
233e1d79d7 | ||
|
|
429ecba355 | ||
|
|
4a84d2a666 | ||
|
|
f58e2dcc5c | ||
|
|
f412b908c3 | ||
|
|
1d1fd6ae26 | ||
|
|
07ecb4b967 | ||
|
|
87ce18ca1d | ||
|
|
a09aea2b8e |
@@ -137,6 +137,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
||||
return createTimezoneEntry(normalizedTz, offset);
|
||||
});
|
||||
|
||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
||||
export const generateTimezoneData = (
|
||||
includeEtcTimezones = false,
|
||||
): Timezone[] => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
@@ -6,13 +7,16 @@ import { Activity, ChartLine } from 'lucide-react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import AlertThreshold from './AlertThreshold';
|
||||
import AnomalyThreshold from './AnomalyThreshold';
|
||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
@@ -75,6 +79,11 @@ function AlertCondition(): JSX.Element {
|
||||
</div>
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
|
||||
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
|
||||
{showCondensedLayoutFlag ? (
|
||||
<div className="condensed-advanced-options-container">
|
||||
<AdvancedOptions />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useQuery } from 'react-query';
|
||||
@@ -17,11 +19,14 @@ import {
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { UpdateThreshold } from './types';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
getMatchTypeTooltip,
|
||||
getQueryNames,
|
||||
} from './utils';
|
||||
|
||||
@@ -37,6 +42,7 @@ function AlertThreshold(): JSX.Element {
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
const channels = data?.data || [];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
@@ -81,8 +87,47 @@ function AlertThreshold(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
<strong>Evaluation Window.</strong>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-threshold-container">
|
||||
<div
|
||||
className={classNames('alert-threshold-container', {
|
||||
'condensed-alert-threshold-container': showCondensedLayoutFlag,
|
||||
})}
|
||||
>
|
||||
{/* Main condition sentence */}
|
||||
<div className="alert-condition-sentences">
|
||||
<div className="alert-condition-sentence">
|
||||
@@ -100,8 +145,7 @@ function AlertThreshold(): JSX.Element {
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.operator}
|
||||
onChange={(value): void => {
|
||||
@@ -110,7 +154,7 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
@@ -124,11 +168,11 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
options={THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the <strong>Evaluation Window.</strong>
|
||||
during the {evaluationWindowContext}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX } from 'lucide-react';
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX, Trash } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { AlertThresholdOperator } from '../context/types';
|
||||
import { ThresholdItemProps } from './types';
|
||||
|
||||
function ThresholdItem({
|
||||
@@ -12,6 +14,7 @@ function ThresholdItem({
|
||||
channels,
|
||||
units,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { thresholdState } = useCreateAlertState();
|
||||
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
|
||||
|
||||
const yAxisUnitSelect = useMemo(() => {
|
||||
@@ -45,6 +48,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 +82,98 @@ function ThresholdItem({
|
||||
style={{ backgroundColor: threshold.color }}
|
||||
/>
|
||||
</div>
|
||||
<Space className="threshold-controls">
|
||||
<div className="threshold-inputs">
|
||||
<Input.Group>
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
</Input.Group>
|
||||
</div>
|
||||
<Typography.Text className="sentence-text">to</Typography.Text>
|
||||
<div className="threshold-controls">
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
<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,
|
||||
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
|
||||
}
|
||||
/>
|
||||
{showRecoveryThreshold && (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">recover on</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue ?? ''}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
<Tooltip title="Remove recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Trash size={16} />}
|
||||
onClick={removeRecoveryThreshold}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Button.Group>
|
||||
{!showRecoveryThreshold && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={(): void => setShowRecoveryThreshold(true)}
|
||||
/>
|
||||
<Tooltip title="Add recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={addRecoveryThreshold}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
<Tooltip title="Remove threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
{showRecoveryThreshold && (
|
||||
<Input.Group className="recovery-threshold-input-group">
|
||||
<Input
|
||||
placeholder="Recovery threshold"
|
||||
disabled
|
||||
style={{ width: 260 }}
|
||||
className="recovery-threshold-label"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
</Input.Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,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 =>
|
||||
@@ -125,7 +125,12 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
};
|
||||
|
||||
const verifySelectRenders = (title: string): void => {
|
||||
const select = screen.getByTitle(title);
|
||||
// Try to find by title first, if not found, try to find by text content
|
||||
let select = screen.queryByTitle(title);
|
||||
if (!select) {
|
||||
// For match type select, look for the text content instead
|
||||
select = screen.getByText(title);
|
||||
}
|
||||
expect(select).toBeInTheDocument();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,15 +2,36 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { 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.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock: any = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
uplotMock.paths = paths;
|
||||
return uplotMock;
|
||||
});
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
alertState: INITIAL_ALERT_STATE,
|
||||
setAlertState: mockSetAlertState,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
setThresholdState: mockSetThresholdState,
|
||||
} as any);
|
||||
|
||||
const TEST_CONSTANTS = {
|
||||
THRESHOLD_ID: 'test-threshold-1',
|
||||
@@ -21,6 +42,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',
|
||||
@@ -122,7 +144,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', () => {
|
||||
@@ -135,9 +157,8 @@ describe('ThresholdItem', () => {
|
||||
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),
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -251,7 +272,9 @@ 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();
|
||||
@@ -295,7 +318,7 @@ describe('ThresholdItem', () => {
|
||||
|
||||
// Check that channels are rendered as multiple select
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select multiple channels
|
||||
@@ -318,7 +341,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 +354,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', () => {
|
||||
@@ -357,30 +380,7 @@ describe('ThresholdItem', () => {
|
||||
const recoveryValueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(recoveryValueInput).toHaveValue('80');
|
||||
});
|
||||
|
||||
it('renders recovery threshold label as disabled', () => {
|
||||
renderThresholdItem();
|
||||
showRecoveryThreshold();
|
||||
|
||||
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
|
||||
expect(recoveryLabelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders correct channel options', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check that channels are rendered
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select different channels
|
||||
const channelSelectors = screen.getAllByRole('combobox');
|
||||
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
|
||||
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
|
||||
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
|
||||
expect(recoveryValueInput).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('handles threshold without channels', () => {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
padding-right: 72px;
|
||||
background-color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
|
||||
.alert-condition-sentences {
|
||||
display: flex;
|
||||
@@ -84,10 +84,13 @@
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px !important;
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
@@ -145,6 +148,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
@@ -275,3 +279,272 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
.condensed-anomaly-threshold-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.condensed-advanced-options-container {
|
||||
margin-top: 16px;
|
||||
width: fit-parent;
|
||||
}
|
||||
|
||||
.condensed-evaluation-settings-container {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--bg-slate-400);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-condition-container {
|
||||
.alert-condition {
|
||||
.alert-condition-tabs {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.explorer-view-option {
|
||||
border-left: 0.5px solid var(--bg-vanilla-300);
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-threshold-container,
|
||||
.anomaly-threshold-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.alert-condition-sentences {
|
||||
.alert-condition-sentence {
|
||||
.sentence-text {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
.threshold-item {
|
||||
.threshold-row {
|
||||
.threshold-controls {
|
||||
.threshold-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recovery-threshold-input-group {
|
||||
.recovery-threshold-btn {
|
||||
color: var(--bg-ink-400);
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type UpdateThreshold = {
|
||||
(
|
||||
thresholdId: string,
|
||||
field: Exclude<keyof Threshold, 'channels'>,
|
||||
value: string,
|
||||
value: string | number | null,
|
||||
): void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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 {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -44,3 +48,303 @@ 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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,15 +49,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 =>
|
||||
|
||||
@@ -44,14 +44,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 +57,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,3 +149,75 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-header {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
|
||||
&__tab-bar {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#f5f5f5,
|
||||
#f5f5f5 10px,
|
||||
#e5e5e5 10px,
|
||||
#e5e5e5 20px
|
||||
);
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
&__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,8 +1,12 @@
|
||||
$top-nav-background-1: #0f0f0f;
|
||||
$top-nav-background-2: #101010;
|
||||
|
||||
$top-nav-background-1-light: #f5f5f5;
|
||||
$top-nav-background-2-light: #e5e5e5;
|
||||
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-ink-500);
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
@@ -15,3 +19,19 @@ $top-nav-background-2: #101010;
|
||||
);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$top-nav-background-1-light,
|
||||
$top-nav-background-1-light 10px,
|
||||
$top-nav-background-2-light 10px,
|
||||
$top-nav-background-2-light 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import AlertCondition from './AlertCondition';
|
||||
import { CreateAlertProvider } from './context';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import Footer from './Footer';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
|
||||
function CreateAlertV2({
|
||||
initialQuery = initialQueriesMap.metrics,
|
||||
@@ -16,14 +20,19 @@ function CreateAlertV2({
|
||||
}): JSX.Element {
|
||||
useShareBuilderUrl({ defaultValue: initialQuery });
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider>
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertHeader />
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</div>
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Switch, Tooltip, Typography } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IAdvancedOptionItemProps } from '../types';
|
||||
|
||||
function AdvancedOptionItem({
|
||||
title,
|
||||
description,
|
||||
input,
|
||||
tooltipText,
|
||||
onToggle,
|
||||
}: IAdvancedOptionItemProps): JSX.Element {
|
||||
const [showInput, setShowInput] = useState<boolean>(false);
|
||||
|
||||
const handleOnToggle = (): void => {
|
||||
onToggle?.();
|
||||
setShowInput((currentShowInput) => !currentShowInput);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="advanced-option-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
{title}
|
||||
{tooltipText && (
|
||||
<Tooltip title={tooltipText}>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="advanced-option-item-right-content">
|
||||
{showInput && <div className="advanced-option-item-input">{input}</div>}
|
||||
<Switch onChange={handleOnToggle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdvancedOptionItem;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
|
||||
export default AdvancedOptionItem;
|
||||
@@ -0,0 +1,250 @@
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
margin-top: 16px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.advanced-option-item-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.advanced-option-item {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
.advanced-option-item-input-group {
|
||||
.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);
|
||||
color: var(--bg-ink-400);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptionItem from './AdvancedOptionItem/AdvancedOptionItem';
|
||||
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}>
|
||||
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
|
||||
<EvaluationCadence />
|
||||
<AdvancedOptionItem
|
||||
title="Alert when data stops coming"
|
||||
description="Send notification if no data is received for a specified time period."
|
||||
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter tolerance limit..."
|
||||
type="number"
|
||||
style={{ width: 100 }}
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: Number(e.target.value),
|
||||
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
description="Only trigger alert when there are enough data points to make a reliable decision."
|
||||
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter minimum datapoints..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Account for data delay"
|
||||
description="Shift the evaluation window backwards to account for data processing delays."
|
||||
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter delay..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: Number(e.target.value),
|
||||
timeUnit: advancedOptions.delayEvaluation.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.delay}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: advancedOptions.delayEvaluation.delay,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.timeUnit}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdvancedOptions;
|
||||
@@ -0,0 +1,654 @@
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Calendar,
|
||||
Calendar1,
|
||||
Code,
|
||||
Edit,
|
||||
Edit3Icon,
|
||||
Info,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
} from '../context/constants';
|
||||
import { AdvancedOptionsState } from '../context/types';
|
||||
import {
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import {
|
||||
IEvaluationCadenceDetailsProps,
|
||||
IEvaluationCadencePreviewProps,
|
||||
} from './types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
isValidRRule,
|
||||
TIMEZONE_DATA,
|
||||
} from './utils';
|
||||
|
||||
export function EvaluationCadenceDetails({
|
||||
setIsOpen,
|
||||
}: IEvaluationCadenceDetailsProps): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
const [evaluationCadence, setEvaluationCadence] = useState<
|
||||
AdvancedOptionsState['evaluationCadence']
|
||||
>({
|
||||
...advancedOptions.evaluationCadence,
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Editor',
|
||||
icon: <Edit3Icon size={14} />,
|
||||
value: 'editor',
|
||||
},
|
||||
{
|
||||
label: 'RRule',
|
||||
icon: <Code size={14} />,
|
||||
value: 'rrule',
|
||||
},
|
||||
];
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
|
||||
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
|
||||
);
|
||||
|
||||
const occurenceOptions =
|
||||
evaluationCadence.custom.repeatEvery === 'week'
|
||||
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
|
||||
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
|
||||
|
||||
const EditorView = (
|
||||
<div className="editor-view" data-testid="editor-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>REPEAT EVERY</Typography.Text>
|
||||
<Select
|
||||
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
|
||||
value={evaluationCadence.custom.repeatEvery || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
repeatEvery: value,
|
||||
occurence: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select repeat every"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>ON DAY(S)</Typography.Text>
|
||||
<Select
|
||||
options={occurenceOptions}
|
||||
value={evaluationCadence.custom.occurence || null}
|
||||
mode="multiple"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
occurence: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select day(s)"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.custom.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationCadence.custom.timezone || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
timezone: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RRuleView = (
|
||||
<div className="rrule-view" data-testid="rrule-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON</Typography.Text>
|
||||
<DatePicker
|
||||
value={evaluationCadence.rrule.date}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
date: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.rrule.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
value={evaluationCadence.rrule.rrule}
|
||||
placeholder="Enter RRule"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
rrule: value.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsOpen(false);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveCustomSchedule = (): void => {
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
custom: evaluationCadence.custom,
|
||||
rrule: evaluationCadence.rrule,
|
||||
},
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: evaluationCadence.mode,
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const disableSaveButton = useMemo(() => {
|
||||
if (activeTab === 'editor') {
|
||||
return (
|
||||
!evaluationCadence.custom.repeatEvery ||
|
||||
!evaluationCadence.custom.occurence.length ||
|
||||
!evaluationCadence.custom.startAt ||
|
||||
!evaluationCadence.custom.timezone
|
||||
);
|
||||
}
|
||||
return (
|
||||
!evaluationCadence.rrule.rrule ||
|
||||
!evaluationCadence.rrule.date ||
|
||||
!evaluationCadence.rrule.startAt ||
|
||||
!isValidRRule(evaluationCadence.rrule.rrule)
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (activeTab === 'rrule') {
|
||||
return buildAlertScheduleFromRRule(
|
||||
evaluationCadence.rrule.rrule,
|
||||
evaluationCadence.rrule.date,
|
||||
evaluationCadence.rrule.startAt,
|
||||
15,
|
||||
);
|
||||
}
|
||||
return buildAlertScheduleFromCustomSchedule(
|
||||
evaluationCadence.custom.repeatEvery,
|
||||
evaluationCadence.custom.occurence,
|
||||
evaluationCadence.custom.startAt,
|
||||
evaluationCadence.custom.timezone,
|
||||
15,
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
|
||||
setActiveTab(tab);
|
||||
const mode = tab === 'editor' ? 'custom' : 'rrule';
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-details">
|
||||
<Typography.Text className="evaluation-cadence-details-title">
|
||||
Add Custom Schedule
|
||||
</Typography.Text>
|
||||
<div className="evaluation-cadence-details-content">
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': activeTab === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
handleChangeTab(tab.value as 'editor' | 'rrule');
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'editor' && EditorView}
|
||||
{activeTab === 'rrule' && RRuleView}
|
||||
<div className="buttons-row">
|
||||
<Button type="default" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveCustomSchedule}
|
||||
disabled={disableSaveButton}
|
||||
>
|
||||
Save Custom Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
{schedule ? (
|
||||
<div className="schedule-preview">
|
||||
<div className="schedule-preview-header">
|
||||
<Calendar size={16} />
|
||||
<Typography.Text className="schedule-preview-title">
|
||||
Schedule Preview
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="schedule-preview-list">
|
||||
{schedule.map((date) => (
|
||||
<div key={date.toISOString()} className="schedule-preview-item">
|
||||
<div className="schedule-preview-timeline">
|
||||
<div className="schedule-preview-timeline-line" />
|
||||
</div>
|
||||
<div className="schedule-preview-content">
|
||||
<div className="schedule-preview-date">
|
||||
{date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
,{' '}
|
||||
{date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className="schedule-preview-separator" />
|
||||
<div className="schedule-preview-timezone">
|
||||
UTC {date.getTimezoneOffset() <= 0 ? '+' : '-'}{' '}
|
||||
{Math.abs(Math.floor(date.getTimezoneOffset() / 60))}:
|
||||
{String(Math.abs(date.getTimezoneOffset() % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-schedule">
|
||||
<Info size={32} />
|
||||
<Typography.Text>
|
||||
Please fill the relevant information to generate a schedule
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationCadencePreview({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: IEvaluationCadencePreviewProps): JSX.Element {
|
||||
const { advancedOptions } = useCreateAlertState();
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (advancedOptions.evaluationCadence.mode === 'rrule') {
|
||||
return buildAlertScheduleFromRRule(
|
||||
advancedOptions.evaluationCadence.rrule.rrule,
|
||||
advancedOptions.evaluationCadence.rrule.date,
|
||||
advancedOptions.evaluationCadence.rrule.startAt,
|
||||
15,
|
||||
);
|
||||
}
|
||||
return buildAlertScheduleFromCustomSchedule(
|
||||
advancedOptions.evaluationCadence.custom.repeatEvery,
|
||||
advancedOptions.evaluationCadence.custom.occurence,
|
||||
advancedOptions.evaluationCadence.custom.startAt,
|
||||
advancedOptions.evaluationCadence.custom.timezone,
|
||||
15,
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence]);
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onCancel={(): void => setIsOpen(false)} footer={null}>
|
||||
<div className="evaluation-cadence-details evaluation-cadence-preview">
|
||||
<div className="evaluation-cadence-details-content">
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
{schedule ? (
|
||||
<div className="schedule-preview">
|
||||
<div className="schedule-preview-header">
|
||||
<Calendar size={16} />
|
||||
<Typography.Text className="schedule-preview-title">
|
||||
Schedule Preview
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="schedule-preview-list">
|
||||
{schedule.map((date) => (
|
||||
<div key={date.toISOString()} className="schedule-preview-item">
|
||||
<div className="schedule-preview-timeline">
|
||||
<div className="schedule-preview-timeline-line" />
|
||||
</div>
|
||||
<div className="schedule-preview-content">
|
||||
<div className="schedule-preview-date">
|
||||
{date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
,{' '}
|
||||
{date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className="schedule-preview-separator" />
|
||||
<div className="schedule-preview-timezone">
|
||||
UTC {date.getTimezoneOffset() <= 0 ? '+' : '-'}{' '}
|
||||
{Math.abs(Math.floor(date.getTimezoneOffset() / 60))}:
|
||||
{String(Math.abs(date.getTimezoneOffset() % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-schedule">
|
||||
<Info size={32} />
|
||||
<Typography.Text>
|
||||
Please fill the relevant information to generate a schedule
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditCustomSchedule({
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
setIsPreviewVisible,
|
||||
}: {
|
||||
setIsEvaluationCadenceDetailsVisible: (isOpen: boolean) => void;
|
||||
setIsPreviewVisible: (isOpen: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (advancedOptions.evaluationCadence.mode === 'custom') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.repeatEvery
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.occurence
|
||||
.map(
|
||||
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
|
||||
)
|
||||
.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Starting on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence]);
|
||||
|
||||
const handleEdit = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
};
|
||||
|
||||
const handlePreview = (): void => {
|
||||
setIsPreviewVisible(true);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(false);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-custom-schedule">
|
||||
{displayText}
|
||||
<div className="button-row">
|
||||
<Button.Group>
|
||||
<Button type="default" onClick={handleEdit}>
|
||||
<Edit size={12} />
|
||||
<Typography.Text>Edit custom schedule</Typography.Text>
|
||||
</Button>
|
||||
<Button type="default" onClick={handlePreview}>
|
||||
<Calendar1 size={12} />
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="discard-button"
|
||||
type="default"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationCadence(): JSX.Element {
|
||||
const [
|
||||
isEvaluationCadenceDetailsVisible,
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
] = useState(false);
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
||||
|
||||
const showCustomScheduleButton = useMemo(
|
||||
() =>
|
||||
!isEvaluationCadenceDetailsVisible &&
|
||||
advancedOptions.evaluationCadence.mode === 'default',
|
||||
[isEvaluationCadenceDetailsVisible, advancedOptions.evaluationCadence.mode],
|
||||
);
|
||||
|
||||
const showCustomSchedule = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'custom',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-container">
|
||||
<div className="advanced-option-item evaluation-cadence-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
How often to check
|
||||
<Tooltip title="Controls how frequently the alert evaluates your conditions. For most alerts, 1-5 minutes is sufficient.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
How frequently this alert checks your data. Default: Every 1 minute
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{showCustomScheduleButton && (
|
||||
<div className="advanced-option-item-right-content">
|
||||
<Input.Group className="advanced-option-item-input-group">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter time"
|
||||
style={{ width: 180 }}
|
||||
value={advancedOptions.evaluationCadence.default.value}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
value: Number(value.target.value),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
placeholder="Select time unit"
|
||||
style={{ width: 120 }}
|
||||
value={advancedOptions.evaluationCadence.default.timeUnit}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
timeUnit: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
<Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Typography.Text>Add custom schedule</Typography.Text>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEvaluationCadenceDetailsVisible &&
|
||||
advancedOptions.evaluationCadence.mode !== 'default' && (
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
setIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={setIsPreviewVisible}
|
||||
/>
|
||||
)}
|
||||
{isEvaluationCadenceDetailsVisible && (
|
||||
<EvaluationCadenceDetails
|
||||
isOpen={isEvaluationCadenceDetailsVisible}
|
||||
setIsOpen={setIsEvaluationCadenceDetailsVisible}
|
||||
/>
|
||||
)}
|
||||
{isPreviewVisible && (
|
||||
<EvaluationCadencePreview
|
||||
isOpen={isPreviewVisible}
|
||||
setIsOpen={setIsPreviewVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationCadence;
|
||||
@@ -0,0 +1,85 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Popover, Typography } from 'antd';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import AdvancedOptions from './AdvancedOptions';
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
const {
|
||||
alertType,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
} = useCreateAlertState();
|
||||
const [
|
||||
isEvaluationWindowPopoverOpen,
|
||||
setIsEvaluationWindowPopoverOpen,
|
||||
] = useState(false);
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const popoverContent = (
|
||||
<Popover
|
||||
open={isEvaluationWindowPopoverOpen}
|
||||
onOpenChange={(visibility: boolean): void => {
|
||||
setIsEvaluationWindowPopoverOpen(visibility);
|
||||
}}
|
||||
content={
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
isOpen={isEvaluationWindowPopoverOpen}
|
||||
setIsOpen={setIsEvaluationWindowPopoverOpen}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
showArrow={false}
|
||||
>
|
||||
<Button>
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
<div className="evaluate-alert-conditions-button-right">
|
||||
<div className="evaluate-alert-conditions-button-right-text">
|
||||
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
|
||||
</div>
|
||||
{isEvaluationWindowPopoverOpen ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
if (showCondensedLayoutFlag) {
|
||||
return (
|
||||
<div className="condensed-evaluation-settings-container">
|
||||
{popoverContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evaluation-settings-container">
|
||||
<Stepper stepNumber={3} label="Evaluation settings" />
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
|
||||
<div className="evaluate-alert-conditions-container">
|
||||
<Typography.Text>Check conditions using data from</Typography.Text>
|
||||
<div className="evaluate-alert-conditions-separator" />
|
||||
{popoverContent}
|
||||
</div>
|
||||
)}
|
||||
<AdvancedOptions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationSettings;
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Button, Input, Select, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { RE_NOTIFICATION_UNIT_OPTIONS } from '../context/constants';
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowDetailsProps,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from './types';
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation';
|
||||
import { TIMEZONE_DATA } from './utils';
|
||||
|
||||
function EvaluationWindowDetails({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowDetailsProps): JSX.Element {
|
||||
const currentHourOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const currentMonthOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe === 'custom'
|
||||
) {
|
||||
return `Last ${evaluationWindow.startingAt.number} ${
|
||||
RE_NOTIFICATION_UNIT_OPTIONS.find(
|
||||
(option) => option.value === evaluationWindow.startingAt.unit,
|
||||
)?.label
|
||||
}${parseInt(evaluationWindow.startingAt.number, 10) > 1 ? 's' : ''}`;
|
||||
}
|
||||
if (evaluationWindow.windowType === 'cumulative') {
|
||||
if (evaluationWindow.timeframe === 'currentHour') {
|
||||
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
|
||||
}
|
||||
if (evaluationWindow.timeframe === 'currentDay') {
|
||||
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
}
|
||||
if (evaluationWindow.timeframe === 'currentMonth') {
|
||||
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, [evaluationWindow]);
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe !== 'custom'
|
||||
) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isCurrentHour =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentHour';
|
||||
const isCurrentDay =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentDay';
|
||||
const isCurrentMonth =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentMonth';
|
||||
|
||||
const handleNumberChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: value,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
<Select
|
||||
options={currentHourOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
<Select
|
||||
options={currentMonthOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
A Rolling Window has a fixed size and shifts its starting point over time
|
||||
based on when the rules are evaluated.
|
||||
</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>VALUE</Typography.Text>
|
||||
<Input
|
||||
name="value"
|
||||
type="number"
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>UNIT</Typography.Text>
|
||||
<Select
|
||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const { containerRef, firstItemRef } = useKeyboardNavigation({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
if (sectionId === 'window-type') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
});
|
||||
} else if (sectionId === 'timeframe') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
});
|
||||
}
|
||||
},
|
||||
onEscape: (): void => {
|
||||
const triggerElement = document.querySelector(
|
||||
'[aria-haspopup="true"]',
|
||||
) as HTMLElement;
|
||||
triggerElement?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
sectionId: string,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item" data-section-id={sectionId}>
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option, index) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(option.value);
|
||||
}
|
||||
}}
|
||||
ref={index === 0 ? firstItemRef : undefined}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Rolling Window has a fixed size and shifts its starting point over time
|
||||
based on when the rules are evaluated.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="evaluation-window-popover"
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
aria-label="Evaluation window options"
|
||||
>
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
'window-type',
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
'timeframe',
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,87 @@
|
||||
.time-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.time-input-field {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
&: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);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.time-input-container {
|
||||
.time-input-field {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
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);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import './TimeInput.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function TimeInput({
|
||||
value = '00:00:00',
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: TimeInputProps): JSX.Element {
|
||||
const [hours, setHours] = useState('00');
|
||||
const [minutes, setMinutes] = useState('00');
|
||||
const [seconds, setSeconds] = useState('00');
|
||||
|
||||
// Parse initial value
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length === 3) {
|
||||
setHours(timeParts[0]);
|
||||
setMinutes(timeParts[1]);
|
||||
setSeconds(timeParts[2]);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Notify parent of changes (raw values during typing)
|
||||
const notifyChange = (h: string, m: string, s: string): void => {
|
||||
const rawValue = `${h}:${m}:${s}`;
|
||||
onChange?.(rawValue);
|
||||
};
|
||||
|
||||
// Notify parent of formatted changes (with padding)
|
||||
const notifyFormattedChange = (h: string, m: string, s: string): void => {
|
||||
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:${s.padStart(2, '0')}`;
|
||||
onChange?.(formattedValue);
|
||||
};
|
||||
|
||||
// Handle hours change
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let newHours = e.target.value.replace(/\D/g, '');
|
||||
|
||||
// Limit to 2 digits
|
||||
if (newHours.length > 2) {
|
||||
newHours = newHours.slice(0, 2);
|
||||
}
|
||||
|
||||
// Validate hours (0-23)
|
||||
if (newHours && parseInt(newHours, 10) > 23) {
|
||||
newHours = '23';
|
||||
}
|
||||
|
||||
setHours(newHours);
|
||||
notifyChange(newHours, minutes, seconds);
|
||||
};
|
||||
|
||||
// Handle minutes change
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let newMinutes = e.target.value.replace(/\D/g, '');
|
||||
|
||||
// Limit to 2 digits
|
||||
if (newMinutes.length > 2) {
|
||||
newMinutes = newMinutes.slice(0, 2);
|
||||
}
|
||||
|
||||
// Validate minutes (0-59)
|
||||
if (newMinutes && parseInt(newMinutes, 10) > 59) {
|
||||
newMinutes = '59';
|
||||
}
|
||||
|
||||
setMinutes(newMinutes);
|
||||
notifyChange(hours, newMinutes, seconds);
|
||||
};
|
||||
|
||||
// Handle seconds change
|
||||
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let newSeconds = e.target.value.replace(/\D/g, '');
|
||||
|
||||
// Limit to 2 digits
|
||||
if (newSeconds.length > 2) {
|
||||
newSeconds = newSeconds.slice(0, 2);
|
||||
}
|
||||
|
||||
// Validate seconds (0-59)
|
||||
if (newSeconds && parseInt(newSeconds, 10) > 59) {
|
||||
newSeconds = '59';
|
||||
}
|
||||
|
||||
setSeconds(newSeconds);
|
||||
notifyChange(hours, minutes, newSeconds);
|
||||
};
|
||||
|
||||
// Handle blur events to format values
|
||||
const handleHoursBlur = (): void => {
|
||||
const formattedHours = hours.padStart(2, '0');
|
||||
setHours(formattedHours);
|
||||
notifyFormattedChange(formattedHours, minutes, seconds);
|
||||
};
|
||||
|
||||
const handleMinutesBlur = (): void => {
|
||||
const formattedMinutes = minutes.padStart(2, '0');
|
||||
setMinutes(formattedMinutes);
|
||||
notifyFormattedChange(hours, formattedMinutes, seconds);
|
||||
};
|
||||
|
||||
const handleSecondsBlur = (): void => {
|
||||
const formattedSeconds = seconds.padStart(2, '0');
|
||||
setSeconds(formattedSeconds);
|
||||
notifyFormattedChange(hours, minutes, formattedSeconds);
|
||||
};
|
||||
|
||||
// Helper functions for field navigation
|
||||
const getNextField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'hours':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'seconds';
|
||||
default:
|
||||
return 'hours';
|
||||
}
|
||||
};
|
||||
|
||||
const getPrevField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'seconds':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'hours';
|
||||
default:
|
||||
return 'seconds';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key navigation
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
currentField: 'hours' | 'minutes' | 'seconds',
|
||||
): void => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const nextField = document.querySelector(
|
||||
`[data-field="${getNextField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
nextField?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prevField = document.querySelector(
|
||||
`[data-field="${getPrevField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
prevField?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
onBlur={handleHoursBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="minutes"
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
onBlur={handleMinutesBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="seconds"
|
||||
value={seconds}
|
||||
onChange={handleSecondsChange}
|
||||
onBlur={handleSecondsBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimeInput.defaultProps = {
|
||||
value: '00:00:00',
|
||||
onChange: undefined,
|
||||
disabled: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,3 @@
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AdvancedOptionItem from '../AdvancedOptionItem/AdvancedOptionItem';
|
||||
|
||||
const TEST_INPUT_PLACEHOLDER = 'Test input';
|
||||
const TEST_TITLE = 'Test Title';
|
||||
const TEST_DESCRIPTION = 'Test Description';
|
||||
const TEST_VALUE = 'test value';
|
||||
const FIRST_INPUT_PLACEHOLDER = 'First input';
|
||||
const TEST_INPUT_TEST_ID = 'test-input';
|
||||
|
||||
describe('AdvancedOptionItem', () => {
|
||||
const mockInput = (
|
||||
<input
|
||||
data-testid={TEST_INPUT_TEST_ID}
|
||||
placeholder={TEST_INPUT_PLACEHOLDER}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
title: TEST_TITLE,
|
||||
description: TEST_DESCRIPTION,
|
||||
input: mockInput,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render title and description', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
|
||||
expect(screen.getByText(TEST_DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render switch component', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).toBeInTheDocument();
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should not show input initially', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show input when switch is toggled on', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
expect(switchElement).toBeChecked();
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide input when switch is toggled off', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// First toggle on
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Then toggle off
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle switch state correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Initial state
|
||||
expect(switchElement).not.toBeChecked();
|
||||
|
||||
// After first click
|
||||
await user.click(switchElement);
|
||||
expect(switchElement).toBeChecked();
|
||||
|
||||
// After second click
|
||||
await user.click(switchElement);
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should render input with correct props when visible', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
expect(inputElement).toHaveAttribute('placeholder', TEST_INPUT_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it('should handle multiple toggle operations', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Toggle on
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Toggle off
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Toggle on again
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain input state when toggling', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Toggle on and interact with input
|
||||
await user.click(switchElement);
|
||||
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
await user.type(inputElement, TEST_VALUE);
|
||||
expect(inputElement).toHaveValue(TEST_VALUE);
|
||||
|
||||
// Toggle off
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Toggle back on - input should be recreated (fresh state)
|
||||
await user.click(switchElement);
|
||||
const inputElementAgain = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(inputElementAgain).toHaveValue(''); // Fresh input, no previous state
|
||||
});
|
||||
|
||||
it('should render with different title and description', () => {
|
||||
const customTitle = 'Custom Title';
|
||||
const customDescription = 'Custom Description';
|
||||
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={customTitle}
|
||||
description={customDescription}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(customTitle)).toBeInTheDocument();
|
||||
expect(screen.getByText(customDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with complex input component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const complexInput = (
|
||||
<div data-testid="complex-input">
|
||||
<input placeholder={FIRST_INPUT_PLACEHOLDER} />
|
||||
<select>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={complexInput}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
expect(screen.getByTestId('complex-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(FIRST_INPUT_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from '../../context/constants';
|
||||
import AdvancedOptions from '../AdvancedOptions';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dayjs timezone
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date) => originalDayjs(date));
|
||||
Object.assign(mockDayjs, originalDayjs);
|
||||
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
|
||||
guess: jest.fn(() => 'UTC'),
|
||||
};
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
// Mock Y_AXIS_CATEGORIES
|
||||
jest.mock('components/YAxisUnitSelector/constants', () => ({
|
||||
Y_AXIS_CATEGORIES: [
|
||||
{
|
||||
name: 'Time',
|
||||
units: [
|
||||
{ name: 'Second', id: 's' },
|
||||
{ name: 'Minute', id: 'm' },
|
||||
{ name: 'Hour', id: 'h' },
|
||||
{ name: 'Day', id: 'd' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock the context
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.mock('../../context', () => ({
|
||||
...jest.requireActual('../../context'),
|
||||
useCreateAlertState: (): {
|
||||
advancedOptions: typeof INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
setAdvancedOptions: jest.Mock;
|
||||
evaluationWindow: typeof INITIAL_EVALUATION_WINDOW_STATE;
|
||||
setEvaluationWindow: jest.Mock;
|
||||
} => ({
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock EvaluationCadence component
|
||||
jest.mock('../EvaluationCadence', () => ({
|
||||
__esModule: true,
|
||||
default: function MockEvaluationCadence(): JSX.Element {
|
||||
return (
|
||||
<div data-testid="evaluation-cadence">Evaluation Cadence Component</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const TOLERANCE_LIMIT_PLACEHOLDER = 'Enter tolerance limit...';
|
||||
|
||||
const renderAdvancedOptions = (): void => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<AdvancedOptions />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('AdvancedOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const expandAdvancedOptions = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
const collapseHeader = screen.getByRole('button');
|
||||
await user.click(collapseHeader);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('evaluation-cadence')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should render and allow expansion of advanced options', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
|
||||
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
|
||||
expect(
|
||||
screen.getByText('Send a notification if data is missing'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Enforce minimum datapoints')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delay evaluation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should enable advanced option inputs when switches are toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
|
||||
// Toggle the first switch (send notification)
|
||||
await user.click(switches[0]);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(TOLERANCE_LIMIT_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle the second switch (minimum datapoints)
|
||||
await user.click(switches[1]);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter minimum datapoints...'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update advanced options state when user interacts with inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
|
||||
// Enable send notification option
|
||||
const switches = screen.getAllByRole('switch');
|
||||
await user.click(switches[0]);
|
||||
|
||||
// Wait for tolerance input to appear and test interaction
|
||||
const toleranceInput = await screen.findByPlaceholderText(
|
||||
TOLERANCE_LIMIT_PLACEHOLDER,
|
||||
);
|
||||
await user.clear(toleranceInput);
|
||||
await user.type(toleranceInput, '10');
|
||||
|
||||
const timeUnitSelect = screen.getByRole('combobox');
|
||||
await user.click(timeUnitSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Minute')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Minute'));
|
||||
|
||||
// Verify that the state update function was called (testing behavior, not exact values)
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalled();
|
||||
|
||||
// Verify the function was called with the expected action types
|
||||
const { calls } = mockSetAdvancedOptions.mock;
|
||||
const actionTypes = calls.map((call) => call[0].type);
|
||||
expect(actionTypes).toContain('SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
|
||||
import * as context from '../../context';
|
||||
import EvaluationCadence, {
|
||||
EvaluationCadenceDetails,
|
||||
} from '../EvaluationCadence';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
const EDIT_CUSTOM_SCHEDULE_TEXT = 'Edit custom schedule';
|
||||
const PREVIEW_TEXT = 'Preview';
|
||||
const EVALUATION_CADENCE_TEXT = 'Evaluation cadence';
|
||||
const EVALUATION_CADENCE_DESCRIPTION_TEXT =
|
||||
'Customize when this Alert Rule will run. By default, it runs every 60 seconds (1 minute).';
|
||||
const ADD_CUSTOM_SCHEDULE_TEXT = 'Add custom schedule';
|
||||
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
|
||||
const DISCARD_TEXT = 'Discard';
|
||||
|
||||
describe('EvaluationCadence', () => {
|
||||
it('should render evaluation cadence component in default mode', () => {
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation cadence component in custom mode', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation cadence component in rrule mode', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'rrule',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on discard button should reset the evaluation cadence mode to default', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
|
||||
const discardButton = screen.getByTestId('discard-button');
|
||||
await user.click(discardButton);
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking on preview button should open the preview modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
const previewButton = screen.getByText(PREVIEW_TEXT);
|
||||
await user.click(previewButton);
|
||||
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking on edit custom schedule button should open the edit custom schedule modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
const editCustomScheduleButton = screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT);
|
||||
await user.click(editCustomScheduleButton);
|
||||
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mockSetIsOpen = jest.fn();
|
||||
|
||||
const RULE_VIEW_TEXT = 'RRule';
|
||||
const EDITOR_VIEW_TEST_ID = 'editor-view';
|
||||
const RULE_VIEW_TEST_ID = 'rrule-view';
|
||||
|
||||
describe('EvaluationCadenceDetails', () => {
|
||||
it('should render evaluation cadence details component', () => {
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the editor tab by default', () => {
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the rrule tab when rrule tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
const rruleTab = screen.getByText(RULE_VIEW_TEXT);
|
||||
await user.click(rruleTab);
|
||||
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import * as context from '../../context';
|
||||
import { INITIAL_EVALUATION_WINDOW_STATE } from '../../context/constants';
|
||||
import EvaluationSettings from '../EvaluationSettings';
|
||||
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: mockSetEvaluationWindow,
|
||||
} as any);
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'../AdvancedOptions',
|
||||
() =>
|
||||
function MockAdvancedOptions(): JSX.Element {
|
||||
return <div data-testid="advanced-options">Advanced Options</div>;
|
||||
},
|
||||
);
|
||||
|
||||
describe('EvaluationSettings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render evaluation settings container', () => {
|
||||
render(<EvaluationSettings />);
|
||||
expect(screen.getByText('Evaluation settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation alert conditions text', () => {
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Evaluate Alert Conditions over'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct timeframe text for rolling window', () => {
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rolling')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct timeframe text for cumulative window', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
evaluationWindow: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
},
|
||||
} as any);
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(screen.getByText('Current day')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cumulative')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TimeInput from '../TimeInput/TimeInput';
|
||||
|
||||
describe('TimeInput', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with default value', () => {
|
||||
render(<TimeInput />);
|
||||
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
|
||||
});
|
||||
|
||||
it('should render with provided value', () => {
|
||||
render(<TimeInput value="12:34:56" />);
|
||||
|
||||
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
|
||||
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
|
||||
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
|
||||
});
|
||||
|
||||
it('should handle value changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '12' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle minutes changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '30' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
|
||||
});
|
||||
|
||||
it('should handle seconds changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '45' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
|
||||
});
|
||||
|
||||
it('should pad single digits with zeros', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '5' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
|
||||
});
|
||||
|
||||
it('should filter non-numeric characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should limit input to 2 characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '123456' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('12');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowRight', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowLeft', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(minutesInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle Tab navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from seconds to hours', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(secondsInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from hours to seconds', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(secondsInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<TimeInput className="custom-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
'time-input-container',
|
||||
'custom-class',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable inputs when disabled prop is true', () => {
|
||||
render(<TimeInput disabled />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update internal state when value prop changes', () => {
|
||||
const { rerender } = render(<TimeInput value="01:02:03" />);
|
||||
|
||||
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
|
||||
|
||||
rerender(<TimeInput value="04:05:06" />);
|
||||
|
||||
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle malformed time values gracefully', () => {
|
||||
render(<TimeInput value="invalid:time:format" />);
|
||||
|
||||
// Should show the invalid values as they are
|
||||
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle partial time values', () => {
|
||||
render(<TimeInput value="12:34" />);
|
||||
|
||||
// Should fall back to default values for incomplete format
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from '../types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
getCumulativeWindowTimeframeText,
|
||||
getEvaluationWindowTypeText,
|
||||
getRollingWindowTimeframeText,
|
||||
getTimeframeText,
|
||||
isValidRRule,
|
||||
} from '../utils';
|
||||
|
||||
const MOCK_DATE_STRING = '2024-01-15T10:30:00Z';
|
||||
const FREQ_DAILY = 'FREQ=DAILY';
|
||||
const TEN_THIRTY_TIME = '10:30:00';
|
||||
const NINE_AM_TIME = '09:00:00';
|
||||
|
||||
// Mock dayjs
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date?: string | Date) => {
|
||||
if (date) {
|
||||
return originalDayjs(date);
|
||||
}
|
||||
return originalDayjs(MOCK_DATE_STRING);
|
||||
});
|
||||
Object.keys(originalDayjs).forEach((key) => {
|
||||
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
|
||||
});
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
jest.mock('rrule', () => ({
|
||||
rrulestr: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
|
||||
generateTimezoneData: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
describe('utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEvaluationWindowTypeText', () => {
|
||||
it('should return correct text for rolling window', () => {
|
||||
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
|
||||
});
|
||||
|
||||
it('should return correct text for cumulative window', () => {
|
||||
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
|
||||
});
|
||||
|
||||
it('should default to Rolling for unknown type', () => {
|
||||
expect(
|
||||
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
|
||||
).toBe('Rolling');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCumulativeWindowTimeframeText', () => {
|
||||
it('should return correct text for current hour', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_HOUR),
|
||||
).toBe('Current hour');
|
||||
});
|
||||
|
||||
it('should return correct text for current day', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
|
||||
it('should return correct text for current month', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_MONTH),
|
||||
).toBe('Current month');
|
||||
});
|
||||
|
||||
it('should default to Current hour for unknown timeframe', () => {
|
||||
expect(getCumulativeWindowTimeframeText('unknown')).toBe('Current hour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRollingWindowTimeframeText', () => {
|
||||
it('should return correct text for last 5 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 10 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
|
||||
).toBe('Last 10 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 15 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
|
||||
).toBe('Last 15 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 30 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
|
||||
).toBe('Last 30 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 1 hour', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return correct text for last 2 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
|
||||
).toBe('Last 2 hours');
|
||||
});
|
||||
|
||||
it('should return correct text for last 4 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
|
||||
).toBe('Last 4 hours');
|
||||
});
|
||||
|
||||
it('should default to Last 5 minutes for unknown timeframe', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeframeText', () => {
|
||||
it('should return rolling window text for rolling type', () => {
|
||||
expect(
|
||||
getTimeframeText('rolling', RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return cumulative window text for cumulative type', () => {
|
||||
expect(
|
||||
getTimeframeText('cumulative', CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromRRule', () => {
|
||||
const mockRRule = {
|
||||
all: jest.fn((callback) => {
|
||||
const dates = [
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
];
|
||||
dates.forEach((date, index) => callback(date, index));
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
|
||||
});
|
||||
|
||||
it('should return null for empty rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should build schedule from valid rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule(
|
||||
FREQ_DAILY,
|
||||
null,
|
||||
TEN_THIRTY_TIME,
|
||||
);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toEqual([
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rrule with DTSTART', () => {
|
||||
const date = dayjs('2024-01-20');
|
||||
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
|
||||
|
||||
// When date is provided, DTSTART is automatically added to the rrule string
|
||||
expect(rrulestr).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DTSTART:20240120T020000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rrule without DTSTART', () => {
|
||||
// Test with no date provided - should use original rrule string
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should limit occurrences to maxOccurrences', () => {
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromCustomSchedule', () => {
|
||||
beforeEach(() => {
|
||||
// Mock dayjs timezone methods
|
||||
((dayjs as unknown) as { tz: jest.Mock }).tz = jest.fn(
|
||||
(date?: string | Date) => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = originalDayjs(date || MOCK_DATE_STRING);
|
||||
mockDayjs.startOf = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.add = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.date = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.hour = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.minute = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.second = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.daysInMonth = jest.fn().mockReturnValue(31);
|
||||
mockDayjs.day = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.isAfter = jest.fn().mockReturnValue(true);
|
||||
mockDayjs.toDate = jest.fn().mockReturnValue(new Date(MOCK_DATE_STRING));
|
||||
return mockDayjs;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for missing required parameters', () => {
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '10:30:00', ''),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate monthly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate weekly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid days for monthly schedule', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', 'invalid', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid weekdays for weekly schedule', () => {
|
||||
buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'invalid', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
// Function should handle invalid weekdays gracefully
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
// Test with invalid parameters that should cause an error
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'invalid_repeat_type',
|
||||
['monday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
// Should return empty array, not null, for invalid repeat type
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRRule', () => {
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should return true for valid rrule', () => {
|
||||
expect(isValidRRule(FREQ_DAILY)).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should return false for invalid rrule', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
expect(isValidRRule('INVALID')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const EVALUATION_WINDOW_TYPE = [
|
||||
{ label: 'Rolling', value: 'rolling' },
|
||||
{ label: 'Cumulative', value: 'cumulative' },
|
||||
];
|
||||
|
||||
export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
rolling: [
|
||||
{ label: 'Last 5 minutes', value: '5m0s' },
|
||||
{ label: 'Last 10 minutes', value: '10m0s' },
|
||||
{ label: 'Last 15 minutes', value: '15m0s' },
|
||||
{ label: 'Last 30 minutes', value: '30m0s' },
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
{ label: 'Current day', value: 'currentDay' },
|
||||
{ label: 'Current month', value: 'currentMonth' },
|
||||
],
|
||||
};
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
|
||||
{ label: 'WEEK', value: 'week' },
|
||||
{ label: 'MONTH', value: 'month' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
|
||||
{ label: 'SUNDAY', value: 'sunday' },
|
||||
{ label: 'MONDAY', value: 'monday' },
|
||||
{ label: 'TUESDAY', value: 'tuesday' },
|
||||
{ label: 'WEDNESDAY', value: 'wednesday' },
|
||||
{ label: 'THURSDAY', value: 'thursday' },
|
||||
{ label: 'FRIDAY', value: 'friday' },
|
||||
{ label: 'SATURDAY', value: 'saturday' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => {
|
||||
const value = String(i + 1);
|
||||
return { label: value, value };
|
||||
},
|
||||
);
|
||||
|
||||
export const WEEKDAY_MAP: { [key: string]: number } = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import EvalutationSettings from './EvaluationSettings';
|
||||
|
||||
export default EvalutationSettings;
|
||||
@@ -0,0 +1,836 @@
|
||||
.evaluation-settings-container {
|
||||
margin: 16px;
|
||||
|
||||
.evaluate-alert-conditions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--bg-slate-400);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-options-container {
|
||||
.ant-collapse {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-header {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
.ant-collapse-content-box {
|
||||
background-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-popover-content {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin: 10px;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
.evaluation-window-popover {
|
||||
min-width: 500px;
|
||||
|
||||
.evaluation-window-content {
|
||||
display: flex;
|
||||
|
||||
.evaluation-window-content-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
padding: 12px 16px;
|
||||
min-width: 250px;
|
||||
min-height: 300px;
|
||||
|
||||
.evaluation-window-content-item-label {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evaluation-window-content-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.evaluation-window-content-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 -16px;
|
||||
padding: 4px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-slate-500);
|
||||
border-left: 2px solid var(--bg-robin-500);
|
||||
.ant-typography {
|
||||
font-weight: 500;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 400px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-300);
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 400px;
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.time-select-group {
|
||||
.ant-input-group {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 60%;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
.evaluation-cadence-item {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.edit-custom-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-slate-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.query-section-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.query-section-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 0.5px solid var(--bg-slate-400);
|
||||
border-bottom: 0.5px solid var(--bg-slate-400);
|
||||
width: 120px;
|
||||
height: 36px;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-ink-500);
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
padding-right: 16px;
|
||||
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
.schedule-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
.schedule-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
.schedule-preview-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 20px;
|
||||
|
||||
.schedule-preview-timeline-line {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.evaluation-cadence-preview {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.evaluation-settings-container {
|
||||
.evaluate-alert-conditions-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-separator {
|
||||
border-top: 1px dashed var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-options-container {
|
||||
.ant-collapse {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-header {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
.ant-collapse-content-box {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-content {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
|
||||
.evaluation-window-popover {
|
||||
.evaluation-window-content {
|
||||
.evaluation-window-content-item {
|
||||
border-right: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluation-window-content-item-label {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.evaluation-window-content-list {
|
||||
.evaluation-window-content-list-item {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border-left: 2px solid var(--bg-robin-500);
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-content {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-footer {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-details {
|
||||
.select-group {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.edit-custom-schedule {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
textarea {
|
||||
background: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
.schedule-preview-header {
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
.schedule-preview-item {
|
||||
.schedule-preview-timeline {
|
||||
.schedule-preview-timeline-line {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
border-top: 1px dashed var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from '../context/types';
|
||||
|
||||
export interface IAdvancedOptionItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
tooltipText?: string;
|
||||
input: JSX.Element;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
'LAST_5_MINUTES' = '5m0s',
|
||||
'LAST_10_MINUTES' = '10m0s',
|
||||
'LAST_15_MINUTES' = '15m0s',
|
||||
'LAST_30_MINUTES' = '30m0s',
|
||||
'LAST_1_HOUR' = '1h0m0s',
|
||||
'LAST_2_HOURS' = '2h0m0s',
|
||||
'LAST_4_HOURS' = '4h0m0s',
|
||||
}
|
||||
|
||||
export enum CumulativeWindowTimeframes {
|
||||
'CURRENT_HOUR' = 'currentHour',
|
||||
'CURRENT_DAY' = 'currentDay',
|
||||
'CURRENT_MONTH' = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface IEvaluationCadenceDetailsProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationCadencePreviewProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseKeyboardNavigationOptions {
|
||||
onSelect?: (value: string, sectionId: string) => void;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigation = ({
|
||||
onSelect,
|
||||
onEscape,
|
||||
}: UseKeyboardNavigationOptions = {}): {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
firstItemRef: React.RefObject<HTMLDivElement>;
|
||||
} => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getFocusableItems = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll(
|
||||
'.evaluation-window-content-list-item[tabindex="0"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getInteractiveElements = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
const detailsSection = containerRef.current.querySelector(
|
||||
'.evaluation-window-details',
|
||||
);
|
||||
if (!detailsSection) return [];
|
||||
|
||||
return Array.from(
|
||||
detailsSection.querySelectorAll(
|
||||
'input, select, button, [tabindex="0"], [tabindex="-1"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
return items.findIndex((item) => item === activeElement);
|
||||
}, []);
|
||||
|
||||
const navigateWithinSection = useCallback(
|
||||
(direction: 'up' | 'down'): void => {
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
let nextIndex: number;
|
||||
if (direction === 'down') {
|
||||
nextIndex = (currentIndex + 1) % items.length;
|
||||
} else {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}
|
||||
|
||||
items[nextIndex]?.focus();
|
||||
},
|
||||
[getFocusableItems, getCurrentIndex],
|
||||
);
|
||||
|
||||
const navigateToDetails = useCallback((): void => {
|
||||
const interactiveElements = getInteractiveElements();
|
||||
interactiveElements[0]?.focus();
|
||||
}, [getInteractiveElements]);
|
||||
|
||||
const navigateBackToSection = useCallback((): void => {
|
||||
const items = getFocusableItems();
|
||||
items[0]?.focus();
|
||||
}, [getFocusableItems]);
|
||||
|
||||
const navigateBetweenSections = useCallback(
|
||||
(direction: 'left' | 'right'): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInDetails = activeElement?.closest('.evaluation-window-details');
|
||||
|
||||
if (isInDetails && direction === 'left') {
|
||||
navigateBackToSection();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
const DATA_ATTR = 'data-section-id';
|
||||
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
|
||||
|
||||
if (currentSectionId === 'window-type' && direction === 'right') {
|
||||
const timeframeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
|
||||
);
|
||||
timeframeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'left') {
|
||||
const windowTypeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
|
||||
);
|
||||
windowTypeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'right') {
|
||||
navigateToDetails();
|
||||
}
|
||||
},
|
||||
[
|
||||
navigateBackToSection,
|
||||
navigateToDetails,
|
||||
getFocusableItems,
|
||||
getCurrentIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSelection = useCallback((): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (!activeElement || !onSelect) return;
|
||||
|
||||
const value = activeElement.getAttribute('data-value');
|
||||
const sectionId = activeElement.getAttribute('data-section-id');
|
||||
|
||||
if (value && sectionId) {
|
||||
onSelect(value, sectionId);
|
||||
}
|
||||
}, [onSelect]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('up');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('right');
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
handleSelection();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onEscape?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) | undefined => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (firstItemRef.current) {
|
||||
firstItemRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { RE_NOTIFICATION_UNIT_OPTIONS } from '../context/constants';
|
||||
import { EvaluationWindowState } from '../context/types';
|
||||
import { WEEKDAY_MAP } from './constants';
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
|
||||
|
||||
// Extend dayjs with timezone plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const getEvaluationWindowTypeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
): string => {
|
||||
switch (windowType) {
|
||||
case 'rolling':
|
||||
return 'Rolling';
|
||||
case 'cumulative':
|
||||
return 'Cumulative';
|
||||
default:
|
||||
return 'Rolling';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCumulativeWindowTimeframeText = (
|
||||
evaluationWindow: EvaluationWindowState,
|
||||
): string => {
|
||||
switch (evaluationWindow.timeframe) {
|
||||
case CumulativeWindowTimeframes.CURRENT_HOUR:
|
||||
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
|
||||
case CumulativeWindowTimeframes.CURRENT_DAY:
|
||||
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
case CumulativeWindowTimeframes.CURRENT_MONTH:
|
||||
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRollingWindowTimeframeText = (
|
||||
timeframe: RollingWindowTimeframes,
|
||||
): string => {
|
||||
switch (timeframe) {
|
||||
case RollingWindowTimeframes.LAST_5_MINUTES:
|
||||
return 'Last 5 minutes';
|
||||
case RollingWindowTimeframes.LAST_10_MINUTES:
|
||||
return 'Last 10 minutes';
|
||||
case RollingWindowTimeframes.LAST_15_MINUTES:
|
||||
return 'Last 15 minutes';
|
||||
case RollingWindowTimeframes.LAST_30_MINUTES:
|
||||
return 'Last 30 minutes';
|
||||
case RollingWindowTimeframes.LAST_1_HOUR:
|
||||
return 'Last 1 hour';
|
||||
case RollingWindowTimeframes.LAST_2_HOURS:
|
||||
return 'Last 2 hours';
|
||||
case RollingWindowTimeframes.LAST_4_HOURS:
|
||||
return 'Last 4 hours';
|
||||
default:
|
||||
return 'Last 5 minutes';
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomRollingWindowTimeframeText = (
|
||||
evaluationWindow: EvaluationWindowState,
|
||||
): string =>
|
||||
`Last ${evaluationWindow.startingAt.number} ${
|
||||
RE_NOTIFICATION_UNIT_OPTIONS.find(
|
||||
(option) => option.value === evaluationWindow.startingAt.unit,
|
||||
)?.label
|
||||
}${parseInt(evaluationWindow.startingAt.number, 10) > 1 ? 's' : ''}`;
|
||||
|
||||
export const getTimeframeText = (
|
||||
evaluationWindow: EvaluationWindowState,
|
||||
): string => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return getCustomRollingWindowTimeframeText(evaluationWindow);
|
||||
}
|
||||
return getRollingWindowTimeframeText(
|
||||
evaluationWindow.timeframe as RollingWindowTimeframes,
|
||||
);
|
||||
}
|
||||
return getCumulativeWindowTimeframeText(evaluationWindow);
|
||||
};
|
||||
|
||||
export function buildAlertScheduleFromRRule(
|
||||
rruleString: string,
|
||||
date: Dayjs | null,
|
||||
startAt: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!rruleString) return null;
|
||||
|
||||
// Handle literal \n in string
|
||||
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
|
||||
if (date) {
|
||||
const dt = dayjs(date);
|
||||
if (!dt.isValid()) throw new Error('Invalid date provided');
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
|
||||
const dtWithTime = dt
|
||||
.set('hour', hours)
|
||||
.set('minute', minutes)
|
||||
.set('second', seconds)
|
||||
.set('millisecond', 0);
|
||||
|
||||
const dtStartStr = dtWithTime
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
if (!/DTSTART/i.test(finalRRuleString)) {
|
||||
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rruleObj = rrulestr(finalRRuleString);
|
||||
const occurrences: Date[] = [];
|
||||
rruleObj.all((date, index) => {
|
||||
if (index >= maxOccurrences) return false;
|
||||
occurrences.push(date);
|
||||
return true;
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
} catch (error) {
|
||||
console.error('Error building RRULE:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateMonthlyOccurrences(
|
||||
targetDays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentMonth = dayjs().tz(timezone).startOf('month');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, monthOffset) => {
|
||||
const monthDate = currentMonth.add(monthOffset, 'month');
|
||||
targetDays.forEach((day) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const daysInMonth = monthDate.daysInMonth();
|
||||
if (day <= daysInMonth) {
|
||||
const targetDate = monthDate
|
||||
.date(day)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function generateWeeklyOccurrences(
|
||||
targetWeekdays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentWeek = dayjs().tz(timezone).startOf('week');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, weekOffset) => {
|
||||
const weekDate = currentWeek.add(weekOffset, 'week');
|
||||
targetWeekdays.forEach((weekday) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const targetDate = weekDate
|
||||
.day(weekday)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
export function buildAlertScheduleFromCustomSchedule(
|
||||
repeatEvery: string,
|
||||
occurence: string[],
|
||||
startAt: string,
|
||||
timezone: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!repeatEvery || !occurence.length || !startAt || !timezone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (repeatEvery === 'month') {
|
||||
const targetDays = occurence
|
||||
.map((day) => parseInt(day, 10))
|
||||
.filter((day) => !Number.isNaN(day));
|
||||
occurrences = generateMonthlyOccurrences(
|
||||
targetDays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
} else if (repeatEvery === 'week') {
|
||||
const targetWeekdays = occurence
|
||||
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
|
||||
.filter((day) => day !== undefined);
|
||||
occurrences = generateWeeklyOccurrences(
|
||||
targetWeekdays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
}
|
||||
|
||||
occurrences.sort((a, b) => a.getTime() - b.getTime());
|
||||
return occurrences.slice(0, maxOccurrences);
|
||||
} catch (error) {
|
||||
console.error('Error building custom schedule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export function isValidRRule(rruleString: string): boolean {
|
||||
try {
|
||||
// normalize escaped \n
|
||||
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
rrulestr(finalRRuleString); // will throw if invalid
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
40
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal file
40
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Typography } from 'antd';
|
||||
import { Check, Send, X } from 'lucide-react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
const { discardAlertRule } = useCreateAlertState();
|
||||
|
||||
const handleDiscard = (): void => discardAlertRule();
|
||||
|
||||
const handleTestNotification = (): void => {
|
||||
console.log('test notification');
|
||||
};
|
||||
|
||||
const handleSaveAlert = (): void => {
|
||||
console.log('save alert');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-footer">
|
||||
<Button type="text" onClick={handleDiscard}>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
<div className="button-group">
|
||||
<Button type="default" onClick={handleTestNotification}>
|
||||
<Send size={14} />
|
||||
<Typography.Text>Test Notification</Typography.Text>
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSaveAlert}>
|
||||
<Check size={14} />
|
||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||
</Button>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Select, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function MultipleNotifications(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
thresholdState,
|
||||
} = useCreateAlertState();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const selectedQuery = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData.find(
|
||||
(query) => query.queryName === thresholdState.selectedQuery,
|
||||
),
|
||||
[currentQuery, thresholdState.selectedQuery],
|
||||
);
|
||||
|
||||
const spaceAggregationOptions = useMemo(
|
||||
() =>
|
||||
selectedQuery?.groupBy?.map((groupBy) => ({
|
||||
label: groupBy.key,
|
||||
value: groupBy.key,
|
||||
})) || [],
|
||||
[selectedQuery],
|
||||
);
|
||||
|
||||
const isMultipleNotificationsEnabled = spaceAggregationOptions.length > 0;
|
||||
|
||||
const multipleNotificationsInput = useMemo(() => {
|
||||
const placeholder = isMultipleNotificationsEnabled
|
||||
? 'Select fields to group by (optional)'
|
||||
: 'No grouping fields available';
|
||||
let input = (
|
||||
<div>
|
||||
<Select
|
||||
options={spaceAggregationOptions}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
value={notificationSettings.multipleNotifications}
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
disabled={!isMultipleNotificationsEnabled}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
{isMultipleNotificationsEnabled && (
|
||||
<Typography.Paragraph className="multiple-notifications-select-description">
|
||||
{notificationSettings.multipleNotifications?.length
|
||||
? `Alerts with same ${notificationSettings.multipleNotifications?.join(
|
||||
', ',
|
||||
)} will be grouped`
|
||||
: 'Empty = all matching alerts combined into one notification'}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (!isMultipleNotificationsEnabled) {
|
||||
input = (
|
||||
<Tooltip title="Add 'Group by' fields to your query to enable alert grouping">
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}, [
|
||||
isMultipleNotificationsEnabled,
|
||||
notificationSettings.multipleNotifications,
|
||||
setNotificationSettings,
|
||||
spaceAggregationOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="multiple-notifications-container">
|
||||
<div className="multiple-notifications-header">
|
||||
<Typography.Text className="multiple-notifications-header-title">
|
||||
Group alerts by{' '}
|
||||
<Tooltip title="Group similar alerts together to reduce notification volume. Leave empty to combine all matching alerts into one notification without grouping.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="multiple-notifications-header-description">
|
||||
Combine alerts with the same field values into a single notification.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{multipleNotificationsInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultipleNotifications;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function NotificationMessage(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
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 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">
|
||||
<div className="notification-message-header">
|
||||
<div className="notification-message-header-content">
|
||||
<Typography.Text className="notification-message-header-title">
|
||||
Notification Message
|
||||
<Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="notification-message-header-description">
|
||||
Custom message content for alert notifications. Use template variables to
|
||||
include dynamic information.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
value={notificationSettings.description}
|
||||
onChange={(e): void =>
|
||||
setNotificationSettings({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Enter notification message..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationMessage;
|
||||
@@ -0,0 +1,112 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
RE_NOTIFICATION_CONDITION_OPTIONS,
|
||||
RE_NOTIFICATION_UNIT_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import MultipleNotifications from './MultipleNotifications';
|
||||
import NotificationMessage from './NotificationMessage';
|
||||
|
||||
function NotificationSettings(): JSX.Element {
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const repeatNotificationsInput = (
|
||||
<div className="repeat-notifications-input">
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Input
|
||||
value={notificationSettings.reNotification.value}
|
||||
placeholder="Enter time interval..."
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
type="number"
|
||||
onChange={(e): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: parseInt(e.target.value, 10),
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
placeholder="Select unit"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: value,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>while</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={notificationSettings.reNotification.conditions || null}
|
||||
placeholder="Select conditions"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_CONDITION_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notification-settings-container">
|
||||
<Stepper
|
||||
stepNumber={showCondensedLayoutFlag ? 3 : 4}
|
||||
label="Notification settings"
|
||||
/>
|
||||
<NotificationMessage />
|
||||
<div className="notification-settings-content">
|
||||
<MultipleNotifications />
|
||||
<AdvancedOptionItem
|
||||
title="Repeat notifications"
|
||||
description="Send periodic notifications while the alert condition remains active."
|
||||
tooltipText="Continue sending periodic notifications while the alert condition persists. Useful for ensuring critical alerts aren't missed during long-running incidents. Configure how often to repeat and under what conditions."
|
||||
input={repeatNotificationsInput}
|
||||
onToggle={(): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
...notificationSettings.reNotification,
|
||||
enabled: !notificationSettings.reNotification.enabled,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationSettings;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Input, Select, Switch, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
RE_NOTIFICATION_CONDITION_OPTIONS,
|
||||
RE_NOTIFICATION_UNIT_OPTIONS,
|
||||
} from '../context/constants';
|
||||
|
||||
function ReNotification(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
return (
|
||||
<div className="re-notification-container">
|
||||
<div className="advanced-option-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
Re-notification
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
Send notifications for the alert status periodically as long as the
|
||||
resources have not recovered.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="advanced-option-item-right-content">
|
||||
<Switch
|
||||
checked={notificationSettings.reNotification.enabled}
|
||||
onChange={(checked): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: checked,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bottom" />
|
||||
<div className="re-notification-condition">
|
||||
<Typography.Text>If this alert rule stays in</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={notificationSettings.reNotification.conditions || null}
|
||||
placeholder="Select conditions"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_CONDITION_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>re-notify every</Typography.Text>
|
||||
<Input.Group>
|
||||
<Input
|
||||
value={notificationSettings.reNotification.value}
|
||||
placeholder="Enter time interval..."
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
type="number"
|
||||
onChange={(e): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: parseInt(e.target.value, 10),
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
placeholder="Select unit"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: value,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Input.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReNotification;
|
||||
@@ -0,0 +1,162 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
|
||||
import MultipleNotifications from '../MultipleNotifications';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_QUERY = 'test-query';
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
const mockUseQueryBuilder = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: [{ key: 'service' }, { key: 'environment' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
multipleNotifications: {
|
||||
enabled: false,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
thresholdState: {
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
describe('MultipleNotifications', () => {
|
||||
const { useQueryBuilder } = jest.requireMock(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
|
||||
});
|
||||
|
||||
it('renders single and multiple notification options', () => {
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(screen.getByText('Single Alert Notification')).toBeInTheDocument();
|
||||
expect(screen.getByText('Multiple Alert Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders descriptions for both options', () => {
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Send a single alert notification when the query meets/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Send a notification for each/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders select dropdown for multiple notifications', () => {
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const selectElement = screen.getByText('SELECT VALUE');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to multiple notifications when radio is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const multipleRadio = screen.getByDisplayValue('multiple');
|
||||
await user.click(multipleRadio);
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: {
|
||||
enabled: true,
|
||||
value: 'service', // First option from groupBy
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to single notification when radio is clicked', async () => {
|
||||
// First enable multiple notifications, then switch back to single
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
multipleNotifications: {
|
||||
enabled: true,
|
||||
value: 'service',
|
||||
},
|
||||
},
|
||||
thresholdState: {
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const singleRadio = screen.getByDisplayValue('single');
|
||||
await user.click(singleRadio);
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: {
|
||||
enabled: false,
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disables radio options when no groupBy options are available', () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'test-query',
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const singleRadio = screen.getByDisplayValue('single');
|
||||
const multipleRadio = screen.getByDisplayValue('multiple');
|
||||
|
||||
expect(singleRadio).toBeDisabled();
|
||||
expect(multipleRadio).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
|
||||
import NotificationMessage from '../NotificationMessage';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
description: '',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
describe('NotificationMessage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the notification message tab', () => {
|
||||
render(<NotificationMessage />);
|
||||
expect(screen.getByText('Notification Message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders textarea with placeholder', () => {
|
||||
render(<NotificationMessage />);
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates notification settings when textarea value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotificationMessage />);
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
await user.type(textarea, 'Test');
|
||||
expect(mockSetNotificationSettings).toHaveBeenCalledTimes(4);
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('displays existing description value', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
description: 'Existing message',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
render(<NotificationMessage />);
|
||||
|
||||
const textarea = screen.getByDisplayValue('Existing message');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
|
||||
import NotificationSettings from '../NotificationSettings';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
multipleNotifications: {
|
||||
enabled: false,
|
||||
value: '',
|
||||
},
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 0,
|
||||
unit: 'seconds',
|
||||
conditions: [],
|
||||
},
|
||||
description: '',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
thresholdState: {
|
||||
selectedQuery: '',
|
||||
evaluationWindow: '',
|
||||
algorithm: '',
|
||||
seasonality: '',
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
jest.mock('../NotificationMessage', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="notification-message">NotificationMessage</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('../MultipleNotifications', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="multiple-notifications">MultipleNotifications</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('../ReNotification', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="re-notification">ReNotification</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('NotificationSettings', () => {
|
||||
it('should render the sub components', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('re-notification')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
|
||||
import ReNotification from '../ReNotification';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 0,
|
||||
unit: 'seconds',
|
||||
conditions: [],
|
||||
},
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
describe('ReNotification', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the re-notification title and description', () => {
|
||||
render(<ReNotification />);
|
||||
|
||||
expect(screen.getByText('Re-notification')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Send notifications for the alert status periodically/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders switch to enable/disable re-notification', () => {
|
||||
render(<ReNotification />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).toBeInTheDocument();
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('toggles re-notification when switch is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ReNotification />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: true,
|
||||
value: 0,
|
||||
unit: 'seconds',
|
||||
conditions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders disabled inputs when re-notification is disabled', () => {
|
||||
render(<ReNotification />);
|
||||
|
||||
const timeInput = screen.getByPlaceholderText('Enter time interval...');
|
||||
const unitSelect = screen.getByText('seconds');
|
||||
|
||||
expect(timeInput).toBeDisabled();
|
||||
expect(unitSelect.closest('.ant-select')).toHaveClass('ant-select-disabled');
|
||||
});
|
||||
|
||||
it('renders enabled inputs when re-notification is enabled', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 5,
|
||||
unit: 'minutes',
|
||||
conditions: ['firing'],
|
||||
},
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
render(<ReNotification />);
|
||||
|
||||
const timeInput = screen.getByDisplayValue('5');
|
||||
const unitSelect = screen.getByText('minutes');
|
||||
|
||||
expect(timeInput).not.toBeDisabled();
|
||||
expect(unitSelect.closest('.ant-select')).not.toHaveClass(
|
||||
'ant-select-disabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates time value when input changes', async () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 0,
|
||||
unit: 'seconds',
|
||||
conditions: [],
|
||||
},
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ReNotification />);
|
||||
|
||||
const timeInput = screen.getByPlaceholderText('Enter time interval...');
|
||||
await user.clear(timeInput);
|
||||
await user.type(timeInput, '10');
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: true,
|
||||
value: 1, // parseInt of '1' from the last character typed
|
||||
unit: 'seconds',
|
||||
conditions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
|
||||
export default NotificationSettings;
|
||||
@@ -0,0 +1,339 @@
|
||||
.notification-settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 16px;
|
||||
|
||||
.notification-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: -8px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
|
||||
.notification-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-message-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.notification-message-header-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.repeat-notifications-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-input {
|
||||
width: 120px;
|
||||
border: 1px solid var(--bg-slate-100);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-multiple {
|
||||
.ant-select-selector {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
display: flex;
|
||||
padding: 4px 16px 16px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
justify-content: space-between;
|
||||
|
||||
.multiple-notifications-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
font-size: 10px;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
margin-left: -16px;
|
||||
margin-right: -32px;
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.ant-typography {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--bg-vanilla-400);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 200px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-variable-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.template-variable-content-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
code {
|
||||
background-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.notification-settings-container {
|
||||
.notification-message-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.notification-message-header {
|
||||
.notification-message-header-content {
|
||||
.notification-message-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.repeat-notifications-input {
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.multiple-notifications-header {
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.advanced-option-item {
|
||||
.advanced-option-item-left-content {
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
@@ -16,7 +17,7 @@ export interface ChartPreviewProps {
|
||||
|
||||
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const { thresholdState, alertState } = useCreateAlertState();
|
||||
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@@ -25,14 +26,24 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const yAxisUnit = alertState.yAxisUnit || '';
|
||||
|
||||
const headline = (
|
||||
<div className="chart-preview-headline">
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
<YAxisUnitSelector
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
}
|
||||
headline={headline}
|
||||
name=""
|
||||
query={stagedQuery}
|
||||
selectedInterval={globalSelectedInterval}
|
||||
@@ -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,7 +2,6 @@ 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';
|
||||
@@ -16,13 +15,7 @@ import { buildAlertDefForChartPreview } from './utils';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertState,
|
||||
setAlertState,
|
||||
alertType,
|
||||
setAlertType,
|
||||
thresholdState,
|
||||
} = useCreateAlertState();
|
||||
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
|
||||
|
||||
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
|
||||
|
||||
@@ -51,17 +44,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 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) => (
|
||||
|
||||
@@ -134,9 +134,7 @@ describe('QuerySection', () => {
|
||||
// Check if Stepper is rendered
|
||||
expect(screen.getByTestId('stepper')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('step-number')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('step-label')).toHaveTextContent(
|
||||
'Define the query you want to set an alert on',
|
||||
);
|
||||
expect(screen.getByTestId('step-label')).toHaveTextContent('Define Query');
|
||||
|
||||
// Check if ChartPreview is rendered
|
||||
expect(screen.getByTestId('chart-preview')).toBeInTheDocument();
|
||||
|
||||
@@ -88,6 +88,14 @@
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
.ant-card-body {
|
||||
background-color: var(--bg-ink-500);
|
||||
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,3 +107,69 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@@ -15,7 +21,6 @@ import {
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
name: '',
|
||||
description: '',
|
||||
labels: {},
|
||||
yAxisUnit: undefined,
|
||||
};
|
||||
@@ -24,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,
|
||||
@@ -34,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,
|
||||
@@ -44,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,
|
||||
@@ -54,7 +59,7 @@ export const INITIAL_RANDOM_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: '',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: getRandomColor(),
|
||||
@@ -70,11 +75,54 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
};
|
||||
|
||||
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 1,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 1,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 1,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
evaluationCadence: {
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 1,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
rrule: {
|
||||
date: dayjs(),
|
||||
startAt: '00:00:00',
|
||||
rrule: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '1',
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
|
||||
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 = [
|
||||
@@ -115,3 +163,36 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
||||
];
|
||||
|
||||
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 =
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
||||
|
||||
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
||||
{ value: 'firing', label: 'Firing' },
|
||||
{ value: 'no-data', label: 'No Data' },
|
||||
];
|
||||
|
||||
export const RE_NOTIFICATION_UNIT_OPTIONS = [
|
||||
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
|
||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||
{ value: UniversalYAxisUnit.WEEKS, label: 'Weeks' },
|
||||
];
|
||||
|
||||
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
multipleNotifications: [],
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 0,
|
||||
unit: RE_NOTIFICATION_UNIT_OPTIONS[0].value,
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
};
|
||||
|
||||
@@ -14,15 +14,21 @@ import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
notificationSettingsReducer,
|
||||
} from './utils';
|
||||
|
||||
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
|
||||
@@ -80,12 +86,46 @@ export function CreateAlertProvider(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useReducer(
|
||||
notificationSettingsReducer,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
});
|
||||
}, [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 contextValue: ICreateAlertContextProps = useMemo(
|
||||
() => ({
|
||||
alertState,
|
||||
@@ -94,8 +134,24 @@ export function CreateAlertProvider(
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
discardAlertRule,
|
||||
}),
|
||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
||||
[
|
||||
alertState,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
@@ -9,6 +10,13 @@ export interface ICreateAlertContextProps {
|
||||
setAlertType: Dispatch<AlertTypes>;
|
||||
thresholdState: AlertThresholdState;
|
||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: Dispatch<NotificationSettingsAction>;
|
||||
discardAlertRule: () => void;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@@ -24,22 +32,21 @@ export enum AlertCreationStep {
|
||||
|
||||
export interface AlertState {
|
||||
name: string;
|
||||
description: string;
|
||||
labels: Labels;
|
||||
yAxisUnit: string | undefined;
|
||||
}
|
||||
|
||||
export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined };
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface Threshold {
|
||||
id: string;
|
||||
label: string;
|
||||
thresholdValue: number;
|
||||
recoveryThresholdValue: number;
|
||||
recoveryThresholdValue: number | null;
|
||||
unit: string;
|
||||
channels: string[];
|
||||
color: string;
|
||||
@@ -101,3 +108,114 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
evaluationCadence: {
|
||||
mode: EvaluationCadenceMode;
|
||||
default: {
|
||||
value: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
occurence: string[];
|
||||
timezone: string;
|
||||
};
|
||||
rrule: {
|
||||
date: Dayjs | null;
|
||||
startAt: string;
|
||||
rrule: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type AdvancedOptionsAction =
|
||||
| {
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_EVALUATION_CADENCE';
|
||||
payload: {
|
||||
default: { value: number; timeUnit: string };
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
timezone: string;
|
||||
occurence: string[];
|
||||
};
|
||||
rrule: { date: Dayjs | null; startAt: string; rrule: string };
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface EvaluationWindowState {
|
||||
windowType: 'rolling' | 'cumulative';
|
||||
timeframe: string;
|
||||
startingAt: {
|
||||
time: string;
|
||||
number: string;
|
||||
timezone: string;
|
||||
unit: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EvaluationWindowAction =
|
||||
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
|
||||
| { type: 'SET_TIMEFRAME'; payload: string }
|
||||
| {
|
||||
type: 'SET_STARTING_AT';
|
||||
payload: { time: string; number: string; timezone: string; unit: string };
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
export interface NotificationSettingsState {
|
||||
multipleNotifications: string[] | null;
|
||||
reNotification: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
| {
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS';
|
||||
payload: string[] | null;
|
||||
}
|
||||
| {
|
||||
type: 'SET_RE_NOTIFICATION';
|
||||
payload: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@@ -11,12 +11,24 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsAction,
|
||||
NotificationSettingsState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@@ -29,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,
|
||||
@@ -44,6 +51,8 @@ export const alertCreationReducer = (
|
||||
...state,
|
||||
yAxisUnit: action.payload,
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ALERT_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -110,3 +119,75 @@ export const alertThresholdReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const advancedOptionsReducer = (
|
||||
state: AdvancedOptionsState,
|
||||
action: AdvancedOptionsAction,
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||
case 'SET_DELAY_EVALUATION':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
|
||||
};
|
||||
case 'SET_EVALUATION_CADENCE_MODE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluationWindowReducer = (
|
||||
state: EvaluationWindowState,
|
||||
action: EvaluationWindowAction,
|
||||
): EvaluationWindowState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WINDOW_TYPE':
|
||||
return {
|
||||
...state,
|
||||
windowType: action.payload,
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
timeframe:
|
||||
action.payload === 'rolling'
|
||||
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
|
||||
: 'currentHour',
|
||||
};
|
||||
case 'SET_TIMEFRAME':
|
||||
return { ...state, timeframe: action.payload };
|
||||
case 'SET_STARTING_AT':
|
||||
return { ...state, startingAt: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const notificationSettingsReducer = (
|
||||
state: NotificationSettingsState,
|
||||
action: NotificationSettingsAction,
|
||||
): NotificationSettingsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MULTIPLE_NOTIFICATIONS':
|
||||
return { ...state, multipleNotifications: action.payload };
|
||||
case 'SET_RE_NOTIFICATION':
|
||||
return { ...state, reNotification: action.payload };
|
||||
case 'SET_DESCRIPTION':
|
||||
return { ...state, description: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
|
||||
// UI side FF to switch between the 2 layouts of the create alert page
|
||||
// Layout 1 - Default layout
|
||||
// Layout 2 - Condensed layout
|
||||
export const showCondensedLayout = (): boolean =>
|
||||
localStorage.getItem('showCondensedLayout') === 'true';
|
||||
|
||||
@@ -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';
|
||||
@@ -95,6 +95,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 +106,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -117,6 +119,7 @@ function QuerySection({
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
<Typography.Text>PromQL</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
@@ -16103,6 +16103,13 @@ robust-predicates@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||
|
||||
rrule@2.8.1:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
|
||||
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user