Compare commits

...

26 Commits

Author SHA1 Message Date
amlannandy
ed9e90aff1 chore: add keyboard navigation to evaluation window 2025-09-18 00:35:19 +07:00
amlannandy
57ec4e9a7a chore: layout and light mode changes 2025-09-18 00:35:19 +07:00
amlannandy
fdad0663e9 chore: step 4 changes 2025-09-18 00:35:19 +07:00
amlannandy
b54ecedac3 chore: step 3 changes 2025-09-18 00:35:19 +07:00
amlannandy
834ff461ce chore: fix time input 2025-09-18 00:35:19 +07:00
amlannandy
39245b39cc chore: evaluation window changes 2025-09-18 00:35:19 +07:00
amlannandy
78ceea4f43 chore: step 2 changes 2025-09-18 00:35:19 +07:00
amlannandy
191f72818a chore: first step changes 2025-09-18 00:35:19 +07:00
amlannandy
de1b7deb8d chore: light mode fixes 2025-09-18 00:35:17 +07:00
amlannandy
53aa06bff0 chore: init footer 2025-09-18 00:34:43 +07:00
amlannandy
9c5e4c86c0 chore: delete duplicate evaluate settings folder 2025-09-16 19:44:40 +07:00
amlannandy
58e802af93 chore: correct step number in twin layouts 2025-09-16 19:42:26 +07:00
amlannandy
7c1b694679 chore: add unit tests 2025-09-16 19:42:26 +07:00
amlannandy
a63d490dc5 feat: add notification settings component 2025-09-16 19:42:26 +07:00
amlannandy
9daa4009bd chore: add evaluation window option 2025-09-16 19:42:26 +07:00
amlannandy
954d1f2641 feat: add advanced options 2025-09-16 19:42:26 +07:00
amlannandy
dcac7ec8c7 chore: add twin layouts 2025-09-16 13:01:44 +07:00
amlannandy
233e1d79d7 chore: add tests 2025-09-16 13:01:44 +07:00
amlannandy
429ecba355 chore: renaming 2025-09-16 13:01:44 +07:00
amlannandy
4a84d2a666 chore: renaming 2025-09-16 13:01:44 +07:00
amlannandy
f58e2dcc5c chore: resolve inconsistencies 2025-09-16 13:01:44 +07:00
amlannandy
f412b908c3 chore: changes to evaluation window popover 2025-09-16 13:01:44 +07:00
amlannandy
1d1fd6ae26 chore: add evaluation cadence component 2025-09-16 13:01:44 +07:00
amlannandy
07ecb4b967 chore: add evaluation window option 2025-09-16 13:01:44 +07:00
amlannandy
87ce18ca1d feat: add advanced options 2025-09-16 13:01:44 +07:00
amlannandy
a09aea2b8e feat: add alert condition component 2025-09-16 13:01:44 +07:00
63 changed files with 7229 additions and 193 deletions

View File

@@ -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",

View File

@@ -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[] = [];

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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();
};

View File

@@ -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', () => {

View File

@@ -67,7 +67,7 @@
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: fit-content;
width: 100%;
.alert-condition-sentences {
display: flex;
@@ -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;
}
}
}
}

View File

@@ -8,7 +8,7 @@ export type UpdateThreshold = {
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string,
value: string | number | null,
): void;
};

View File

@@ -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 '';
}
};

View File

@@ -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 =>

View File

@@ -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');
});
});

View File

@@ -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);
}
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

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

View File

@@ -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);
}
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

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

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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,
};

View File

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

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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>,
};
};

View File

@@ -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;
}
}

View 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;

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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: [],
},
});
});
});

View File

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

View File

@@ -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);
}
}
}
}
}

View File

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

View File

@@ -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) => (

View File

@@ -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();

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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,
};

View File

@@ -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 (

View File

@@ -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' };

View File

@@ -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;
}
};

View File

@@ -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';

View File

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

View File

@@ -1,7 +1,7 @@
import './QuerySection.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Tooltip } from 'antd';
import { Button, Tabs, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
@@ -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>
),

View File

@@ -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"