diff --git a/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md b/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md new file mode 100644 index 00000000000..2e2964019a7 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Changed MetricCritera, DimensionFilter and Alert Interfaces ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 60b8a804936..2e4e6c4658a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -2,7 +2,11 @@ export type AlertSeverityType = 0 | 1 | 2 | 3; export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; export type AlertServiceType = 'linode' | 'dbaas'; -type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; +export type DimensionFilterOperatorType = + | 'eq' + | 'neq' + | 'startswith' + | 'endswith'; export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; export type CriteriaConditionType = 'ALL'; @@ -164,20 +168,24 @@ export interface MetricCriteria { aggregation_type: MetricAggregationType; operator: MetricOperatorType; threshold: number; - dimension_filters: DimensionFilter[]; + dimension_filters?: DimensionFilter[]; } -export interface AlertDefinitionMetricCriteria extends MetricCriteria { +export interface AlertDefinitionMetricCriteria + extends Omit { unit: string; label: string; + dimension_filters?: AlertDefinitionDimensionFilter[]; } export interface DimensionFilter { - label: string; dimension_label: string; operator: DimensionFilterOperatorType; value: string; } +export interface AlertDefinitionDimensionFilter extends DimensionFilter { + label: string; +} export interface TriggerCondition { polling_interval_seconds: number; evaluation_period_seconds: number; diff --git a/packages/manager/.changeset/pr-11445-added-1734705630196.md b/packages/manager/.changeset/pr-11445-added-1734705630196.md new file mode 100644 index 00000000000..46161fa6057 --- /dev/null +++ b/packages/manager/.changeset/pr-11445-added-1734705630196.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +DimensionFilter, DimensionFilterField, TriggerCondition component along with Unit Tests ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 0e393915376..8df01ae47cc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -10,6 +10,7 @@ import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; +import { TriggerConditions } from './Criteria/TriggerConditions'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; @@ -18,14 +19,17 @@ import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect' import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues } from './utilities'; -import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; -import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; +import type { + CreateAlertDefinitionForm, + MetricCriteriaForm, + TriggerConditionForm, +} from './types'; import type { ObjectSchema } from 'yup'; -const triggerConditionInitialValues: TriggerCondition = { +const triggerConditionInitialValues: TriggerConditionForm = { criteria_condition: 'ALL', - evaluation_period_seconds: 0, - polling_interval_seconds: 0, + evaluation_period_seconds: null, + polling_interval_seconds: null, trigger_occurrences: 0, }; const criteriaInitialValues: MetricCriteriaForm = { @@ -164,8 +168,10 @@ export const CreateAlertDefinition = () => { name="rule_criteria.rules" serviceType={serviceTypeWatcher!} /> - {/* This is just being displayed to pass the typecheck-manager test. In the next PR maxScrapeInterval will be used by another component */} - {maxScrapeInterval} + { + const user = userEvent.setup(); + + it('render the fields properly', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + expect(screen.getByText('Dimension Filter')).toBeVisible(); + expect(screen.getByText('(optional)')).toBeVisible(); + await user.click( + container.getByRole('button', { name: 'Add dimension filter' }) + ); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Value')).toBeVisible(); + }); + + it('does not render the dimension filed directly with Metric component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dimensionFilterID = 'rule_criteria.rules.0.dimension_filters.0-id'; + expect(container.queryByTestId(dimensionFilterID)).not.toBeInTheDocument(); + }); + + it('adds and removes dimension filter fields dynamically', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + + const dimensionFilterID = 'rule_criteria.rules.0.dimension_filters.1-id'; + await user.click( + container.getByRole('button', { name: dimensionFilterButton }) + ); + await user.click( + container.getByRole('button', { name: dimensionFilterButton }) + ); + expect(container.getByTestId(dimensionFilterID)).toBeInTheDocument(); + await user.click( + within(container.getByTestId(dimensionFilterID)).getByTestId('clear-icon') + ); + await waitFor(() => + expect(container.queryByTestId(dimensionFilterID)).not.toBeInTheDocument() + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx new file mode 100644 index 00000000000..a9a05303102 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -0,0 +1,70 @@ +import { Box } from '@linode/ui'; +import { Button, Stack, Typography } from '@linode/ui'; +import React from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; + +import { DimensionFilterField } from './DimensionFilterField'; + +import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; +import type { Dimension } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface DimensionFilterProps { + /** + * boolean value to disable the Data Field in dimension filter + */ + dataFieldDisabled: boolean; + /** + * dimension filter data for the selected metric + */ + dimensionOptions: Dimension[]; + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} +export const DimensionFilters = (props: DimensionFilterProps) => { + const { dataFieldDisabled, dimensionOptions, name } = props; + const { control } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, + name, + }); + return ( + + + Dimension Filter + (optional) + + + + {fields?.length > 0 && + fields.map((field, index) => ( + remove(index)} + /> + ))} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx new file mode 100644 index 00000000000..c5085cb365b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -0,0 +1,228 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { DimensionOperatorOptions } from '../../constants'; +import { DimensionFilterField } from './DimensionFilterField'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { MetricDefinition } from '@linode/api-v4'; + +const mockData: MetricDefinition[] = [ + { + available_aggregate_functions: ['min', 'max', 'avg'], + dimensions: [ + { + dimension_label: 'cpu', + label: 'CPU name', + values: [], + }, + { + dimension_label: 'state', + label: 'State of CPU', + values: [ + 'user', + 'system', + 'idle', + 'interrupt', + 'nice', + 'softirq', + 'steal', + 'wait', + ], + }, + { + dimension_label: 'LINODE_ID', + label: 'Linode ID', + values: [], + }, + ], + label: 'CPU utilization', + metric: 'system_cpu_utilization_percent', + metric_type: 'gauge', + scrape_interval: '2m', + unit: 'percent', + }, +]; + +const dimensionFieldMockData = mockData[0].dimensions; +describe('Dimension filter field component', () => { + const user = userEvent.setup(); + it('should render all the components and names', () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + }); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Value')).toBeVisible(); + }); + + it('should render the Data Field component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dataFieldContainer = container.getByTestId('data-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await user.click(dataFieldInput); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[0].label, + }) + ).toBeInTheDocument(); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].label, + }) + ).toBeInTheDocument(); + await user.click( + container.getByRole('option', { name: dimensionFieldMockData[0].label }) + ); + expect(within(dataFieldContainer).getByRole('combobox')).toHaveAttribute( + 'value', + dimensionFieldMockData[0].label + ); + }); + + it('should render the Operator component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const operatorContainer = container.getByTestId('operator'); + const operatorInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + + user.click(operatorInput); + + expect( + await container.findByRole('option', { + name: DimensionOperatorOptions[1].label, + }) + ); + + await user.click( + await container.findByRole('option', { + name: DimensionOperatorOptions[0].label, + }) + ); + + expect(within(operatorContainer).getByRole('combobox')).toHaveAttribute( + 'value', + DimensionOperatorOptions[0].label + ); + }); + + it('should render the Value component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dataFieldContainer = container.getByTestId('data-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await user.click(dataFieldInput); + await user.click( + await container.findByRole('option', { + name: dimensionFieldMockData[1].label, + }) + ); + const valueContainer = container.getByTestId('value'); + const valueInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + + user.click(valueInput); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].values[0], + }) + ); + + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].values[1], + }) + ); + + await user.click( + container.getByRole('option', { + name: dimensionFieldMockData[1].values[0], + }) + ); + + expect(within(valueContainer).getByRole('combobox')).toHaveAttribute( + 'value', + dimensionFieldMockData[1].values[0] + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx new file mode 100644 index 00000000000..b0b877c3bf0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -0,0 +1,186 @@ +import { Autocomplete, Box } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { DimensionOperatorOptions } from '../../constants'; +import { ClearIconButton } from './ClearIconButton'; + +import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; +import type { Dimension, DimensionFilterOperatorType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface DimensionFilterFieldProps { + /** + * boolean value to disable the Data Field in dimension filter + */ + dataFieldDisabled: boolean; + /** + * dimension filter data options to list in the Autocomplete component + */ + dimensionOptions: Dimension[]; + /** + * name (with the index) used for the component to set in form + */ + name: FieldPathByValue; + /** + * function to delete the DimensionFilter component + * @returns void + */ + onFilterDelete: () => void; +} + +export const DimensionFilterField = (props: DimensionFilterFieldProps) => { + const { dataFieldDisabled, dimensionOptions, name, onFilterDelete } = props; + + const { control, setValue } = useFormContext(); + + const dataFieldOptions = + dimensionOptions.map((dimension) => ({ + label: dimension.label, + value: dimension.dimension_label, + })) ?? []; + + const handleDataFieldChange = ( + selected: { label: string; value: string }, + operation: string + ) => { + const fieldValue = { + dimension_label: null, + operator: null, + value: null, + }; + setValue( + name, + operation === 'selectOption' + ? { ...fieldValue, dimension_label: selected.value } + : fieldValue, + { shouldValidate: true } + ); + }; + + const dimensionFieldWatcher = useWatch({ + control, + name: `${name}.dimension_label`, + }); + + const selectedDimension = + dimensionOptions && dimensionFieldWatcher + ? dimensionOptions.find( + (dim) => dim.dimension_label === dimensionFieldWatcher + ) ?? null + : null; + + const valueOptions = () => { + if (selectedDimension !== null) { + return selectedDimension.values.map((val) => ({ + label: val, + value: val, + })); + } + return []; + }; + + return ( + + + ( + { + handleDataFieldChange(newValue, operation); + }} + value={ + dataFieldOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="data-field" + disabled={dataFieldDisabled} + errorText={fieldState.error?.message} + label="Data Field" + onBlur={field.onBlur} + options={dataFieldOptions} + placeholder="Select a Data field" + /> + )} + control={control} + name={`${name}.dimension_label`} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + }} + value={ + DimensionOperatorOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="operator" + errorText={fieldState.error?.message} + label="Operator" + onBlur={field.onBlur} + options={DimensionOperatorOptions} + /> + )} + control={control} + name={`${name}.operator`} + /> + + + + ( + + option.value === value.value + } + onChange={( + _, + selected: { label: string; value: string }, + operation + ) => { + field.onChange( + operation === 'selectOption' ? selected.value : null + ); + }} + value={ + valueOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="value" + disabled={!dimensionFieldWatcher} + errorText={fieldState.error?.message} + label="Value" + onBlur={field.onBlur} + options={valueOptions()} + placeholder="Select a Value" + sx={{ flex: 1 }} + /> + )} + control={control} + name={`${name}.value`} + /> + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx index dfd3cea1995..3de387b7319 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx @@ -91,7 +91,7 @@ describe('Metric component tests', () => { }, } ); - const dataFieldContainer = container.getByTestId('Data-field'); + const dataFieldContainer = container.getByTestId('data-field'); expect( within(dataFieldContainer).getByRole('button', { name: @@ -140,7 +140,7 @@ describe('Metric component tests', () => { } ); - const aggregationTypeContainer = container.getByTestId('Aggregation-type'); + const aggregationTypeContainer = container.getByTestId('aggregation-type'); const aggregationTypeInput = within( aggregationTypeContainer ).getByRole('button', { name: 'Open' }); @@ -185,7 +185,7 @@ describe('Metric component tests', () => { }, } ); - const operatorContainer = container.getByTestId('Operator'); + const operatorContainer = container.getByTestId('operator'); const operatorInput = within(operatorContainer).getByRole('button', { name: 'Open', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index 5a4d317237c..6851a6e53c8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Box } from '@linode/ui'; -import { Stack, TextField, Typography } from '@linode/ui'; +import { TextField, Typography } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -9,12 +9,13 @@ import { MetricOperatorOptions, } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; +import { DimensionFilters } from './DimensionFilter'; import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from '../types'; import type { - MetricDefinition, MetricAggregationType, + MetricDefinition, MetricOperatorType, } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; @@ -68,15 +69,13 @@ export const Metric = (props: MetricCriteriaProps) => { operator: null, threshold: 0, }; - if (operation === 'selectOption') { - setValue(name, { - ...fieldValue, - metric: selected.value, - }); - } - if (operation === 'clear') { - setValue(name, fieldValue); - } + setValue( + name, + operation === 'selectOption' + ? { ...fieldValue, metric: selected.value } + : fieldValue, + { shouldValidate: true } + ); }; const metricOptions = React.useMemo(() => { @@ -113,20 +112,24 @@ export const Metric = (props: MetricCriteriaProps) => { ({ backgroundColor: - theme.name === 'light' ? theme.color.grey5 : theme.color.grey9, + theme.name === 'light' + ? theme.tokens.color.Neutrals[5] + : theme.tokens.color.Neutrals.Black, borderRadius: 1, + display: 'flex', + flexDirection: 'column', + gap: 2, p: 2, })} data-testid={`${name}-id`} > - + Metric Threshold - - {showDeleteIcon && } - + {showDeleteIcon && } - + + ( @@ -152,16 +155,15 @@ export const Metric = (props: MetricCriteriaProps) => { 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way.', }} value={ - field.value !== null - ? metricOptions.find( - (option) => option.value === field.value - ) - : null + metricOptions.find( + (option) => option.value === field.value + ) ?? null } - data-testid="Data-field" + data-testid="data-field" disabled={!serviceWatcher} label="Data Field" loading={isMetricDefinitionLoading} + noMarginTop onBlur={field.onBlur} options={metricOptions} placeholder="Select a Data field" @@ -181,25 +183,20 @@ export const Metric = (props: MetricCriteriaProps) => { newValue: { label: string; value: MetricAggregationType }, operation ) => { - if (operation === 'selectOption') { - field.onChange(newValue.value); - } - if (operation === 'clear') { - field.onChange(null); - } + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); }} value={ - field.value !== null - ? aggOptions.find( - (option) => option.value === field.value - ) - : null + aggOptions.find((option) => option.value === field.value) ?? + null } - data-testid="Aggregation-type" + data-testid="aggregation-type" disabled={aggOptions.length === 0} errorText={fieldState.error?.message} key={metricWatcher} label="Aggregation Type" + noMarginTop onBlur={field.onBlur} options={aggOptions} placeholder="Select an Aggregation type" @@ -210,7 +207,7 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.aggregation_type`} /> - + ( { selected: { label: string; value: MetricOperatorType }, operation ) => { - if (operation === 'selectOption') { - field.onChange(selected.value); - } - if (operation === 'clear') { - field.onChange(null); - } + field.onChange( + operation === 'selectOption' ? selected.value : null + ); }} value={ field.value !== null @@ -233,10 +227,11 @@ export const Metric = (props: MetricCriteriaProps) => { ) : null } - data-testid="Operator" + data-testid="operator" errorText={fieldState.error?.message} key={metricWatcher} label="Operator" + noMarginTop onBlur={field.onBlur} options={MetricOperatorOptions} placeholder="Select an operator" @@ -247,51 +242,53 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.operator`} /> - - - - ( - ) => - event.target instanceof HTMLElement && - event.target.blur() - } - data-testid="threshold" - errorText={fieldState.error?.message} - label="Threshold" - min={0} - name={`${name}.threshold`} - onBlur={field.onBlur} - onChange={(e) => field.onChange(e.target.value)} - sx={{ height: '34px' }} - type="number" - value={field.value ?? 0} - /> - )} - control={control} - name={`${name}.threshold`} - /> - - - - {/* There are discussions going on with the UX and within the team about the - * units being outside of the TextField or inside as an adornments - */} - {unit} - - - + + + ( + ) => + event.target instanceof HTMLElement && event.target.blur() + } + data-testid="threshold" + errorText={fieldState.error?.message} + label="Threshold" + min={0} + name={`${name}.threshold`} + noMarginTop + onBlur={field.onBlur} + onChange={(e) => field.onChange(e.target.value)} + sx={{ height: '34px', marginTop: { sm: 1, xs: 0 } }} + type="number" + value={field.value ?? 0} + /> + )} + control={control} + name={`${name}.threshold`} + /> + + {/* There are discussions going on with the UX and within the team about the + * units being outside of the TextField or inside as an adornments + */} + {unit} + + - + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx index 82a739fa29a..82654dcefb2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx @@ -243,43 +243,4 @@ describe('MetricCriteriaField', () => { expect(setMaxInterval).toBeCalledWith(firstOptionConvertedTime); }); - - it('setMaxInterval has to be called', async () => { - queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ - data: mockData, - isError: true, - isLoading: false, - status: 'error', - }); - const setMaxInterval = vi.fn(); - const firstOption = mockData.data[0]; - const secondOption = mockData.data[1]; - const [ - firstOptionConvertedTime, - secondOptionConvertedTime, - ] = convertToSeconds([ - firstOption.scrape_interval, - secondOption.scrape_interval, - ]); - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [firstOption, secondOption], - }, - }, - }, - }); - - expect(setMaxInterval).toBeCalledWith( - Math.max(firstOptionConvertedTime, secondOptionConvertedTime) - ); - }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx new file mode 100644 index 00000000000..6c9f6ada950 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx @@ -0,0 +1,204 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { + EvaluationPeriodOptions, + PollingIntervalOptions, +} from '../../constants'; +import { TriggerConditions } from './TriggerConditions'; + +import type { CreateAlertDefinitionForm } from '../types'; + +const EvaluationPeriodTestId = 'evaluation-period'; + +const PollingIntervalTestId = 'polling-interval'; +describe('Trigger Conditions', () => { + const user = userEvent.setup(); + + it('should render all the components and names', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(container.getByLabelText('Evaluation Period')).toBeInTheDocument(); + expect(container.getByLabelText('Polling Interval')).toBeInTheDocument(); + expect( + container.getByText('Trigger alert when all criteria are met for') + ).toBeInTheDocument(); + expect( + container.getByText('consecutive occurrence(s).') + ).toBeInTheDocument(); + }); + + it('should render the tooltips for the Autocomplete components', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + }); + + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodToolTip = within(evaluationPeriodContainer).getByRole( + 'button', + { + name: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + } + ); + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalToolTip = within(pollingIntervalContainer).getByRole( + 'button', + { + name: 'Choose how often you intend to evaulate the alert condition.', + } + ); + expect(evaluationPeriodToolTip).toBeInTheDocument(); + expect(pollingIntervalToolTip).toBeInTheDocument(); + }); + + it('should render the Evaluation Period component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodInput = within( + evaluationPeriodContainer + ).getByRole('button', { name: 'Open' }); + + user.click(evaluationPeriodInput); + + expect( + await container.findByRole('option', { + name: EvaluationPeriodOptions.linode[1].label, + }) + ).toBeInTheDocument(); + expect( + await container.findByRole('option', { + name: EvaluationPeriodOptions.linode[2].label, + }) + ); + + await user.click( + container.getByRole('option', { + name: EvaluationPeriodOptions.linode[0].label, + }) + ); + + expect( + within(evaluationPeriodContainer).getByRole('combobox') + ).toHaveAttribute('value', EvaluationPeriodOptions.linode[0].label); + }); + + it('should render the Polling Interval component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalInput = within( + pollingIntervalContainer + ).getByRole('button', { name: 'Open' }); + + user.click(pollingIntervalInput); + + expect( + await container.findByRole('option', { + name: PollingIntervalOptions.linode[1].label, + }) + ).toBeInTheDocument(); + + expect( + await container.findByRole('option', { + name: PollingIntervalOptions.linode[2].label, + }) + ); + + await user.click( + container.getByRole('option', { + name: PollingIntervalOptions.linode[0].label, + }) + ); + expect( + within(pollingIntervalContainer).getByRole('combobox') + ).toHaveAttribute('value', PollingIntervalOptions.linode[0].label); + }); + + it('should be able to show the options that are greater than or equal to max scraping Interval', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + }); + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodInput = within( + evaluationPeriodContainer + ).getByRole('button', { name: 'Open' }); + + user.click(evaluationPeriodInput); + + expect( + screen.queryByText(EvaluationPeriodOptions.linode[0].label) + ).not.toBeInTheDocument(); + + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalInput = within( + pollingIntervalContainer + ).getByRole('button', { name: 'Open' }); + user.click(pollingIntervalInput); + expect( + screen.queryByText(PollingIntervalOptions.linode[0].label) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx new file mode 100644 index 00000000000..65338bf4548 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx @@ -0,0 +1,179 @@ +import { Autocomplete, Box, TextField, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import * as React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { + EvaluationPeriodOptions, + PollingIntervalOptions, +} from '../../constants'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { + CreateAlertDefinitionPayload, + TriggerCondition, +} from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; +interface TriggerConditionProps { + /** + * maximum scraping interval value for a metric to filter the evaluation period and polling interval options + */ + maxScrapingInterval: number; + /** + * name used for the component to set in form + */ + name: FieldPathByValue; +} +export const TriggerConditions = (props: TriggerConditionProps) => { + const { maxScrapingInterval, name } = props; + + const { control } = useFormContext(); + const serviceTypeWatcher = useWatch({ + control, + name: 'serviceType', + }); + const getPollingIntervalOptions = () => { + const options = serviceTypeWatcher + ? PollingIntervalOptions[serviceTypeWatcher] + : []; + return options.filter((item) => item.value >= maxScrapingInterval); + }; + + const getEvaluationPeriodOptions = () => { + const options = serviceTypeWatcher + ? EvaluationPeriodOptions[serviceTypeWatcher] + : []; + return options.filter((item) => item.value >= maxScrapingInterval); + }; + + return ( + ({ + backgroundColor: + theme.name === 'light' + ? theme.tokens.color.Neutrals[5] + : theme.tokens.color.Neutrals.Black, + borderRadius: 1, + marginTop: theme.spacing(2), + p: 2, + })} + > + Trigger Conditions + + + ( + { + field.onChange( + operation === 'selectOption' ? selected.value : null + ); + }} + textFieldProps={{ + labelTooltipText: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + }} + value={ + getEvaluationPeriodOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="evaluation-period" + disabled={!serviceTypeWatcher} + errorText={fieldState.error?.message} + label="Evaluation Period" + onBlur={field.onBlur} + options={getEvaluationPeriodOptions()} + placeholder="Select an Evaluation period" + /> + )} + control={control} + name={`${name}.evaluation_period_seconds`} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + }} + textFieldProps={{ + labelTooltipText: + 'Choose how often you intend to evaulate the alert condition.', + }} + value={ + getPollingIntervalOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="polling-interval" + disabled={!serviceTypeWatcher} + errorText={fieldState.error?.message} + label="Polling Interval" + onBlur={field.onBlur} + options={getPollingIntervalOptions()} + placeholder="Select a Polling" + /> + )} + control={control} + name={`${name}.polling_interval_seconds`} + /> + + + + Trigger alert when all criteria are met for + + + ( + + event.target instanceof HTMLElement && event.target.blur() + } + sx={{ + height: '30px', + width: '30px', + }} + data-testid="trigger-occurences" + errorText={fieldState.error?.message} + label="" + min={0} + name={`${name}.trigger_occurrences`} + onBlur={field.onBlur} + onChange={(e) => field.onChange(e.target.value)} + type="number" + value={field.value ?? 0} + /> + )} + control={control} + name={`${name}.trigger_occurrences`} + /> + + + consecutive occurrence(s). + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx index 4d0dfd6bd4e..fce9818b017 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx @@ -9,7 +9,7 @@ import { CloudPulseAlertSeveritySelect } from './AlertSeveritySelect'; describe('Severity component tests', () => { it('should render the component', () => { const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByLabelText('Severity')).toBeInTheDocument(); expect(getByTestId('severity')).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx index cdd10fe690a..376ed7ba7f3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx @@ -47,7 +47,7 @@ export const CloudPulseAlertSeveritySelect = ( ) : null } - data-testid={'severity'} + data-testid="severity" errorText={fieldState.error?.message} label="Severity" onBlur={field.onBlur} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx index 1da3f1df9e0..f9b36f444ce 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx @@ -9,7 +9,7 @@ import { EngineOption } from './EngineOption'; describe('EngineOption component tests', () => { it('should render the component when resource type is dbaas', () => { const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByLabelText('Engine Option')).toBeInTheDocument(); expect(getByTestId('engine-option')).toBeInTheDocument(); @@ -17,7 +17,7 @@ describe('EngineOption component tests', () => { it('should render the options happy path', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); expect(await screen.findByRole('option', { name: 'MySQL' })); @@ -26,7 +26,7 @@ describe('EngineOption component tests', () => { it('should be able to select an option', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); await user.click(await screen.findByRole('option', { name: 'MySQL' })); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 63959e9c0bb..90671fce719 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -3,13 +3,18 @@ import type { AlertSeverityType, CreateAlertDefinitionPayload, DimensionFilter, + DimensionFilterOperatorType, MetricAggregationType, MetricCriteria, MetricOperatorType, + TriggerCondition, } from '@linode/api-v4'; export interface CreateAlertDefinitionForm - extends Omit { + extends Omit< + CreateAlertDefinitionPayload, + 'rule_criteria' | 'severity' | 'trigger_conditions' + > { engineType: null | string; entity_ids: string[]; region: string; @@ -18,12 +23,32 @@ export interface CreateAlertDefinitionForm }; serviceType: AlertServiceType | null; severity: AlertSeverityType | null; + trigger_conditions: TriggerConditionForm; } export interface MetricCriteriaForm - extends Omit { + extends Omit< + MetricCriteria, + 'aggregation_type' | 'dimension_filters' | 'metric' | 'operator' + > { aggregation_type: MetricAggregationType | null; - dimension_filters: DimensionFilter[]; + dimension_filters: DimensionFilterForm[]; metric: null | string; operator: MetricOperatorType | null; } + +export interface DimensionFilterForm + extends Omit { + dimension_label: null | string; + operator: DimensionFilterOperatorType | null; + value: null | string; +} + +export interface TriggerConditionForm + extends Omit< + TriggerCondition, + 'evaluation_period_seconds' | 'polling_interval_seconds' + > { + evaluation_period_seconds: null | number; + polling_interval_seconds: null | number; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 9a6bc907101..ba52a3b0068 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -1,9 +1,16 @@ import { omitProps } from '@linode/ui'; -import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { + CreateAlertDefinitionForm, + DimensionFilterForm, + MetricCriteriaForm, + TriggerConditionForm, +} from './types'; import type { CreateAlertDefinitionPayload, + DimensionFilter, MetricCriteria, + TriggerCondition, } from '@linode/api-v4'; // filtering out the form properties which are not part of the payload @@ -16,16 +23,18 @@ export const filterFormValues = ( 'engineType', 'severity', 'rule_criteria', + 'trigger_conditions', ]); - // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type const severity = formValues.severity ?? 1; const entityIds = formValues.entity_ids; const rules = formValues.rule_criteria.rules; + const triggerConditions = formValues.trigger_conditions; return { ...values, entity_ids: entityIds, rule_criteria: { rules: filterMetricCriteriaFormValues(rules) }, severity, + trigger_conditions: filterTriggerConditionFormValues(triggerConditions), }; }; @@ -37,13 +46,37 @@ export const filterMetricCriteriaFormValues = ( return { ...values, aggregation_type: rule.aggregation_type ?? 'avg', - dimension_filters: rule.dimension_filters, + dimension_filters: filterDimensionFilterFormValues( + rule.dimension_filters + ), metric: rule.metric ?? '', operator: rule.operator ?? 'eq', }; }); }; +const filterDimensionFilterFormValues = ( + formValues: DimensionFilterForm[] +): DimensionFilter[] => { + return formValues.map((dimensionFilter) => { + return { + dimension_label: dimensionFilter.dimension_label ?? '', + operator: dimensionFilter.operator ?? 'eq', + value: dimensionFilter.value ?? '', + }; + }); +}; + +const filterTriggerConditionFormValues = ( + formValues: TriggerConditionForm +): TriggerCondition => { + return { + ...formValues, + evaluation_period_seconds: formValues.evaluation_period_seconds ?? 0, + polling_interval_seconds: formValues.polling_interval_seconds ?? 0, + }; +}; + export const convertToSeconds = (secondsList: string[]) => { return secondsList.map((second) => { const unit = second.slice(-1)[0]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 53c6c880d91..f6397392284 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,5 +1,6 @@ import type { AlertSeverityType, + DimensionFilterOperatorType, AlertStatusType, MetricAggregationType, MetricOperatorType, @@ -74,6 +75,48 @@ export const MetricAggregationOptions: Item[] = [ }, ]; +export const DimensionOperatorOptions: Item< + string, + DimensionFilterOperatorType +>[] = [ + { + label: 'Equal', + value: 'eq', + }, + { + label: 'Ends with', + value: 'endswith', + }, + { + label: 'Not Equal', + value: 'neq', + }, + { + label: 'Starts with', + value: 'startswith', + }, +]; + +export const EvaluationPeriodOptions = { + dbaas: [{ label: '5 min', value: 300 }], + linode: [ + { label: '1 min', value: 60 }, + { label: '5 min', value: 300 }, + { label: '15 min', value: 900 }, + { label: '30 min', value: 1800 }, + { label: '1 hr', value: 3600 }, + ], +}; + +export const PollingIntervalOptions = { + dbaas: [{ label: '5 min', value: 300 }], + linode: [ + { label: '1 min', value: 60 }, + { label: '5 min', value: 300 }, + { label: '10 min', value: 600 }, + ], +}; + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 51f5dc70dae..da3347b9894 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2499,12 +2499,12 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg'], dimensions: [ { - dim_label: 'cpu', + dimension_label: 'cpu', label: 'CPU name', values: null, }, { - dim_label: 'state', + dimension_label: 'state', label: 'State of CPU', values: [ 'user', @@ -2518,7 +2518,7 @@ export const handlers = [ ], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2533,7 +2533,7 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'state', + dimension_label: 'state', label: 'State of memory', values: [ 'used', @@ -2545,7 +2545,7 @@ export const handlers = [ ], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2560,17 +2560,17 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'device', + dimension_label: 'device', label: 'Device name', values: ['lo', 'eth0'], }, { - dim_label: 'direction', + dimension_label: 'direction', label: 'Direction of network transfer', values: ['transmit', 'receive'], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2585,17 +2585,17 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'device', + dimension_label: 'device', label: 'Device name', values: ['loop0', 'sda', 'sdb'], }, { - dim_label: 'direction', + dimension_label: 'direction', label: 'Operation direction', values: ['read', 'write'], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, diff --git a/packages/validation/.changeset/pr-11445-changed-1734706687923.md b/packages/validation/.changeset/pr-11445-changed-1734706687923.md new file mode 100644 index 00000000000..d9e29241267 --- /dev/null +++ b/packages/validation/.changeset/pr-11445-changed-1734706687923.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Error messages for few attributes ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index 0128c96b73b..206385d3a80 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -1,7 +1,7 @@ import { array, number, object, string } from 'yup'; const dimensionFilters = object({ - dimension_label: string().required('Label is required for the filter.'), + dimension_label: string().required('Data Field is required for the filter.'), operator: string().required('Operator is required.'), value: string().required('Value is required.'), }); @@ -24,7 +24,8 @@ const triggerConditionValidation = object({ ), trigger_occurrences: number() .required('Trigger Occurrences is required.') - .positive('Number of occurrences must be greater than zero.'), + .positive('Value must be greater than zero.') + .typeError('Trigger Occurrences is required.'), }); export const createAlertDefinitionSchema = object({