From 35aba8c4fb78f0f77e98eecbcdd9b78cf95b5398 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:37:40 +0530 Subject: [PATCH 01/11] upcoming: [DI-22184] - Added Metric, MetricCriteria components with Unit Tests and minor changes to align with changes in API spec --- packages/api-v4/src/cloudpulse/types.ts | 25 +- .../src/factories/cloudpulse/alerts.ts | 3 +- .../CreateAlertDefinition.test.tsx | 52 +++- .../CreateAlert/CreateAlertDefinition.tsx | 29 +- .../CreateAlert/Criteria/Metric.test.tsx | 234 ++++++++++++++ .../Alerts/CreateAlert/Criteria/Metric.tsx | 293 ++++++++++++++++++ .../Criteria/MetricCriteria.test.tsx | 277 +++++++++++++++++ .../CreateAlert/Criteria/MetricCriteria.tsx | 107 +++++++ .../ResourceMultiSelect.tsx | 3 +- .../CloudPulse/Alerts/CreateAlert/types.ts | 10 +- .../Alerts/CreateAlert/utilities.ts | 57 +++- .../features/CloudPulse/Alerts/constants.ts | 52 +++- packages/validation/src/cloudpulse.schema.ts | 27 +- 13 files changed, 1130 insertions(+), 39 deletions(-) create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4b64bf16c30..e40ef3d116b 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -5,6 +5,17 @@ export type AlertServiceType = 'linode' | 'dbaas'; type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; export type AlertDefinitionType = 'default' | 'custom'; export type AlertStatusType = 'enabled' | 'disabled'; +export type CriteriaConditionType = 'ALL'; +export type MetricUnitType = + | 'number' + | 'byte' + | 'second' + | 'percent' + | 'bit_per_second' + | 'millisecond' + | 'KB' + | 'MB' + | 'GB'; export interface Dashboard { id: number; label: string; @@ -148,18 +159,23 @@ export interface CreateAlertDefinitionPayload { rule_criteria: { rules: MetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_condition: TriggerCondition; channel_ids: number[]; } export interface MetricCriteria { metric: string; aggregation_type: MetricAggregationType; operator: MetricOperatorType; - value: number; + threshold: number; dimension_filters: DimensionFilter[]; } +export interface AlertDefinitionMetricCriteria extends MetricCriteria { + unit: string; + label: string; +} export interface DimensionFilter { + label: string; dimension_label: string; operator: DimensionFilterOperatorType; value: string; @@ -169,6 +185,7 @@ export interface TriggerCondition { polling_interval_seconds: number; evaluation_period_seconds: number; trigger_occurrences: number; + criteria_condition: CriteriaConditionType; } export interface Alert { id: number; @@ -181,9 +198,9 @@ export interface Alert { service_type: AlertServiceType; entity_ids: string[]; rule_criteria: { - rules: MetricCriteria[]; + rules: AlertDefinitionMetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_condition: TriggerCondition; channels: { id: string; label: string; diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index a0bc2b6edf7..326acc98bb1 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -17,7 +17,8 @@ export const alertFactory = Factory.Sync.makeFactory({ service_type: 'linode', severity: 0, status: 'enabled', - triggerCondition: { + trigger_condition: { + criteria_condition: 'ALL', evaluation_period_seconds: 0, polling_interval_seconds: 0, trigger_occurrences: 0, diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index c8342ee6293..76cf1f479d3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -7,12 +7,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAlertDefinition } from './CreateAlertDefinition'; describe('AlertDefinition Create', () => { it('should render input components', async () => { - const { getByLabelText } = renderWithTheme(); + const { getByLabelText, getByText } = renderWithTheme( + + ); + expect(getByText('1. General Information')).toBeVisible(); expect(getByLabelText('Name')).toBeVisible(); expect(getByLabelText('Description (optional)')).toBeVisible(); expect(getByLabelText('Severity')).toBeVisible(); + expect(getByLabelText('Service')).toBeVisible(); + expect(getByLabelText('Region')).toBeVisible(); + expect(getByLabelText('Resources')).toBeVisible(); + expect(getByText('2. Criteria')).toBeVisible(); + expect(getByText('Metric Threshold')).toBeVisible(); + expect(getByLabelText('Data Field')).toBeVisible(); + expect(getByLabelText('Aggregation Type')).toBeVisible(); + expect(getByLabelText('Operator')); + expect(getByLabelText('Threshold')); }); + it('should be able to enter a value in the textbox', async () => { const { getByLabelText } = renderWithTheme(); const input = getByLabelText('Name'); @@ -23,17 +36,40 @@ describe('AlertDefinition Create', () => { ); expect(specificInput).toHaveAttribute('value', 'text'); }); + it('should render client side validation errors', async () => { - const { getByText } = renderWithTheme(); + const user = userEvent.setup(); + const container = renderWithTheme(); + const input = container.getByLabelText('Threshold'); + const submitButton = container.getByText('Submit').closest('button'); + + await userEvent.click(submitButton!); + + expect(container.getByText('Name is required.')).toBeVisible(); + expect(container.getByText('Severity is required.')).toBeVisible(); + expect(container.getByText('Service is required.')).toBeVisible(); + expect(container.getByText('Region is required.')).toBeVisible(); + expect( + container.getByText('At least one resource is needed.') + ).toBeVisible(); + expect(container.getByText('Metric Data Field is required.')).toBeVisible(); + expect(container.getByText('Aggregation type is required.')).toBeVisible(); + expect(container.getByText('Criteria Operator is required.')).toBeVisible(); + + await user.clear(input); + await user.type(input, '-3'); + await userEvent.click(submitButton!); - const submitButton = getByText('Submit').closest('button'); + expect( + await container.findByText('Threshold value cannot be negative.') + ).toBeVisible(); + await user.clear(input); + await user.type(input, 'sdgf'); await userEvent.click(submitButton!); - expect(getByText('Name is required.')).toBeVisible(); - expect(getByText('Severity is required.')).toBeVisible(); - expect(getByText('Service is required.')).toBeVisible(); - expect(getByText('Region is required.')).toBeVisible(); - expect(getByText('At least one resource is needed.')).toBeVisible(); + expect( + await container.findByText('Threshold value should be a number.') + ).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 61a6822075e..94101bd6ad5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -9,18 +9,20 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; +import { MetricCriteriaField } from './Criteria/MetricCriteria'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; import { CreateAlertDefinitionFormSchema } from './schemas'; -import { filterFormValues, filterMetricCriteriaFormValues } from './utilities'; +import { filterFormValues } from './utilities'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; const triggerConditionInitialValues: TriggerCondition = { + criteria_condition: 'ALL', evaluation_period_seconds: 0, polling_interval_seconds: 0, trigger_occurrences: 0, @@ -28,9 +30,9 @@ const triggerConditionInitialValues: TriggerCondition = { const criteriaInitialValues: MetricCriteriaForm = { aggregation_type: null, dimension_filters: [], - metric: '', + metric: null, operator: null, - value: 0, + threshold: 0, }; const initialValues: CreateAlertDefinitionForm = { channel_ids: [], @@ -39,11 +41,11 @@ const initialValues: CreateAlertDefinitionForm = { label: '', region: '', rule_criteria: { - rules: filterMetricCriteriaFormValues(criteriaInitialValues), + rules: [criteriaInitialValues], }, serviceType: null, severity: null, - triggerCondition: triggerConditionInitialValues, + trigger_condition: triggerConditionInitialValues, }; const overrides = [ @@ -81,6 +83,11 @@ export const CreateAlertDefinition = () => { getValues('serviceType')! ); + /** + * The maxScrapeInterval variable will be required for the Trigger Conditions part of the Critieria section. + */ + const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); + const serviceTypeWatcher = watch('serviceType'); const onSubmit = handleSubmit(async (values) => { try { @@ -92,8 +99,13 @@ export const CreateAlertDefinition = () => { } catch (errors) { for (const error of errors) { if (error.field) { + // eslint-disable-next-line no-console + console.log(error); setError(error.field, { message: error.reason }); } else { + enqueueSnackbar(`Alert failed: ${error.reason}`, { + variant: 'error', + }); setError('root', { message: error.reason }); } } @@ -150,6 +162,13 @@ export const CreateAlertDefinition = () => { serviceType={serviceTypeWatcher} /> + + setMaxScrapeInterval(interval) + } + name="rule_criteria.rules" + serviceType={serviceTypeWatcher!} + /> { + const user = userEvent.setup(); + it('should render all the components and names', () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + expect(container.getAllByLabelText('Data Field')); + expect(container.getAllByLabelText('Aggregation Type')); + expect(container.getAllByLabelText('Operator')); + expect(container.getAllByLabelText('Threshold')); + }); + + it('should render the Data Field component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + const dataFieldContainer = container.getByTestId('Data-field'); + expect( + within(dataFieldContainer).getByRole('button', { + name: + '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.', + }) + ); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + user.click(dataFieldInput); + + expect( + await container.findByRole('option', { name: mockData[0].label }) + ).toBeInTheDocument(); + + await user.click( + container.getByRole('option', { name: mockData[0].label }) + ); + expect(within(dataFieldContainer).getByRole('combobox')).toHaveAttribute( + 'value', + mockData[0].label + ); + }); + + it('should render the Aggregation Type component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + + const aggregationTypeContainer = container.getByTestId('Aggregation-type'); + const aggregationTypeInput = within( + aggregationTypeContainer + ).getByRole('button', { name: 'Open' }); + + user.click(aggregationTypeInput); + + expect( + await container.findByRole('option', { name: 'Minimum' }) + ).toBeInTheDocument(); + + expect( + container.getByRole('option', { name: 'Average' }) + ).toBeInTheDocument(); + + await user.click(await container.findByRole('option', { name: 'Average' })); + + expect( + within(aggregationTypeContainer).getByRole('combobox') + ).toHaveAttribute('value', 'Average'); + }); + + 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: '>' }) + ).toBeInTheDocument(); + expect(container.getByRole('option', { name: '==' })).toBeInTheDocument(); + expect(container.getByRole('option', { name: '<' })).toBeInTheDocument(); + await user.click(await container.findByRole('option', { name: '>' })); + + expect(within(operatorContainer).getByRole('combobox')).toHaveAttribute( + 'value', + '>' + ); + }); + + it('should render the Threshold component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + + const input = container.getByLabelText('Threshold'); + await user.clear(input); + await user.type(input, '3'); + const thresholdInput = within( + container.getByTestId('threshold') + ).getByTestId('textfield-input'); + expect(container.getByDisplayValue('3')).toBeInTheDocument(); + expect(thresholdInput.getAttribute('type')).toBe('number'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx new file mode 100644 index 00000000000..b28b2aba5f9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -0,0 +1,293 @@ +import { Autocomplete, Box } from '@linode/ui'; +import { Stack, TextField, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { + MetricAggregationOptions, + MetricOperatorOptions, +} from '../../constants'; +import { StyledDeleteIcon } from '../utilities'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from '../types'; +import type { + AvailableMetrics, + MetricAggregationType, + MetricOperatorType, + MetricUnitType, +} from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface MetricCriteriaProps { + /** + * apiErrors while accessing the metric definitions endpoint + */ + apiError: boolean[]; + /** + * metric data fetched by the metric definitions endpoint + */ + data: AvailableMetrics[]; + /** + * name (with the index) used for the component to set in form + */ + name: FieldPathByValue; + /** + * function to delete the Metric component + * @returns void + */ + onMetricDelete: () => void; + /** + * to control when to show the delete icon + */ + showDeleteIcon: boolean; +} +export const Metric = (props: MetricCriteriaProps) => { + const { apiError, data, name, onMetricDelete, showDeleteIcon } = props; + const [isMetricDefinitionError, isMetricDefinitionLoading] = apiError; + const { + control, + setValue, + watch, + } = useFormContext(); + + const handleDataFieldChange = ( + selected: { label: string; unit: MetricUnitType; value: string }, + operation: string + ) => { + const fieldValue: MetricCriteriaForm = { + aggregation_type: null, + dimension_filters: [], + metric: null, + operator: null, + threshold: 0, + }; + if (operation === 'selectOption') { + setValue(name, { + ...fieldValue, + metric: selected.value, + }); + } + if (operation === 'clear') { + setValue(name, fieldValue); + } + }; + + const metricOptions = React.useMemo(() => { + return data + ? data.map((metric) => ({ + label: metric.label, + value: metric.metric, + })) + : []; + }, [data]); + + const metricWatcher = watch(`${name}.metric`); + + const selectedMetric = React.useMemo(() => { + return data && metricWatcher + ? data.find((metric) => metric.metric === metricWatcher) + : null; + }, [data, metricWatcher]); + + const unit = selectedMetric?.unit ?? null; + const aggOptions = React.useMemo((): Item< + string, + MetricAggregationType + >[] => { + return selectedMetric && selectedMetric.available_aggregate_functions + ? MetricAggregationOptions.filter((option) => + selectedMetric.available_aggregate_functions.includes(option.value) + ) + : []; + }, [selectedMetric]); + + return ( + ({ + backgroundColor: + theme.name === 'light' ? theme.color.grey5 : theme.color.grey9, + borderRadius: 1, + p: 2, + })} + data-testid={`${name}-id`} + > + + + Metric Threshold + + {showDeleteIcon && ( + + )} + + + + + ( + { + handleDataFieldChange(newValue, reason); + }} + textFieldProps={{ + labelTooltipText: + '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 + } + data-testid={'Data-field'} + disabled={!watch('serviceType')} + label="Data Field" + loading={isMetricDefinitionLoading} + onBlur={field.onBlur} + options={metricOptions} + placeholder="Select a Data field" + size="medium" + /> + )} + control={control} + name={`${name}.metric`} + /> + + + ( + { + if (operation === 'selectOption') { + field.onChange(newValue.value); + } + if (operation === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? aggOptions.find( + (option) => option.value === field.value + ) + : null + } + data-testid={'Aggregation-type'} + disabled={aggOptions.length === 0} + errorText={fieldState.error?.message} + key={metricWatcher} + label="Aggregation Type" + onBlur={field.onBlur} + options={aggOptions} + placeholder="Select an Aggregation type" + sx={{ paddingTop: { sm: 1, xs: 0 } }} + /> + )} + control={control} + name={`${name}.aggregation_type`} + /> + + + ( + { + if (operation === 'selectOption') { + field.onChange(selected.value); + } + if (operation === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? MetricOperatorOptions.find( + (option) => option.value === field.value + ) + : null + } + data-testid={'Operator'} + errorText={fieldState.error?.message} + key={metricWatcher} + label={'Operator'} + onBlur={field.onBlur} + options={MetricOperatorOptions} + placeholder="Select an operator" + sx={{ paddingTop: { sm: 1, xs: 0 } }} + /> + )} + control={control} + 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)} + type="number" + value={field.value ?? 0} + /> + )} + control={control} + name={`${name}.threshold`} + /> + + + + {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 new file mode 100644 index 00000000000..cbedaa7a38a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx @@ -0,0 +1,277 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { convertToSeconds } from '../utilities'; +import { MetricCriteriaField } from './MetricCriteria'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { MetricDefinitions } from '@linode/api-v4'; + +const queryMocks = vi.hoisted(() => ({ + useGetCloudPulseMetricDefinitionsByServiceType: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/services', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/services'); + return { + ...actual, + useGetCloudPulseMetricDefinitionsByServiceType: + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType, + }; +}); + +const mockData: MetricDefinitions = { + data: [ + { + 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', + }, + { + available_aggregate_functions: ['min', 'max', 'avg', 'sum'], + dimensions: [ + { + dimension_label: 'state', + label: 'State of memory', + values: [ + 'used', + 'free', + 'buffered', + 'cached', + 'slab_reclaimable', + 'slab_unreclaimable', + ], + }, + { + dimension_label: 'LINODE_ID', + label: 'Linode ID', + values: [], + }, + ], + label: 'Memory Usage', + metric: 'system_memory_usage_by_resource', + metric_type: 'gauge', + scrape_interval: '30s', + unit: 'byte', + }, + ], +}; + +queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: mockData, + isError: false, + isLoading: false, + status: 'success', +}); + +describe('MetricCriteriaField', () => { + const user = userEvent.setup(); + it('renders correctly', () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData.data[0]], + }, + }, + }, + }); + expect(screen.getByText('2. Criteria')).toBeVisible(); + expect(screen.getByText('Metric Threshold')).toBeVisible(); + }); + + it('renders the initial metric field without the delete-icon', async () => { + const { + getByTestId, + queryByTestId, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData.data[0]], + }, + }, + }, + }); + expect(getByTestId('rule_criteria.rules.0-id')).toBeInTheDocument(); + await waitFor(() => + expect( + queryByTestId('rule_criteria.rules.0-delete-icon') + ).not.toBeInTheDocument() + ); + }); + + it('handles error state while fetching metric definitions', async () => { + // Mock the API to simulate error state + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + status: 'error', + }), + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData.data[0]], + }, + }, + }, + }); + expect( + await screen.findByText(/Error in fetching the data./i) + ).toBeInTheDocument(); + }); + + it('adds and removes metric fields dynamically', async () => { + const { + getByTestId, + queryByTestId, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData.data[0]], + }, + }, + }, + }); + await user.click(screen.getByRole('button', { name: 'Add metric' })); + expect(getByTestId('rule_criteria.rules.1-id')).toBeInTheDocument(); + await user.click(screen.getByTestId('rule_criteria.rules.1-delete-icon')); + await waitFor(() => + expect(queryByTestId('rule_criteria.rules.1-id')).not.toBeInTheDocument() + ); + }); + + it('getMaxInterval has to be called', async () => { + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: mockData, + isError: true, + isLoading: false, + status: 'error', + }); + const getMaxInterval = vi.fn(); + const firstOption = mockData.data[0]; + const [firstOptionConvertedTime] = convertToSeconds([ + firstOption.scrape_interval, + ]); + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [firstOption], + }, + }, + }, + }); + + expect(getMaxInterval).toBeCalledWith(firstOptionConvertedTime); + }); + + it('getMaxInterval has to be called', async () => { + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: mockData, + isError: true, + isLoading: false, + status: 'error', + }); + const getMaxInterval = 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(getMaxInterval).toBeCalledWith( + Math.max(firstOptionConvertedTime, secondOptionConvertedTime) + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx new file mode 100644 index 00000000000..aa52850e398 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -0,0 +1,107 @@ +import { Box, Button, Stack, Typography } from '@linode/ui'; +import * as React from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; + +import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; + +import { convertToSeconds } from '../utilities'; +import { Metric } from './Metric'; + +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from '../types'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface MetricCriteriaProps { + /** + * function used to pass the scrape interval value to the parent component + * @param maxInterval number value that takes the maximum scrape interval from the list of selected metrics + * @returns void + */ + getMaxInterval: (maxInterval: number) => void; + /** + * name used for the component to set formik field + */ + name: FieldPathByValue; + /** + * serviceType used by the api to fetch the metric definitions + */ + serviceType: AlertServiceType | null; +} + +export const MetricCriteriaField = (props: MetricCriteriaProps) => { + const { getMaxInterval, name, serviceType } = props; + const { + data: metricDefinitions, + isError: isMetricDefinitionError, + isLoading: isMetricDefinitionLoading, + } = useGetCloudPulseMetricDefinitionsByServiceType( + serviceType!, + serviceType !== null + ); + + const { control, watch } = useFormContext(); + + const metricCriteriaWatcher = watch(name); + React.useEffect(() => { + const formMetricValues = new Set( + metricCriteriaWatcher.map((item: MetricCriteriaForm) => item.metric) + ); + + const intervalList = + metricDefinitions && + metricDefinitions.data + .filter((item) => formMetricValues.has(item.metric)) + .map((item) => item.scrape_interval); + const maxInterval = Math.max( + ...convertToSeconds(intervalList ? intervalList : []) + ); + getMaxInterval(maxInterval); + }, [getMaxInterval, metricCriteriaWatcher, metricDefinitions]); + + const { append, fields, remove } = useFieldArray({ + control, + name, + }); + return ( + ({ marginTop: theme.spacing(3) })}> + + 2. Criteria + + ({ marginTop: theme.spacing(3) })}> + {fields.map((field, index) => { + return ( + remove(index)} + showDeleteIcon={fields.length > 1} + /> + ); + })} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx index aabe52f3b8b..57d883a1ea4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -34,11 +34,12 @@ export const CloudPulseMultiResourceSelect = ( const { engine, name, region, serviceType } = { ...props }; const { control, setValue } = useFormContext(); + const { data: resources, isError, isLoading } = useResourcesQuery( Boolean(region && serviceType), serviceType?.toString(), {}, - engine !== null ? { engine, region } : { region } + serviceType === 'dbaas' ? { engine, region } : { region } ); const getResourcesList = React.useMemo((): Item[] => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 844b47639a0..63959e9c0bb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -2,22 +2,28 @@ import type { AlertServiceType, AlertSeverityType, CreateAlertDefinitionPayload, + DimensionFilter, MetricAggregationType, MetricCriteria, MetricOperatorType, } from '@linode/api-v4'; export interface CreateAlertDefinitionForm - extends Omit { + extends Omit { engineType: null | string; entity_ids: string[]; region: string; + rule_criteria: { + rules: MetricCriteriaForm[]; + }; serviceType: AlertServiceType | null; severity: AlertSeverityType | null; } export interface MetricCriteriaForm - extends Omit { + extends Omit { aggregation_type: MetricAggregationType | null; + dimension_filters: DimensionFilter[]; + metric: null | string; operator: MetricOperatorType | null; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 7459ca1c5da..b79ce32c704 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -1,4 +1,6 @@ import { omitProps } from '@linode/ui'; +import ClearOutlineOutlined from '@mui/icons-material/ClearOutlined'; +import styled from '@mui/material/styles/styled'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; import type { @@ -15,18 +17,61 @@ export const filterFormValues = ( 'region', 'engineType', 'severity', + 'rule_criteria', ]); // 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!; const entityIds = formValues.entity_ids; - return { ...values, entity_ids: entityIds, severity }; + const rules = formValues.rule_criteria.rules; + return { + ...values, + entity_ids: entityIds, + rule_criteria: { rules: filterMetricCriteriaFormValues(rules) }, + severity, + }; }; export const filterMetricCriteriaFormValues = ( - formValues: MetricCriteriaForm + formValues: MetricCriteriaForm[] ): MetricCriteria[] => { - const aggregationType = formValues.aggregation_type!; - const operator = formValues.operator!; - const values = omitProps(formValues, ['aggregation_type', 'operator']); - return [{ ...values, aggregation_type: aggregationType, operator }]; + return formValues.map((rule) => { + const values = omitProps(rule, ['aggregation_type', 'operator', 'metric']); + return { + ...values, + aggregation_type: rule.aggregation_type!, + dimension_filters: rule.dimension_filters, + metric: rule.metric!, + operator: rule.operator!, + }; + }); }; + +export const convertToSeconds = (secondsList: string[]) => { + return secondsList.map((second) => { + const unit = second.slice(-1)[0]; + const number = parseInt(second.slice(0, -1), 10); + switch (unit) { + case 's': + return number; + case 'm': + return number * 60; + case 'h': + return number * 3600; + default: + return number * 0; + } + }); +}; + +export const StyledDeleteIcon = styled(ClearOutlineOutlined)(({ theme }) => ({ + '&:active': { + transform: 'scale(0.9)', + }, + '&:hover': { + color: theme.color.blue, + }, + color: theme.palette.text.primary, + cursor: 'pointer', + margin: 2, + padding: 2, +})); diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 08f6223e263..8b80fc39d1c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,4 +1,8 @@ -import type { AlertSeverityType } from '@linode/api-v4'; +import type { + AlertSeverityType, + MetricAggregationType, + MetricOperatorType, +} from '@linode/api-v4'; export interface Item { label: L; @@ -21,3 +25,49 @@ export const engineTypeOptions: Item[] = [ value: 'postgresql', }, ]; + +export const MetricOperatorOptions: Item[] = [ + { + label: '>', + value: 'gt', + }, + { + label: '<', + value: 'lt', + }, + { + label: '>=', + value: 'gte', + }, + { + label: '<=', + value: 'lte', + }, + { + label: '==', + value: 'eq', + }, +]; + +export const MetricAggregationOptions: Item[] = [ + { + label: 'Average', + value: 'avg', + }, + { + label: 'Minimum', + value: 'min', + }, + { + label: 'Maximum', + value: 'max', + }, + { + label: 'Count', + value: 'count', + }, + { + label: 'Sum', + value: 'sum', + }, +]; diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index c639deb9d7d..560a1ecb978 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -12,14 +12,14 @@ const metricCriteria = object({ operator: string().required('Criteria Operator is required.'), threshold: number() .required('Threshold value is required.') - .min(0, 'Threshold value cannot be negative.'), + .min(0, 'Threshold value cannot be negative.') + .typeError('Threshold value should be a number.'), dimension_filters: array().of(dimensionFilters).notRequired(), }); -const triggerCondition = object({ - criteria_condition: string().required('Criteria condition is required.'), - polling_interval_seconds: string().required('Polling Interval is required.'), - evaluation_period_seconds: string().required( +const trigger_condition = object({ + polling_interval_seconds: number().required('Polling Interval is required.'), + evaluation_period_seconds: number().required( 'Evaluation Period is required.' ), trigger_occurrences: number() @@ -30,10 +30,15 @@ const triggerCondition = object({ export const createAlertDefinitionSchema = object({ label: string().required('Name is required.'), description: string().optional(), - severity: string().required('Severity is required.'), - entity_ids: array().of(string()).min(1, 'At least one resource is needed.'), - criteria: array() - .of(metricCriteria) - .min(1, 'At least one metric criteria is needed.'), - triggerCondition, + severity: number().oneOf([0, 1, 2, 3]).required('Severity is required.'), + entity_ids: array() + .of(string().required()) + .min(1, 'At least one resource is needed.'), + rule_criteria: object({ + rules: array() + .of(metricCriteria) + .min(1, 'At least one metric criteria is needed.'), + }), + trigger_condition, + channel_ids: array(number()), }); From 92fe9990eac190bce7799b260da61bde1f5a3e52 Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Mon, 9 Dec 2024 20:25:35 +0530 Subject: [PATCH 02/11] upcoming: [DI-20929] - Added applied filters view in CloudPulse (#11354) * upcoming: [DI-20929] - Added labels parameter to global filter change handlers * upcoming: [DI-20929] - Added cloudpulse applied filter * upcoming: [DI-20929] - Added test cases * upcoming: [DI-20929] - updated failing test cases * upcoming: [DI-22221] - Fixed console error * upcoming: [DI-20929] - Added changeset * upcoming: [DI-20929] - Updated changeset * upcoming: [DI-20929] - Updated import libraries * upcomign: [DI-20929] - Updated types * upcomign: [DI-20929] - Updated types * upcoming: [DI-20929] - Updated styles for dark theme * upcoming: [DI-20929] - Updated styling * upcoming: [DI-20929 ] - Eslint issue fixed * upcoming: [DI-20929] - updated function order * upcoming: [DI-20929] - Improve code readability --- ...r-11354-upcoming-features-1733237771685.md | 5 ++ .../Dashboard/CloudPulseDashboardLanding.tsx | 70 ++++++++++++++----- .../CloudPulseDashboardWithFilters.tsx | 46 +++++++++--- .../Overview/GlobalFilters.test.tsx | 2 + .../CloudPulse/Overview/GlobalFilters.tsx | 14 +++- .../CloudPulse/Utils/FilterBuilder.ts | 7 +- .../shared/CloudPulseAppliedFilter.test.tsx | 39 +++++++++++ .../shared/CloudPulseAppliedFilter.tsx | 63 +++++++++++++++++ .../CloudPulseAppliedFilterRenderer.tsx | 38 ++++++++++ .../shared/CloudPulseCustomSelect.tsx | 2 + .../shared/CloudPulseCustomSelectUtils.ts | 18 ++++- .../CloudPulseDashboardFilterBuilder.test.tsx | 2 + .../CloudPulseDashboardFilterBuilder.tsx | 20 +++++- .../shared/CloudPulseRegionSelect.tsx | 18 +++-- 14 files changed, 306 insertions(+), 38 deletions(-) create mode 100644 packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx diff --git a/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md b/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md new file mode 100644 index 00000000000..2d5d7973305 --- /dev/null +++ b/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add `CloudPulseAppliedFilter` and `CloudPulseAppliedFilterRenderer` components, update filter change handler function to add another parameter `label` ([#11354](https://github.com/linode/manager/pull/11354)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 828a05301c7..238f81c6189 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,13 +1,19 @@ -import { Grid, Paper } from '@mui/material'; +import { Box, Paper } from '@linode/ui'; +import { Grid } from '@mui/material'; import * as React from 'react'; import { GlobalFilters } from '../Overview/GlobalFilters'; +import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { CloudPulseDashboardRenderer } from './CloudPulseDashboardRenderer'; import type { Dashboard, TimeDuration } from '@linode/api-v4'; export type FilterValueType = number | number[] | string | string[] | undefined; +export interface FilterData { + id: { [filterKey: string]: FilterValueType }; + label: { [filterKey: string]: string[] }; +} export interface DashboardProp { dashboard?: Dashboard; filterValue: { @@ -17,26 +23,47 @@ export interface DashboardProp { } export const CloudPulseDashboardLanding = () => { - const [filterValue, setFilterValue] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [filterData, setFilterData] = React.useState({ + id: {}, + label: {}, + }); + const [timeDuration, setTimeDuration] = React.useState(); const [dashboard, setDashboard] = React.useState(); + const [showAppliedFilters, setShowAppliedFilters] = React.useState( + false + ); + + const toggleAppliedFilter = (isVisible: boolean) => { + setShowAppliedFilters(isVisible); + }; + const onFilterChange = React.useCallback( - (filterKey: string, filterValue: FilterValueType) => { - setFilterValue((prev: { [key: string]: FilterValueType }) => ({ - ...prev, - [filterKey]: filterValue, - })); + (filterKey: string, filterValue: FilterValueType, labels: string[]) => { + setFilterData((prev: FilterData) => { + return { + id: { + ...prev.id, + [filterKey]: filterValue, + }, + label: { + ...prev.label, + [filterKey]: labels, + }, + }; + }); }, [] ); const onDashboardChange = React.useCallback((dashboardObj: Dashboard) => { setDashboard(dashboardObj); - setFilterValue({}); // clear the filter values on dashboard change + setFilterData({ + id: {}, + label: {}, + }); // clear the filter values on dashboard change }, []); const onTimeDurationChange = React.useCallback( (timeDurationObj: TimeDuration) => { @@ -47,17 +74,26 @@ export const CloudPulseDashboardLanding = () => { return ( - - + + + + {dashboard?.service_type && showAppliedFilters && ( + + )} + diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 00f73834381..7c42b26fe25 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; @@ -16,7 +17,7 @@ import { } from '../Utils/ReusableDashboardFilterUtils'; import { CloudPulseDashboard } from './CloudPulseDashboard'; -import type { FilterValueType } from './CloudPulseDashboardLanding'; +import type { FilterData, FilterValueType } from './CloudPulseDashboardLanding'; import type { TimeDuration } from '@linode/api-v4'; export interface CloudPulseDashboardWithFiltersProp { @@ -37,18 +38,38 @@ export const CloudPulseDashboardWithFilters = React.memo( dashboardId ); - const [filterValue, setFilterValue] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [filterData, setFilterData] = React.useState({ + id: {}, + label: {}, + }); const [timeDuration, setTimeDuration] = React.useState({ unit: 'min', value: 30, }); + const [showAppliedFilters, setShowAppliedFilters] = React.useState( + false + ); + + const toggleAppliedFilter = (isVisible: boolean) => { + setShowAppliedFilters(isVisible); + }; + const onFilterChange = React.useCallback( - (filterKey: string, value: FilterValueType) => { - setFilterValue((prev) => ({ ...prev, [filterKey]: value })); + (filterKey: string, value: FilterValueType, labels: string[]) => { + setFilterData((prev) => { + return { + id: { + ...prev.id, + [filterKey]: value, + }, + label: { + ...prev.label, + [filterKey]: labels, + }, + }; + }); }, [] ); @@ -91,7 +112,7 @@ export const CloudPulseDashboardWithFilters = React.memo( const isFilterBuilderNeeded = checkIfFilterBuilderNeeded(dashboard); const isMandatoryFiltersSelected = checkMandatoryFiltersSelected({ dashboardObj: dashboard, - filterValue, + filterValue: filterData.id, resource, timeDuration, }); @@ -132,15 +153,24 @@ export const CloudPulseDashboardWithFilters = React.memo( )} + + {showAppliedFilters && ( + + )} + {isMandatoryFiltersSelected ? ( { return renderWithTheme( @@ -14,6 +15,7 @@ const setup = () => { handleAnyFilterChange={mockHandleAnyFilterChange} handleDashboardChange={mockHandleDashboardChange} handleTimeDurationChange={mockHandleTimeDurationChange} + handleToggleAppliedFilter={mockHandleToggleAppliedFilter} /> ); }; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index b3ab3e678da..e371c50351b 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -17,9 +17,14 @@ import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { AclpConfig, Dashboard, TimeDuration } from '@linode/api-v4'; export interface GlobalFilterProperties { - handleAnyFilterChange(filterKey: string, filterValue: FilterValueType): void; + handleAnyFilterChange( + filterKey: string, + filterValue: FilterValueType, + labels: string[] + ): void; handleDashboardChange(dashboard: Dashboard | undefined): void; handleTimeDurationChange(timeDuration: TimeDuration): void; + handleToggleAppliedFilter(isVisible: boolean): void; } export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { @@ -27,6 +32,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleAnyFilterChange, handleDashboardChange, handleTimeDurationChange, + handleToggleAppliedFilter, } = props; const { @@ -68,19 +74,20 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { ( filterKey: string, value: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: AclpConfig = {} ) => { if (savePref) { updatePreferences(updatedPreferenceData); } - handleAnyFilterChange(filterKey, value); + handleAnyFilterChange(filterKey, value, labels); }, [] ); const handleGlobalRefresh = React.useCallback(() => { - handleAnyFilterChange(REFRESH, Date.now()); + handleAnyFilterChange(REFRESH, Date.now(), []); }, []); const theme = useTheme(); @@ -143,6 +150,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index e058b2e3d03..93a72d435f8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -54,7 +54,11 @@ interface CloudPulseMandatoryFilterCheckProps { */ export const getRegionProperties = ( props: CloudPulseFilterProperties, - handleRegionChange: (region: string | undefined, savePref?: boolean) => void + handleRegionChange: ( + region: string | undefined, + labels: [], + savePref?: boolean + ) => void ): CloudPulseRegionSelectProps => { const { name: label, placeholder } = props.config.configuration; const { dashboard, isServiceAnalyticsIntegration, preferences } = props; @@ -119,6 +123,7 @@ export const getCustomSelectProperties = ( handleCustomSelectChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: {} ) => void diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx new file mode 100644 index 00000000000..6a8dbb2a274 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseAppliedFilter } from './CloudPulseAppliedFilter'; +import { CloudPulseAppliedFilterRenderer } from './CloudPulseAppliedFilterRenderer'; + +const data = { + region: ['us-east'], + resource: ['res1', 'res2'], +}; + +const testId = 'applied-filter'; + +describe('CloudPulse Applied Filter', () => { + it('should render applied filter component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(testId)).toBeInTheDocument(); + }); + + it('should render the applied filter key & values', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(testId)).toHaveTextContent('region'); + expect(getByTestId(testId)).toHaveTextContent('res1'); + expect(getByTestId(testId)).not.toHaveTextContent('resources'); + }); + + it('should not render the applied filter component', () => { + const { queryByTestId } = renderWithTheme( + + ); + + expect(queryByTestId(testId)).toBe(null); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx new file mode 100644 index 00000000000..ec012793023 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx @@ -0,0 +1,63 @@ +import { Box, Chip, Typography } from '@linode/ui'; +import React from 'react'; + +export interface CloudPulseAppliedFilterProps { + filters: { + [label: string]: string[]; + }; +} +export const CloudPulseAppliedFilter = ( + props: CloudPulseAppliedFilterProps +) => { + const { filters } = props; + + return ( + + {Object.entries(filters).map((data, index) => { + const label = data[0]; + const filterValue = data[1]; + return ( + + {`${label}:`} + {filterValue.map((value, index) => { + return ( + ({ + backgroundColor: theme.tokens.color.Ultramarine[10], + color: theme.tokens.color.Neutrals.Black, + fontSize: '14px', + mr: index === filterValue.length - 1 ? 4 : 1, + px: 1, + py: 0.5, + width: { sm: 'fit-content', xs: '98%' }, + })} + key={`${label} ${value}`} + label={value} + /> + ); + })} + + ); + })} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx new file mode 100644 index 00000000000..085b1569f90 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import { CloudPulseAppliedFilter } from './CloudPulseAppliedFilter'; + +import type { CloudPulseAppliedFilterProps } from './CloudPulseAppliedFilter'; + +interface AppliedFilterRendererProps extends CloudPulseAppliedFilterProps { + serviceType: string; +} + +export const CloudPulseAppliedFilterRenderer = ( + props: AppliedFilterRendererProps +) => { + const { filters, serviceType } = props; + + const filterConfig = FILTER_CONFIG.get(serviceType); + + if (!filterConfig) { + return <>; + } + const configuredFilters = filterConfig.filters; + + const appliedFilter = configuredFilters + .filter((filter) => { + const filterKey = filter.configuration.filterKey; + return Boolean(filters[filterKey]?.length); + }) + .reduce( + (prevValue, filter) => ({ + ...prevValue, + [filter.configuration.name]: filters[filter.configuration.filterKey], + }), + {} + ); + + return ; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 98338efb533..a68af932c17 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -68,10 +68,12 @@ export interface CloudPulseCustomSelectProps { * The callback function , that will be called on a filter change * @param filterKey - The filterKey of the component * @param value - The selected filter value + * @param labels - Labels of the selected filter value */ handleSelectionChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: AclpConfig ) => void; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts index ce6dabc4a02..164e22d5471 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts @@ -14,6 +14,7 @@ interface CloudPulseCustomSelectProps { handleSelectionChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: AclpConfig ) => void; @@ -139,7 +140,8 @@ export const getInitialDefaultSelections = ( const initialSelection = isMultiSelect ? [options[0]] : options[0]; handleSelectionChange( filterKey, - isMultiSelect ? [options[0].id] : options[0].id + isMultiSelect ? [options[0].id] : options[0].id, + [options[0].label] ); return initialSelection; } @@ -155,7 +157,12 @@ export const getInitialDefaultSelections = ( ? isMultiSelect ? selectedValues.map(({ id }) => id) : selectedValues[0].id - : undefined // if this is multiselect, return list of ids, otherwise return single id + : undefined, // if this is multiselect, return list of ids, otherwise return single id + selectedValues && selectedValues.length > 0 + ? isMultiSelect + ? selectedValues.map(({ label }) => label) + : [selectedValues[0].label] + : [] ); return selectedValues?.length ? isMultiSelect @@ -195,6 +202,12 @@ export const handleCustomSelectionChange = ( : String(value.id) : undefined; + const labels = value + ? Array.isArray(value) + ? value.map(({ label }) => label) + : [value.label] + : []; + let updatedPreferenceData: AclpConfig = {}; // update the preferences @@ -216,6 +229,7 @@ export const handleCustomSelectionChange = ( handleSelectionChange( filterKey, result, + labels, savePreferences, updatedPreferenceData ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index ec2c10bacc5..71a684c9a1d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -13,6 +13,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { service_type: 'linode', })} emitFilterChange={vi.fn()} + handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} /> ); @@ -28,6 +29,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { service_type: 'dbaas', })} emitFilterChange={vi.fn()} + handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index d836b73ec63..ae55e6b2afa 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -41,10 +41,13 @@ export interface CloudPulseDashboardFilterBuilderProps { emitFilterChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatePreferenceData?: {} ) => void; + handleToggleAppliedFilter: (isVisible: boolean) => void; + /** * this will handle the restrictions, if the parent of the component is going to be integrated in service analytics page */ @@ -61,6 +64,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const { dashboard, emitFilterChange, + handleToggleAppliedFilter, isServiceAnalyticsIntegration, preferences, } = props; @@ -105,12 +109,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ( filterKey: string, filterValue: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: AclpConfig = {} ) => { emitFilterChange( filterKey, filterValue, + labels, savePref, updatedPreferenceData ); @@ -124,6 +130,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( emitFilterChangeByFilterKey( RESOURCE_ID, resourceId.map((resource) => resource.id), + resourceId.map((resource) => resource.label), savePref, { [RESOURCES]: resourceId.map((resource: { id: string }) => @@ -136,7 +143,11 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ); const handleRegionChange = React.useCallback( - (region: string | undefined, savePref: boolean = false) => { + ( + region: string | undefined, + labels: string[], + savePref: boolean = false + ) => { const updatedPreferenceData = { [REGION]: region, [RESOURCES]: undefined, @@ -144,6 +155,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( emitFilterChangeByFilterKey( REGION, region, + labels, savePref, updatedPreferenceData ); @@ -155,12 +167,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ( filterKey: string, value: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: {} = {} ) => { emitFilterChangeByFilterKey( filterKey, value, + labels, savePref, updatedPreferenceData ); @@ -215,6 +229,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ); const toggleShowFilter = () => { + handleToggleAppliedFilter(showFilter); setShowFilter((showFilterPrev) => !showFilterPrev); }; @@ -228,7 +243,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( CustomIcon={InfoIcon} CustomIconStyles={{ height: '40px', width: '40px' }} errorText={'Please configure filters to continue'} - > + /> ); } @@ -313,6 +328,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( item maxHeight={theme.spacing(23)} overflow={'auto'} + pr={{ sm: 0, xs: 2 }} rowGap={theme.spacing(2)} xs={12} > diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index bb8a1c25f03..3d38f51c287 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -7,7 +7,11 @@ import type { Dashboard, FilterValue } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; - handleRegionChange: (region: string | undefined, savePref?: boolean) => void; + handleRegionChange: ( + region: string | undefined, + labels: string[], + savePref?: boolean + ) => void; label: string; placeholder?: string; savePreferences?: boolean; @@ -32,10 +36,10 @@ export const CloudPulseRegionSelect = React.memo( React.useEffect(() => { if (regions && savePreferences) { const region = defaultValue - ? regions.find((regionObj) => regionObj.id === defaultValue)?.id + ? regions.find((regionObj) => regionObj.id === defaultValue) : undefined; - handleRegionChange(region); - setSelectedRegion(region); + handleRegionChange(region?.id, region ? [region.label] : []); + setSelectedRegion(region?.id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [regions]); @@ -44,7 +48,11 @@ export const CloudPulseRegionSelect = React.memo( { setSelectedRegion(region?.id); - handleRegionChange(region?.id, savePreferences); + handleRegionChange( + region?.id, + region ? [region.label] : [], + savePreferences + ); }} currentCapability={undefined} data-testid="region-select" From 85d62d346937fa78e5eb957b286296f5ca692c70 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:37:52 -0500 Subject: [PATCH 03/11] upcoming: [M3-8869] - Update types for NodeBalancer UDP support (#11321) * initial types * small fixes * support nodes * update validatoon schemas * add changesets * add comment * clean up types in manager * add changeset * clean up validation schemas * update and fix more nodebalancer schemas * make validation schema more strict * more improvements to schemas --------- Co-authored-by: Banks Nussman --- .../pr-11321-added-1733240671518.md | 5 + packages/api-v4/src/nodebalancers/types.ts | 102 ++++++++++++++++-- .../pr-11321-tech-stories-1733243149867.md | 5 + .../NodeBalancers/NodeBalancerConfigPanel.tsx | 1 + .../NodeBalancers/NodeBalancerCreate.tsx | 2 +- .../NodeBalancerConfigurations.tsx | 9 +- .../src/features/NodeBalancers/types.ts | 65 ++++------- .../src/features/NodeBalancers/utils.ts | 3 +- .../pr-11321-added-1733240871159.md | 5 + .../validation/src/nodebalancers.schema.ts | 95 +++++++++++++--- 10 files changed, 216 insertions(+), 76 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11321-added-1733240671518.md create mode 100644 packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md create mode 100644 packages/validation/.changeset/pr-11321-added-1733240871159.md diff --git a/packages/api-v4/.changeset/pr-11321-added-1733240671518.md b/packages/api-v4/.changeset/pr-11321-added-1733240671518.md new file mode 100644 index 00000000000..6650a12aebd --- /dev/null +++ b/packages/api-v4/.changeset/pr-11321-added-1733240671518.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Types for UDP NodeBalancer support ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index f8e93154b0b..68f89c7ac32 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -1,8 +1,31 @@ +type TCPAlgorithm = 'roundrobin' | 'leastconn' | 'source'; +type UDPAlgorithm = 'roundrobin' | 'leastconn' | 'ring_hash'; + +export type Algorithm = TCPAlgorithm | UDPAlgorithm; + +export type Protocol = 'http' | 'https' | 'tcp' | 'udp'; + +type TCPStickiness = 'none' | 'table' | 'http_cookie'; +type UDPStickiness = 'none' | 'session' | 'source_ip'; + +export type Stickiness = TCPStickiness | UDPStickiness; + export interface NodeBalancer { id: number; label: string; hostname: string; + /** + * Maximum number of new TCP connections that a client (identified by a specific source IP) + * is allowed to initiate every second. + */ client_conn_throttle: number; + /** + * Maximum number of new UDP sessions that a client (identified by a specific source IP) + * is allowed to initiate every second. + * + * @todo Remove optionality once UDP support is live + */ + client_udp_sess_throttle?: number; region: string; ipv4: string; ipv6: null | string; @@ -31,11 +54,15 @@ export interface BalancerTransfer { total: number; } +/** + * 'none' is reserved for nodes used in UDP configurations. They don't support different modes. + */ export type NodeBalancerConfigNodeMode = | 'accept' | 'reject' | 'backup' - | 'drain'; + | 'drain' + | 'none'; export interface NodeBalancerConfig { id: number; @@ -44,22 +71,31 @@ export interface NodeBalancerConfig { check_passive: boolean; ssl_cert: string; nodes_status: NodesStatus; - protocol: 'http' | 'https' | 'tcp'; + protocol: Protocol; ssl_commonname: string; check_interval: number; check_attempts: number; check_timeout: number; check_body: string; check_path: string; + /** + * @todo Remove optionality once UDP support is live + */ + udp_check_port?: number; + /** + * @readonly This is returned by the API but *not* editable + * @todo Remove optionality once UDP support is live + * @default 16 + */ + udp_session_timeout?: number; proxy_protocol: NodeBalancerProxyProtocol; check: 'none' | 'connection' | 'http' | 'http_body'; ssl_key: string; - stickiness: 'none' | 'table' | 'http_cookie'; - algorithm: 'roundrobin' | 'leastconn' | 'source'; + stickiness: Stickiness; + algorithm: Algorithm; ssl_fingerprint: string; cipher_suite: 'recommended' | 'legacy'; nodes: NodeBalancerConfigNode[]; - modifyStatus?: 'new'; } export type NodeBalancerProxyProtocol = 'none' | 'v1' | 'v2'; @@ -82,9 +118,36 @@ export interface NodeBalancerStats { export interface CreateNodeBalancerConfig { port?: number; - protocol?: 'http' | 'https' | 'tcp'; - algorithm?: 'roundrobin' | 'leastconn' | 'source'; - stickiness?: 'none' | 'table' | 'http_cookie'; + /** + * If `udp` is chosen: + * - `check_passive` must be `false` or unset + * - `proxy_protocol` must be `none` or unset + * - The various SSL related fields like `ssl_cert`, `ssl_key`, `cipher_suite_recommended` should not be set + */ + protocol?: Protocol; + /** + * @default "none" + */ + proxy_protocol?: NodeBalancerProxyProtocol; + /** + * The algorithm for this configuration. + * + * TCP and HTTP support `roundrobin`, `leastconn`, and `source` + * UDP supports `roundrobin`, `leastconn`, and `ring_hash` + * + * @default roundrobin + */ + algorithm?: Algorithm; + /** + * Session stickiness for this configuration. + * + * TCP and HTTP support `none`, `table`, and `http_cookie` + * UDP supports `none`, `session`, and `source_ip` + * + * @default `session` for UDP + * @default `none` for TCP and HTTP + */ + stickiness?: Stickiness; check?: 'none' | 'connection' | 'http' | 'http_body'; check_interval?: number; check_timeout?: number; @@ -92,6 +155,11 @@ export interface CreateNodeBalancerConfig { check_path?: string; check_body?: string; check_passive?: boolean; + /** + * Must be between 1 and 65535 + * @default 80 + */ + udp_check_port?: number; cipher_suite?: 'recommended' | 'legacy'; ssl_cert?: string; ssl_key?: string; @@ -102,6 +170,9 @@ export type UpdateNodeBalancerConfig = CreateNodeBalancerConfig; export interface CreateNodeBalancerConfigNode { address: string; label: string; + /** + * Should not be specified when creating a node used on a UDP configuration + */ mode?: NodeBalancerConfigNodeMode; weight?: number; } @@ -126,8 +197,21 @@ export interface NodeBalancerConfigNodeWithPort extends NodeBalancerConfigNode { export interface CreateNodeBalancerPayload { region?: string; label?: string; + /** + * The connections per second throttle for TCP and HTTP connections + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ client_conn_throttle?: number; - configs: any; + /** + * The connections per second throttle for UDP sessions + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ + client_udp_sess_throttle?: number; + configs: CreateNodeBalancerConfig[]; firewall_id?: number; tags?: string[]; } diff --git a/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md b/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md new file mode 100644 index 00000000000..9350b678a28 --- /dev/null +++ b/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up NodeBalancer related types ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 8a123ad6cd4..7cf7e7b43a9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -166,6 +166,7 @@ export const NodeBalancerConfigPanel = ( const algorithmHelperText = { leastconn: LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT, + ring_hash: '', // @todo Add copy as part of UDP NodeBalancer project roundrobin: ROUND_ROBIN_ALGORITHM_HELPER_TEXT, source: SOURCE_ALGORITHM_HELPER_TEXT, }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index a0ef7926607..57c4064f241 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -57,8 +57,8 @@ import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics/customEvent import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; +import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType, renderMonthlyPriceToCorrectDecimalPlace, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index cdd1462d932..0ba3cf6b091 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -7,7 +7,7 @@ import { getNodeBalancerConfigs, updateNodeBalancerConfig, updateNodeBalancerConfigNode, -} from '@linode/api-v4/lib/nodebalancers'; +} from '@linode/api-v4'; import { Accordion, Box, Button, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { @@ -49,12 +49,13 @@ import type { NodeBalancerConfigFieldsWithStatus, NodeBalancerConfigNodeFields, } from '../types'; -import type { Grants } from '@linode/api-v4'; import type { + APIError, + Grants, NodeBalancerConfig, NodeBalancerConfigNode, -} from '@linode/api-v4/lib/nodebalancers'; -import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; + ResourcePage, +} from '@linode/api-v4'; import type { Lens } from 'ramda'; import type { RouteComponentProps } from 'react-router-dom'; import type { PromiseLoaderResponse } from 'src/components/PromiseLoader/PromiseLoader'; diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 1a4d465615e..df194d87cbf 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -1,65 +1,44 @@ import type { - NodeBalancerConfigNodeMode, + APIError, + Algorithm, + NodeBalancerConfigNode, NodeBalancerProxyProtocol, -} from '@linode/api-v4/lib/nodebalancers/types'; -import type { APIError } from '@linode/api-v4/lib/types'; + Protocol, + Stickiness, + UpdateNodeBalancerConfig, +} from '@linode/api-v4'; export interface NodeBalancerConfigFieldsWithStatus extends NodeBalancerConfigFields { + /** + * Exists for the sake of local operations + */ modifyStatus?: 'new'; } -export interface ExtendedNodeBalancerConfigNode { - address: string; - config_id?: number; - errors?: APIError[]; - id: number; - label: string; - mode?: NodeBalancerConfigNodeMode; - modifyStatus?: 'delete' | 'new' | 'update'; - nodebalancer_id: number; - port?: number; - status: 'DOWN' | 'UP' | 'unknown'; - weight?: number; -} -export interface NodeBalancerConfigFields { - algorithm?: 'leastconn' | 'roundrobin' | 'source'; - check?: 'connection' | 'http' | 'http_body' | 'none'; - check_attempts?: number /** 1..30 */; - check_body?: string; - check_interval?: number; - check_passive?: boolean; - check_path?: string; - check_timeout?: number /** 1..30 */; - cipher_suite?: 'legacy' | 'recommended'; +export interface NodeBalancerConfigFields extends UpdateNodeBalancerConfig { id?: number; nodes: NodeBalancerConfigNodeFields[]; - port?: number /** 1..65535 */; - protocol?: 'http' | 'https' | 'tcp'; - proxy_protocol?: NodeBalancerProxyProtocol; - ssl_cert?: string; - ssl_key?: string; - stickiness?: 'http_cookie' | 'none' | 'table'; } -export interface NodeBalancerConfigNodeFields { +export interface NodeBalancerConfigNodeFields + extends Partial { address: string; - config_id?: number; errors?: APIError[]; - id?: number; label: string; - mode?: NodeBalancerConfigNodeMode; - /* for the sake of local operations */ + /** + * Exists for the sake of local operations + */ modifyStatus?: 'delete' | 'new' | 'update'; - nodebalancer_id?: number; + /** + * @note `port` is an "extended" field. The API includes it in the `address` + */ port?: number; - status?: 'DOWN' | 'UP' | 'unknown'; - weight?: number; } export interface NodeBalancerConfigPanelProps { addNode: (nodeIdx?: number) => void; - algorithm: 'leastconn' | 'roundrobin' | 'source'; + algorithm: Algorithm; checkBody: string; checkPassive: boolean; @@ -113,10 +92,10 @@ export interface NodeBalancerConfigPanelProps { onSslCertificateChange: (v: string) => void; port: number; privateKey: string; - protocol: 'http' | 'https' | 'tcp'; + protocol: Protocol; proxyProtocol: NodeBalancerProxyProtocol; removeNode: (nodeIdx: number) => void; - sessionStickiness: 'http_cookie' | 'none' | 'table'; + sessionStickiness: Stickiness; sslCertificate: string; submitting?: boolean; } diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index b7cec227bcc..528dd6e7831 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -3,7 +3,6 @@ import { filter, isNil } from 'ramda'; import { getErrorMap } from 'src/utilities/errorUtils'; import type { - ExtendedNodeBalancerConfigNode, NodeBalancerConfigFields, NodeBalancerConfigFieldsWithStatus, NodeBalancerConfigNodeFields, @@ -51,7 +50,7 @@ export const nodeForRequest = (node: NodeBalancerConfigNodeFields) => ({ weight: +node.weight!, }); -export const formatAddress = (node: ExtendedNodeBalancerConfigNode) => ({ +export const formatAddress = (node: NodeBalancerConfigNodeFields) => ({ ...node, address: `${node.address}:${node.port}`, }); diff --git a/packages/validation/.changeset/pr-11321-added-1733240871159.md b/packages/validation/.changeset/pr-11321-added-1733240871159.md new file mode 100644 index 00000000000..9e03fd4fe1b --- /dev/null +++ b/packages/validation/.changeset/pr-11321-added-1733240871159.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Validation for UDP NodeBalancer support ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 6cefa2592c4..6c93bfee5f9 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -20,6 +20,11 @@ export const CHECK_INTERVAL = { MAX: 3600, }; +const CONNECTION_THROTTLE = { + MIN: 0, + MAX: 20, +}; + export const nodeBalancerConfigNodeSchema = object({ label: string() .matches( @@ -46,11 +51,15 @@ export const nodeBalancerConfigNodeSchema = object({ .min(1, `Weight must be between 1 and 255.`) .max(255, `Weight must be between 1 and 255.`), - mode: mixed().oneOf(['accept', 'reject', 'backup', 'drain']), + mode: string().oneOf(['accept', 'reject', 'backup', 'drain']), }); export const createNodeBalancerConfigSchema = object({ - algorithm: mixed().oneOf(['roundrobin', 'leastconn', 'source']), + algorithm: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'ring_hash']), + otherwise: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'source']), + }), check_attempts: number() .min( CHECK_ATTEMPTS.MIN, @@ -76,7 +85,10 @@ export const createNodeBalancerConfigSchema = object({ ) .typeError('Interval must be a number.') .integer(), - check_passive: boolean(), + check_passive: boolean().when('protocol', { + is: 'udp', + then: (schema) => schema.isFalse(), // You can't enable check_passtive with UDP + }), check_path: string() .matches(/\/.*/) .when('check', { @@ -87,7 +99,11 @@ export const createNodeBalancerConfigSchema = object({ is: 'http_body', then: (schema) => schema.required('An HTTP path is required.'), }), - proxy_protocol: string().oneOf(['none', 'v1', 'v2']), + proxy_protocol: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none']), // UDP does not support proxy_protocol + otherwise: (schema) => schema.oneOf(['none', 'v1', 'v2']), + }), check_timeout: number() .min( CHECK_TIMEOUT.MIN, @@ -106,7 +122,7 @@ export const createNodeBalancerConfigSchema = object({ .required('Port is required') .min(1, PORT_WARNING) .max(65535, PORT_WARNING), - protocol: mixed().oneOf(['http', 'https', 'tcp']), + protocol: string().oneOf(['http', 'https', 'tcp', 'udp']), ssl_key: string().when('protocol', { is: 'https', then: (schema) => schema.required('SSL key is required when using HTTPS.'), @@ -116,7 +132,12 @@ export const createNodeBalancerConfigSchema = object({ then: (schema) => schema.required('SSL certificate is required when using HTTPS.'), }), - stickiness: mixed().oneOf(['none', 'table', 'http_cookie']), + stickiness: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none', 'source_ip', 'session']), + otherwise: (schema) => schema.oneOf(['none', 'table', 'http_cookie']), + }), + udp_check_port: number().min(1).max(65535), nodes: array() .of(nodeBalancerConfigNodeSchema) .required() @@ -124,7 +145,11 @@ export const createNodeBalancerConfigSchema = object({ }); export const UpdateNodeBalancerConfigSchema = object({ - algorithm: mixed().oneOf(['roundrobin', 'leastconn', 'source']), + algorithm: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'ring_hash']), + otherwise: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'source']), + }), check_attempts: number() .min( CHECK_ATTEMPTS.MIN, @@ -150,7 +175,10 @@ export const UpdateNodeBalancerConfigSchema = object({ ) .typeError('Interval must be a number.') .integer(), - check_passive: boolean(), + check_passive: boolean().when('protocol', { + is: 'udp', + then: (schema) => schema.isFalse(), // You can't enable check_passtive with UDP + }), check_path: string() .matches(/\/.*/) .when('check', { @@ -161,7 +189,11 @@ export const UpdateNodeBalancerConfigSchema = object({ is: 'http_body', then: (schema) => schema.required('An HTTP path is required.'), }), - proxy_protocol: string().oneOf(['none', 'v1', 'v2']), + proxy_protocol: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none']), // UDP does not support proxy_protocol + otherwise: (schema) => schema.oneOf(['none', 'v1', 'v2']), + }), check_timeout: number() .min( CHECK_TIMEOUT.MIN, @@ -180,7 +212,7 @@ export const UpdateNodeBalancerConfigSchema = object({ .integer() .min(1, PORT_WARNING) .max(65535, PORT_WARNING), - protocol: mixed().oneOf(['http', 'https', 'tcp']), + protocol: string().oneOf(['http', 'https', 'tcp', 'udp']), ssl_key: string().when('protocol', { is: 'https', then: (schema) => schema.required(), @@ -189,10 +221,36 @@ export const UpdateNodeBalancerConfigSchema = object({ is: 'https', then: (schema) => schema.required(), }), - stickiness: mixed().oneOf(['none', 'table', 'http_cookie']), - nodes: array().of(nodeBalancerConfigNodeSchema), + udp_check_port: number().min(1).max(65535), + stickiness: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none', 'source_ip', 'session']), + otherwise: (schema) => schema.oneOf(['none', 'table', 'http_cookie']), + }), }); +const client_conn_throttle = number() + .min( + CONNECTION_THROTTLE.MIN, + `Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .max( + CONNECTION_THROTTLE.MAX, + `Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .typeError('Client Connection Throttle must be a number.'); + +const client_udp_sess_throttle = number() + .min( + CONNECTION_THROTTLE.MIN, + `UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .max( + CONNECTION_THROTTLE.MAX, + `UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .typeError('UDP Session Throttle must be a number.'); + export const NodeBalancerSchema = object({ label: string() .required('Label is required.') @@ -203,7 +261,11 @@ export const NodeBalancerSchema = object({ "Label can't contain special characters or spaces." ), - client_conn_throttle: number().typeError('Must be a number.'), + client_conn_throttle, + + client_udp_sess_throttle, + + tags: array(string()), region: string().required('Region is required.'), @@ -249,8 +311,7 @@ export const UpdateNodeBalancerSchema = object({ /^[a-zA-Z0-9-_]+$/, "Label can't contain special characters or spaces." ), - - client_conn_throttle: number().typeError('Must be a number.'), - - region: string(), + client_conn_throttle, + client_udp_sess_throttle, + tags: array(string()), }); From 906e061da8f38ea0d44d6f28627f084483d6946d Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 9 Dec 2024 23:40:42 -0500 Subject: [PATCH 04/11] tests: [M3-8656] - Mock LKE creation flow + APL coverage (#11347) * cypress test and small text changes * feat: dedicated LKE create cluster Cypress test for APL * fix: mocking apl feature flag * mock APL account beta * save progress * save prpgress on mocking all LKE creation * post rebase cleanup * Wrap up lke-create * cleanup * Added changeset: Mock LKE creation flow + APL coverage * feedback @bnussman-akamai @hkhalil-akamai * missing one mock * Added changeset: Nullable AccountBeta ended & description properties * damn you, APL * feedback @jdamore-linode * fix maybe flake * fix other flake --------- Co-authored-by: dennisvankekem --- .../pr-11347-fixed-1733327446988.md | 5 + packages/api-v4/src/account/types.ts | 4 +- .../pr-11347-tests-1733170030162.md | 5 + .../billing/smoke-billing-activity.spec.ts | 3 +- .../e2e/core/kubernetes/lke-create.spec.ts | 497 +++++++++++++----- packages/manager/cypress/support/api/lke.ts | 28 +- .../support/constants/dc-specific-pricing.ts | 7 +- .../manager/cypress/support/constants/lke.ts | 11 - .../cypress/support/intercepts/betas.ts | 21 +- .../manager/cypress/support/intercepts/lke.ts | 14 + packages/manager/src/factories/betas.ts | 17 +- packages/manager/src/factories/databases.ts | 11 +- .../CreateCluster/ApplicationPlatform.tsx | 13 +- .../CreateCluster/HAControlPlane.tsx | 2 +- 14 files changed, 451 insertions(+), 187 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md create mode 100644 packages/manager/.changeset/pr-11347-tests-1733170030162.md diff --git a/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md b/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md new file mode 100644 index 00000000000..d21cf788e93 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +Nullable AccountBeta ended & description properties ([#11347](https://github.com/linode/manager/pull/11347)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index d23ded3eb0d..08b575c7606 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -607,8 +607,8 @@ export interface AccountBeta { label: string; started: string; id: string; - ended?: string; - description?: string; + ended: string | null; + description: string | null; /** * The datetime the account enrolled into the beta * @example 2024-10-23T14:22:29 diff --git a/packages/manager/.changeset/pr-11347-tests-1733170030162.md b/packages/manager/.changeset/pr-11347-tests-1733170030162.md new file mode 100644 index 00000000000..261f1ae08b3 --- /dev/null +++ b/packages/manager/.changeset/pr-11347-tests-1733170030162.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock LKE creation flow + APL coverage ([#11347](https://github.com/linode/manager/pull/11347)) diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 4780133b78f..c23a44f3d55 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -353,7 +353,6 @@ describe('Billing Activity Feed', () => { // reflect each timezone. timeZonesList.forEach((timezone) => { const timezoneId = timezone.key; - const humanReadable = timezone.human; mockUpdateProfile({ ...mockProfile, @@ -367,7 +366,7 @@ describe('Billing Activity Feed', () => { cy.findByText('Timezone') .should('be.visible') .click() - .type(`${humanReadable}{enter}`); + .type('Pacific Time - Los Angeles{enter}'); ui.button .findByTitle('Update Timezone') diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 6c93301d30b..cd9c4c9b00d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -3,32 +3,35 @@ */ import { accountFactory, + dedicatedTypeFactory, kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, linodeTypeFactory, regionFactory, + nodePoolFactory, + kubeLinodeFactory, } from 'src/factories'; import { mockCreateCluster, mockGetCluster, mockCreateClusterError, mockGetControlPlaneACL, + mockGetClusterPools, + mockGetDashboardUrl, + mockGetApiEndpoints, + mockGetClusters, + mockGetLKEClusterTypes, } from 'support/intercepts/lke'; +import { mockGetAccountBeta } from 'support/intercepts/betas'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetRegions, mockGetRegionAvailability, } from 'support/intercepts/regions'; -import { KubernetesCluster } from '@linode/api-v4'; -import { LkePlanDescription } from 'support/api/lke'; -import { lkeClusterPlans } from 'support/constants/lke'; -import { chooseRegion, getRegionById } from 'support/util/regions'; -import { interceptCreateCluster } from 'support/intercepts/lke'; +import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomItem } from 'support/util/random'; -import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; import { dcPricingLkeCheckoutSummaryPlaceholder, dcPricingLkeHAPlaceholder, @@ -40,77 +43,141 @@ import { } from 'support/constants/dc-specific-pricing'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; +import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; -/** - * Gets the label for an LKE plan as shown in creation plan table. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE plan name. - * - * @returns LKE plan name for plan. - */ -const getLkePlanName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB`; -}; +import type { ExtendedType } from 'src/utilities/extendType'; +import type { LkePlanDescription } from 'support/api/lke'; +import { PriceType } from '@linode/api-v4/lib/types'; -/** - * Gets the label for an LKE plan as shown in the creation checkout bar. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE checkout name. - * - * @returns LKE checkout plan name for plan. - */ -const getLkePlanCheckoutName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB Plan`; -}; +const dedicatedNodeCount = 4; +const nanodeNodeCount = 3; -/** - * Returns each plan in an array which is similar to the given plan. - * - * Plans are considered similar if they have identical type and size. - * - * @param clusterPlan - Cluster plan with which to compare similarity. - * @param clusterPlans - Array from which to find similar cluster plans. - * - * @returns Array of similar cluster plans. - */ -const getSimilarPlans = ( - clusterPlan: LkePlanDescription, - clusterPlans: LkePlanDescription[] -) => { - return clusterPlans.filter((otherClusterPlan: any) => { - return ( - clusterPlan.type === otherClusterPlan.type && - clusterPlan.size === otherClusterPlan.size - ); - }); -}; +const clusterRegion = chooseRegion({ + capabilities: ['Kubernetes'], +}); +const dedicatedCpuPool = nodePoolFactory.build({ + count: dedicatedNodeCount, + nodes: kubeLinodeFactory.buildList(dedicatedNodeCount), + type: 'g6-dedicated-2', +}); +const nanodeMemoryPool = nodePoolFactory.build({ + count: nanodeNodeCount, + nodes: kubeLinodeFactory.buildList(nanodeNodeCount), + type: 'g6-nanode-1', +}); +const dedicatedType = dedicatedTypeFactory.build({ + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + price: { + hourly: 0.054, + monthly: 36.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-2' + )?.region_prices, + vcpus: 2, +}) as ExtendedType; +const nanodeType = linodeTypeFactory.build({ + disk: 25600, + id: 'g6-nanode-1', + label: 'Linode 2 GB', + memory: 2048, + price: { + hourly: 0.0075, + monthly: 5.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-nanode-1' + )?.region_prices, + vcpus: 1, +}) as ExtendedType; +const mockedLKEClusterPrices: PriceType[] = [ + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + }, +]; +const mockedLKEHAClusterPrices: PriceType[] = [ + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [], + transfer: 0, + }, +]; -authenticate(); describe('LKE Cluster Creation', () => { - before(() => { - cleanUp(['linodes', 'lke-clusters']); - }); - /* * - Confirms that users can create a cluster by completing the LKE create form. * - Confirms that LKE cluster is created. * - Confirms that user is redirected to new LKE cluster summary page. + * - Confirms that correct information is shown on the LKE cluster summary page * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. - * - Confirms that correct information is shown on the LKE cluster summary page */ - it('can create an LKE cluster', () => { - cy.tag('method:e2e', 'purpose:dcTesting'); - const clusterLabel = randomLabel(); - const clusterRegion = chooseRegion({ - capabilities: ['Kubernetes'], - }); - const clusterVersion = '1.27'; - const clusterPlans = new Array(2) - .fill(null) - .map(() => randomItem(lkeClusterPlans)); + const clusterLabel = randomLabel(); + const clusterVersion = '1.27'; + const clusterPlans: LkePlanDescription[] = [ + { + nodeCount: dedicatedNodeCount, + planName: 'Dedicated 4 GB', + size: 4, + tab: 'Dedicated CPU', + type: 'dedicated', + }, + { + nodeCount: nanodeNodeCount, + planName: 'Linode 2 GB', + size: 24, + tab: 'Shared CPU', + type: 'nanode', + }, + ]; + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const mockedLKEClusterTypes = [dedicatedType, nanodeType]; + const { + CPU: totalCpu, + RAM: totalMemory, + Storage: totalStorage, + } = getTotalClusterMemoryCPUAndStorage( + mockedLKEClusterPools, + mockedLKEClusterTypes + ); - interceptCreateCluster().as('createCluster'); + it('can create an LKE cluster', () => { + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEClusterPrices).as('getLKEClusterTypes'); + mockGetClusters([mockedLKECluster]).as('getClusters'); cy.visitWithLogin('/kubernetes/clusters'); @@ -123,44 +190,40 @@ describe('LKE Cluster Creation', () => { cy.url().should('endWith', '/kubernetes/create'); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') + cy.get('[data-qa-textfield-label="Cluster Label"]') .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + .click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') + ui.autocomplete + .findByLabel('Kubernetes Version') .click() .type(`${clusterVersion}{enter}`); - cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + cy.get('[data-testid="ha-radio-button-no"]').should('be.visible').click(); - let totalCpu = 0; - let totalMemory = 0; - let totalStorage = 0; let monthPrice = 0; - // Add a node pool for each randomly selected plan, and confirm that the + // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { - const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; cy.findByText(planName) .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -176,31 +239,16 @@ describe('LKE Cluster Creation', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); - // Expected information on the LKE cluster summary page. - if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 1; - totalMemory = totalMemory + nodeCount * 2; - totalStorage = totalStorage + nodeCount * 50; - monthPrice = monthPrice + nodeCount * 12; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 24; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 36; - } + monthPrice = getTotalClusterPrice({ + highAvailabilityPrice: 0, + pools: [nanodeMemoryPool, dedicatedCpuPool], + region: clusterRegion.id, + types: mockedLKEClusterTypes, + }); }); - // $60.00/month for enabling HA control plane - const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -215,31 +263,40 @@ describe('LKE Cluster Creation', () => { // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. - cy.wait('@createCluster').then(({ response }) => { - if (!response) { - throw new Error( - `Error creating LKE cluster ${clusterLabel}; API request failed` - ); - } - const cluster: KubernetesCluster = response.body; - cy.url().should('endWith', `/kubernetes/clusters/${cluster.id}/summary`); - }); + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedLKECluster.id}/summary` + ); // Confirm that each node pool is shown. clusterPlans.forEach((clusterPlan) => { // Because multiple node pools may have identical labels, we figure out // how many identical labels for each plan will exist and confirm that // the expected number is present. - const nodePoolLabel = getLkePlanName(clusterPlan); + const nodePoolLabel = clusterPlan.planName; const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; - //Confirm that the cluster created with the expected parameters. + // Confirm that the cluster created with the expected parameters. cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); - cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); - cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); - cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.findAllByText(`${Math.round(totalStorage / 1024)} GB Storage`).should( + 'be.visible' + ); + cy.findAllByText(`${Math.round(totalMemory / 1024)} GB RAM`).should( + 'be.visible' + ); + cy.findAllByText(`$${monthPrice.toFixed(2)}/month`).should('be.visible'); cy.contains('Kubernetes API Endpoint').should('be.visible'); cy.contains('linodelke.net:443').should('be.visible'); @@ -249,26 +306,169 @@ describe('LKE Cluster Creation', () => { .should('be.visible'); }); - // Navigate to the LKE landing page and confirm that new cluster is shown. ui.breadcrumb .find() .should('be.visible') .within(() => { cy.findByText(clusterLabel).should('be.visible'); + }); + }); +}); + +describe('LKE Cluster Creation with APL enabled', () => { + it('can create an LKE cluster with APL flag enabled', () => { + const clusterLabel = randomLabel(); + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const dedicated4Type = dedicatedTypeFactory.build({ + disk: 163840, + id: 'g6-dedicated-4', + label: 'Dedicated 8GB', + memory: 8192, + price: { + hourly: 0.108, + monthly: 72.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 4, + }); + const dedicated8Type = dedicatedTypeFactory.build({ + disk: 327680, + id: 'g6-dedicated-8', + label: 'Dedicated 16GB', + memory: 16384, + price: { + hourly: 0.216, + monthly: 144.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 8, + }); + const mockedLKEClusterTypes = [ + dedicatedType, + dedicated4Type, + dedicated8Type, + nanodeType, + ]; + mockAppendFeatureFlags({ + apl: { + enabled: true, + }, + }).as('getFeatureFlags'); + mockGetAccountBeta({ + id: 'apl', + label: 'Akamai App Platform Beta', + enrolled: '2024-11-04T21:39:41', + description: + 'Akamai App Platform is a platform that combines developer and operations-centric tools, automation and self-service to streamline the application lifecycle when using Kubernetes. This process will pre-register you for an upcoming beta.', + started: '2024-10-31T18:00:00', + ended: null, + }).as('getAccountBeta'); + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEHAClusterPrices).as('getLKEClusterTypes'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + + cy.visitWithLogin('/kubernetes/create'); + + cy.wait([ + '@getFeatureFlags', + '@getAccountBeta', + '@getLinodeTypes', + '@getLKEClusterTypes', + ]); + + // Enter cluster details + cy.get('[data-qa-textfield-label="Cluster Label"]') + .should('be.visible') + .click(); + cy.focused().type(`${clusterLabel}{enter}`); + + ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); + + cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); + cy.findByTestId('ha-radio-button-yes').should('be.disabled'); + cy.get( + '[aria-label="Enabled by default when Akamai App Platform is enabled."]' + ).should('be.visible'); + + // Check that Shared CPU plans are disabled + ui.tabList.findTabByTitle('Shared CPU').click(); + cy.findByText( + 'Shared CPU instances are currently not available for Akamai App Platform.' + ).should('be.visible'); + cy.get('[data-qa-plan-row="Linode 2 GB"]').should('have.attr', 'disabled'); + + // Check that Dedicated CPU plans are available if greater than 8GB + ui.tabList.findTabByTitle('Dedicated CPU').click(); + cy.get('[data-qa-plan-row="Dedicated 4 GB"]').should( + 'have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 16 GB"]').within(() => { + cy.get('[name="Quantity"]').click(); + cy.get('[name="Quantity"]').type('{selectall}3'); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText('kubernetes').should('be.visible').click(); + // Check that the checkout bar displays the correct information + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + cy.findByText(`Dedicated 16 GB Plan`).should('be.visible'); + cy.findByText('$432.00').should('be.visible'); + cy.findByText('High Availability (HA) Control Plane').should( + 'be.visible' + ); + cy.findByText('$60.00/month').should('be.visible'); + cy.findByText('$492.00').should('be.visible'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); }); - cy.url().should('endWith', '/kubernetes/clusters'); - cy.findByText(clusterLabel).should('be.visible'); + cy.wait([ + '@createCluster', + '@getCluster', + '@getClusterPools', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); }); }); describe('LKE Cluster Creation with DC-specific pricing', () => { - before(() => { - cleanUp('lke-clusters'); - }); - /* * - Confirms that DC-specific prices are present in the LKE create form. * - Confirms that pricing docs link is shown in "Region" section. @@ -279,7 +479,6 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { it('can dynamically update prices when creating an LKE cluster based on region', () => { const dcSpecificPricingRegion = getRegionById('us-east'); const clusterLabel = randomLabel(); - const clusterVersion = '1.27'; const clusterPlans = new Array(2) .fill(null) .map(() => randomItem(dcPricingLkeClusterPlans)); @@ -322,18 +521,16 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .click() .type(`${clusterLabel}{enter}`); - ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); + ui.regionSelect + .find() + .click() + .type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); - // Confirm that with region and HA selections, create button is still disabled until plan selection is made. cy.get('[data-qa-deploy-linode]') .should('contain.text', 'Create Cluster') @@ -343,10 +540,9 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${clusterPlan.planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); @@ -373,7 +569,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); }); @@ -512,7 +708,6 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -647,7 +842,6 @@ describe('LKE Cluster Creation with ACL', () => { .click(); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -785,7 +979,6 @@ describe('LKE Cluster Creation with ACL', () => { cy.contains('Must be a valid IPv6 address.').should('not.exist'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -964,3 +1157,25 @@ describe('LKE Cluster Creation with LKE-E', () => { }); }); }); + +/** + * Returns each plan in an array which is similar to the given plan. + * + * Plans are considered similar if they have identical type and size. + * + * @param clusterPlan - Cluster plan with which to compare similarity. + * @param clusterPlans - Array from which to find similar cluster plans. + * + * @returns Array of similar cluster plans. + */ +const getSimilarPlans = ( + clusterPlan: LkePlanDescription, + clusterPlans: LkePlanDescription[] +) => { + return clusterPlans.filter((otherClusterPlan) => { + return ( + clusterPlan.type === otherClusterPlan.type && + clusterPlan.size === otherClusterPlan.size + ); + }); +}; diff --git a/packages/manager/cypress/support/api/lke.ts b/packages/manager/cypress/support/api/lke.ts index 14ed8339749..fa99f1eed71 100644 --- a/packages/manager/cypress/support/api/lke.ts +++ b/packages/manager/cypress/support/api/lke.ts @@ -1,29 +1,39 @@ import { - KubeNodePoolResponse, - KubernetesCluster, - PoolNodeResponse, deleteKubernetesCluster, getKubernetesClusters, getNodePools, } from '@linode/api-v4'; +import { DateTime } from 'luxon'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; -import { DateTime } from 'luxon'; import { isTestLabel } from './common'; +import type { + KubeNodePoolResponse, + KubernetesCluster, + PoolNodeResponse, +} from '@linode/api-v4'; +import type { LinodeTypeClass } from '@linode/api-v4/lib/linodes/types'; + /** * Describes an LKE plan as shown in Cloud Manager. */ export interface LkePlanDescription { - // / Plan size, GB. + /** Number of nodes in the plan. */ + nodeCount: number; + /** Name of the plan. */ + planName: string; + /** Plan size, GB. */ size: number; - - // / Label for tab containing the plan in creation screen. + /** Label for tab containing the plan in creation screen. */ tab: string; + /** Type of plan. */ + type: LinodeTypeClass; +} - // / Type of plan. - type: string; +export interface LkePlanDescriptionAPL extends LkePlanDescription { + disabled: boolean; } /* diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 3843a35aceb..584fee1378f 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -3,7 +3,8 @@ */ import { linodeTypeFactory } from '@src/factories'; -import { LkePlanDescription } from 'support/api/lke'; + +import type { LkePlanDescription } from 'support/api/lke'; /** Notice shown to users when selecting a region with a different price structure. */ export const dcPricingRegionDifferenceNotice = @@ -127,9 +128,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( export const dcPricingLkeClusterPlans: LkePlanDescription[] = dcPricingMockLinodeTypes.map( (type) => { return { + nodeCount: 1, + planName: 'Linode 2 GB', size: parseInt(type.id.split('-')[2], 10), tab: 'Shared CPU', - type: 'Linode', + type: 'nanode', }; } ); diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 1a4ca20aded..c6db3407abb 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -1,14 +1,3 @@ -import { LkePlanDescription } from 'support/api/lke'; - -/** - * Subset of LKE cluster plans as shown on Cloud Manager. - */ -export const lkeClusterPlans: LkePlanDescription[] = [ - { size: 4, tab: 'Dedicated CPU', type: 'Dedicated' }, - { size: 2, tab: 'Shared CPU', type: 'Linode' }, - { size: 4, tab: 'Shared CPU', type: 'Linode' }, -]; - /** * Kubernetes versions available for cluster creation via Cloud Manager. */ diff --git a/packages/manager/cypress/support/intercepts/betas.ts b/packages/manager/cypress/support/intercepts/betas.ts index 384961da39e..4874620ec77 100644 --- a/packages/manager/cypress/support/intercepts/betas.ts +++ b/packages/manager/cypress/support/intercepts/betas.ts @@ -6,7 +6,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Beta } from '@linode/api-v4'; +import type { AccountBeta, Beta } from '@linode/api-v4'; /** * Intercepts GET request to fetch account betas (the ones the user has opted into) and mocks response. @@ -15,7 +15,9 @@ import type { Beta } from '@linode/api-v4'; * * @returns Cypress chainable. */ -export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { +export const mockGetAccountBetas = ( + betas: AccountBeta[] +): Cypress.Chainable => { return cy.intercept( 'GET', apiMatcher('account/betas'), @@ -23,6 +25,21 @@ export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { ); }; +/** + * Intercepts GET request to fetch a beta and mocks response. + * + * @param beta - Beta with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountBeta = (beta: AccountBeta): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`account/betas/${beta.id}`), + makeResponse(beta) + ); +}; + /** * Intercepts GET request to fetch available betas (all betas available to the user). * diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 5f646730a96..3f91e6a74a2 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -19,6 +19,7 @@ import type { KubernetesCluster, KubernetesControlPlaneACLPayload, KubernetesVersion, + PriceType, } from '@linode/api-v4'; /** @@ -455,3 +456,16 @@ export const mockUpdateControlPlaneACLError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts GET request for LKE cluster types and mocks the response + * + * @param types - LKE cluster types with which to mock response + * + * @returns Cypress chainable + */ +export const mockGetLKEClusterTypes = ( + types: PriceType[] +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('lke/types*'), paginateResponse(types)); +}; diff --git a/packages/manager/src/factories/betas.ts b/packages/manager/src/factories/betas.ts index 80041895527..60b3c8c99c0 100644 --- a/packages/manager/src/factories/betas.ts +++ b/packages/manager/src/factories/betas.ts @@ -1,20 +1,23 @@ -import { Beta, AccountBeta } from '@linode/api-v4'; -import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; +import Factory from 'src/factories/factoryProxy'; + +import type { AccountBeta, Beta } from '@linode/api-v4'; + export const betaFactory = Factory.Sync.makeFactory({ + description: + 'Aliquam erat volutpat. Nunc eleifend leo vitae magna. In id erat non orci commodo lobortis. Proin neque massa, cursus ut, gravida ut, lobortis eget, lacus. Sed diam. Praesent fermentum tempor tellus. Nullam tempus. Mauris ac felis vel velit tristique imperdiet. Donec at pede. Etiam vel neque nec dui dignissim bibendum. Vivamus id enim. Phasellus neque orci, porta a, aliquet quis, semper a, massa. Phasellus purus. Pellentesque tristique imperdiet tortor. Nam euismod tellus id erat.', id: Factory.each((i) => `beta-${i}`), label: Factory.each((i) => `Beta ${i}`), started: DateTime.now().toISO(), - description: - 'Aliquam erat volutpat. Nunc eleifend leo vitae magna. In id erat non orci commodo lobortis. Proin neque massa, cursus ut, gravida ut, lobortis eget, lacus. Sed diam. Praesent fermentum tempor tellus. Nullam tempus. Mauris ac felis vel velit tristique imperdiet. Donec at pede. Etiam vel neque nec dui dignissim bibendum. Vivamus id enim. Phasellus neque orci, porta a, aliquet quis, semper a, massa. Phasellus purus. Pellentesque tristique imperdiet tortor. Nam euismod tellus id erat.', }); export const accountBetaFactory = Factory.Sync.makeFactory({ - id: Factory.each((i) => `beta-${i}`), - label: Factory.each((i) => `Account Beta ${i}`), - started: DateTime.now().toISO(), description: 'Aliquam erat volutpat. Nunc eleifend leo vitae magna. In id erat non orci commodo lobortis. Proin neque massa, cursus ut, gravida ut, lobortis eget, lacus. Sed diam. Praesent fermentum tempor tellus. Nullam tempus. Mauris ac felis vel velit tristique imperdiet. Donec at pede. Etiam vel neque nec dui dignissim bibendum. Vivamus id enim. Phasellus neque orci, porta a, aliquet quis, semper a, massa. Phasellus purus. Pellentesque tristique imperdiet tortor. Nam euismod tellus id erat.', + ended: null, enrolled: DateTime.now().toISO(), + id: Factory.each((i) => `beta-${i}`), + label: Factory.each((i) => `Account Beta ${i}`), + started: DateTime.now().toISO(), }); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index a8cfd57cd1d..715007bb1ef 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -235,12 +235,11 @@ export const databaseFactory = Factory.Sync.makeFactory({ }); export const databaseBackupFactory = Factory.Sync.makeFactory({ - created: Factory.each(() => - randomDate( - new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), - new Date() - ).toISOString() - ), + created: Factory.each(() => { + const now = new Date(); + const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); + return randomDate(tenDaysAgo, now).toISOString(); + }), id: Factory.each((i) => i), label: Factory.each(() => `backup-${v4()}`), type: pickRandom(['snapshot', 'auto']), diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx index d0f4e0c135d..20d0f5557fc 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx @@ -36,7 +36,7 @@ export const ApplicationPlatform = (props: APLProps) => { }; return ( - + ({ '&&.MuiFormLabel-root.Mui-focused': { @@ -48,19 +48,24 @@ export const ApplicationPlatform = (props: APLProps) => { })} > - Akamai App Platform + Akamai App Platform handleChange(e)}> } label={Yes, enable Akamai App Platform.} - control={} name="yes" value="yes" /> - } label="No" name="no" value="no" /> + } + label="No" + name="no" + value="no" + /> ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index b886bf2861b..794921ef8ec 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -117,7 +117,7 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { } + control={} label="No" name="no" value="no" From 57b884e3d0e6326f67a4ccbf7841b94e60d26071 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Tue, 10 Dec 2024 11:06:38 +0530 Subject: [PATCH 05/11] fix: [M3-8796] - Disallow word-break in billing contact info (#11379) * fix: [M3-8796] - Disallow word-break in billing contact info * Added changeset: Disallow word-break in billing contact info --- packages/manager/.changeset/pr-11379-fixed-1733476117431.md | 5 +++++ .../BillingPanels/ContactInfoPanel/ContactInformation.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11379-fixed-1733476117431.md diff --git a/packages/manager/.changeset/pr-11379-fixed-1733476117431.md b/packages/manager/.changeset/pr-11379-fixed-1733476117431.md new file mode 100644 index 00000000000..315aec9540b --- /dev/null +++ b/packages/manager/.changeset/pr-11379-fixed-1733476117431.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disallow word-break in billing contact info ([#11379](https://github.com/linode/manager/pull/11379)) diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index a6ffc59c1e6..644c5ccff93 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -216,7 +216,7 @@ export const ContactInformation = React.memo((props: Props) => { {(firstName || lastName) && ( {firstName} {lastName} @@ -224,7 +224,7 @@ export const ContactInformation = React.memo((props: Props) => { {company && ( {company} From 8f27d964abaaaa90eda810150894c57aefe59895 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:38:12 +0530 Subject: [PATCH 06/11] upcoming: [DI-22184] - Internal Review Changes: Assertion checks in Unit tests, added a type for the Form, renamed prop --- packages/api-v4/src/cloudpulse/types.ts | 4 +- .../CreateAlertDefinition.test.tsx | 4 +- .../CreateAlert/CreateAlertDefinition.tsx | 15 ++++--- .../CreateAlert/Criteria/Metric.test.tsx | 42 +++++++++---------- .../Criteria/MetricCriteria.test.tsx | 28 +++++++------ .../CreateAlert/Criteria/MetricCriteria.tsx | 18 ++++---- .../ResourceMultiSelect.tsx | 3 +- 7 files changed, 58 insertions(+), 56 deletions(-) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index e40ef3d116b..3a7daa4a153 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -153,13 +153,14 @@ export interface ServiceTypesList { export interface CreateAlertDefinitionPayload { label: string; + tags?: string[]; description?: string; entity_ids?: string[]; severity: AlertSeverityType; rule_criteria: { rules: MetricCriteria[]; }; - trigger_condition: TriggerCondition; + trigger_conditions: TriggerCondition; channel_ids: number[]; } export interface MetricCriteria { @@ -190,6 +191,7 @@ export interface TriggerCondition { export interface Alert { id: number; label: string; + tags: string[]; description: string; has_more_resources: boolean; status: AlertStatusType; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index 76cf1f479d3..ea1a737bafe 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -22,8 +22,8 @@ describe('AlertDefinition Create', () => { expect(getByText('Metric Threshold')).toBeVisible(); expect(getByLabelText('Data Field')).toBeVisible(); expect(getByLabelText('Aggregation Type')).toBeVisible(); - expect(getByLabelText('Operator')); - expect(getByLabelText('Threshold')); + expect(getByLabelText('Operator')).toBeVisible(); + expect(getByLabelText('Threshold')).toBeVisible(); }); it('should be able to enter a value in the textbox', async () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 94101bd6ad5..4f7dfec6f11 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -23,9 +23,9 @@ import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; const triggerConditionInitialValues: TriggerCondition = { criteria_condition: 'ALL', - evaluation_period_seconds: 0, - polling_interval_seconds: 0, - trigger_occurrences: 0, + evaluation_period_seconds: 300, + polling_interval_seconds: 60, + trigger_occurrences: 2, }; const criteriaInitialValues: MetricCriteriaForm = { aggregation_type: null, @@ -35,7 +35,7 @@ const criteriaInitialValues: MetricCriteriaForm = { threshold: 0, }; const initialValues: CreateAlertDefinitionForm = { - channel_ids: [], + channel_ids: [10000], engineType: null, entity_ids: [], label: '', @@ -45,7 +45,8 @@ const initialValues: CreateAlertDefinitionForm = { }, serviceType: null, severity: null, - trigger_condition: triggerConditionInitialValues, + tags: [''], + trigger_conditions: triggerConditionInitialValues, }; const overrides = [ @@ -99,8 +100,6 @@ export const CreateAlertDefinition = () => { } catch (errors) { for (const error of errors) { if (error.field) { - // eslint-disable-next-line no-console - console.log(error); setError(error.field, { message: error.reason }); } else { enqueueSnackbar(`Alert failed: ${error.reason}`, { @@ -163,7 +162,7 @@ export const CreateAlertDefinition = () => { /> + setMaxInterval={(interval: number) => setMaxScrapeInterval(interval) } name="rule_criteria.rules" 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 b600a25766a..65b204bd1ff 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 @@ -1,4 +1,4 @@ -import { within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -48,28 +48,26 @@ const mockData: AvailableMetrics[] = [ describe('Metric component tests', () => { const user = userEvent.setup(); it('should render all the components and names', () => { - const container = renderWithThemeAndHookFormContext( - { - component: ( - - ), - useFormOptions: { - defaultValues: { - serviceType: 'linode', - }, + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', }, - } - ); - expect(container.getAllByLabelText('Data Field')); - expect(container.getAllByLabelText('Aggregation Type')); - expect(container.getAllByLabelText('Operator')); - expect(container.getAllByLabelText('Threshold')); + }, + }); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Aggregation Type')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Threshold')).toBeVisible(); }); it('should render the Data Field component with options happy path and select an option', async () => { 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 cbedaa7a38a..7056166f88a 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 @@ -102,9 +102,9 @@ describe('MetricCriteriaField', () => { renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -117,6 +117,10 @@ describe('MetricCriteriaField', () => { }); expect(screen.getByText('2. Criteria')).toBeVisible(); expect(screen.getByText('Metric Threshold')).toBeVisible(); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Aggregation Type')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Threshold')).toBeVisible(); }); it('renders the initial metric field without the delete-icon', async () => { @@ -126,9 +130,9 @@ describe('MetricCriteriaField', () => { } = renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -158,9 +162,9 @@ describe('MetricCriteriaField', () => { renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -183,9 +187,9 @@ describe('MetricCriteriaField', () => { } = renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -204,14 +208,14 @@ describe('MetricCriteriaField', () => { ); }); - it('getMaxInterval has to be called', async () => { + it('setMaxInterval has to be called', async () => { queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ data: mockData, isError: true, isLoading: false, status: 'error', }); - const getMaxInterval = vi.fn(); + const setMaxInterval = vi.fn(); const firstOption = mockData.data[0]; const [firstOptionConvertedTime] = convertToSeconds([ firstOption.scrape_interval, @@ -219,9 +223,9 @@ describe('MetricCriteriaField', () => { renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -233,17 +237,17 @@ describe('MetricCriteriaField', () => { }, }); - expect(getMaxInterval).toBeCalledWith(firstOptionConvertedTime); + expect(setMaxInterval).toBeCalledWith(firstOptionConvertedTime); }); - it('getMaxInterval has to be called', async () => { + it('setMaxInterval has to be called', async () => { queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ data: mockData, isError: true, isLoading: false, status: 'error', }); - const getMaxInterval = vi.fn(); + const setMaxInterval = vi.fn(); const firstOption = mockData.data[0]; const secondOption = mockData.data[1]; const [ @@ -256,9 +260,9 @@ describe('MetricCriteriaField', () => { renderWithThemeAndHookFormContext({ component: ( ), useFormOptions: { @@ -270,7 +274,7 @@ describe('MetricCriteriaField', () => { }, }); - expect(getMaxInterval).toBeCalledWith( + expect(setMaxInterval).toBeCalledWith( Math.max(firstOptionConvertedTime, secondOptionConvertedTime) ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx index aa52850e398..27c535c6f8b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -12,12 +12,6 @@ import type { AlertServiceType } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; interface MetricCriteriaProps { - /** - * function used to pass the scrape interval value to the parent component - * @param maxInterval number value that takes the maximum scrape interval from the list of selected metrics - * @returns void - */ - getMaxInterval: (maxInterval: number) => void; /** * name used for the component to set formik field */ @@ -26,10 +20,16 @@ interface MetricCriteriaProps { * serviceType used by the api to fetch the metric definitions */ serviceType: AlertServiceType | null; + /** + * function used to pass the scrape interval value to the parent component + * @param maxInterval number value that takes the maximum scrape interval from the list of selected metrics + * @returns void + */ + setMaxInterval: (maxInterval: number) => void; } export const MetricCriteriaField = (props: MetricCriteriaProps) => { - const { getMaxInterval, name, serviceType } = props; + const { name, serviceType, setMaxInterval } = props; const { data: metricDefinitions, isError: isMetricDefinitionError, @@ -55,8 +55,8 @@ export const MetricCriteriaField = (props: MetricCriteriaProps) => { const maxInterval = Math.max( ...convertToSeconds(intervalList ? intervalList : []) ); - getMaxInterval(maxInterval); - }, [getMaxInterval, metricCriteriaWatcher, metricDefinitions]); + setMaxInterval(maxInterval); + }, [setMaxInterval, metricCriteriaWatcher, metricDefinitions]); const { append, fields, remove } = useFieldArray({ control, diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx index 57d883a1ea4..1ca76ad165f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -34,7 +34,6 @@ export const CloudPulseMultiResourceSelect = ( const { engine, name, region, serviceType } = { ...props }; const { control, setValue } = useFormContext(); - const { data: resources, isError, isLoading } = useResourcesQuery( Boolean(region && serviceType), serviceType?.toString(), @@ -46,7 +45,7 @@ export const CloudPulseMultiResourceSelect = ( return resources && resources.length > 0 ? resources.map((resource) => ({ label: resource.label, - value: resource.id, + value: resource.id.toString(), })) : []; }, [resources]); From 6fa2545ccda589da49c49206792ffe98e3f527cd Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:42:50 +0530 Subject: [PATCH 07/11] upcoming: [DI-22184] - Removing few initial values used for internal testing --- .../CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 4f7dfec6f11..0cccf2483c6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -21,6 +21,7 @@ import { filterFormValues } from './utilities'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; + const triggerConditionInitialValues: TriggerCondition = { criteria_condition: 'ALL', evaluation_period_seconds: 300, @@ -43,9 +44,9 @@ const initialValues: CreateAlertDefinitionForm = { rule_criteria: { rules: [criteriaInitialValues], }, + tags: [''], serviceType: null, severity: null, - tags: [''], trigger_conditions: triggerConditionInitialValues, }; From 6431f026cc690ac41d7069aef58a00d16c223e03 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:44:17 +0530 Subject: [PATCH 08/11] upcoming: [DI-22184] - Fixing previoius accidental commit --- .../Alerts/CreateAlert/CreateAlertDefinition.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 0cccf2483c6..cfc4cab74ae 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -21,12 +21,11 @@ import { filterFormValues } from './utilities'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; - const triggerConditionInitialValues: TriggerCondition = { criteria_condition: 'ALL', - evaluation_period_seconds: 300, - polling_interval_seconds: 60, - trigger_occurrences: 2, + evaluation_period_seconds: 0, + polling_interval_seconds: 0, + trigger_occurrences: 0, }; const criteriaInitialValues: MetricCriteriaForm = { aggregation_type: null, @@ -36,7 +35,7 @@ const criteriaInitialValues: MetricCriteriaForm = { threshold: 0, }; const initialValues: CreateAlertDefinitionForm = { - channel_ids: [10000], + channel_ids: [], engineType: null, entity_ids: [], label: '', @@ -44,9 +43,9 @@ const initialValues: CreateAlertDefinitionForm = { rule_criteria: { rules: [criteriaInitialValues], }, - tags: [''], serviceType: null, severity: null, + tags: [''], trigger_conditions: triggerConditionInitialValues, }; From 652b2aad726513fdc08da760ac68f3df09716279 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 19:48:45 +0530 Subject: [PATCH 09/11] upcoming: [DI-22217] - Added the Alert Listing page with Alerting Table and added relevant api endpoints (#11346) * upcoming: [DI-22217] - Added AlertListing component With Table headers, Table rows and added the GET api endpoint and mockServer for fetching alert definitions * upcoming : [DI-22217] - Added the TableRowError and TableRowLoading for the AlertListing Table and minor changes * upcoming: [DI-22217] - Added changesets * upcoming: [DI-22217] - Review changes: Improving AlertListing test cases, unabbreviated UT to Unit Tests in changeset * upcoming: [DI-22217] - Removed unused import * upcoming: [DI-22217] - Review changes: Removed redundant checks, removed waitFor in the unit tests and removed Severity column, removed the Placeholder Icon for Alerts Landing empty state * upcoming: [DI-22217] - Restructured the alert queries * upcoming: [DI-22217] - Review changes: Added a TableRowLabelMap for the TableHead Row, Replaced referencing colors directly and used theme styling for colors * upcoming: [DI-22217] - fixed eslint error * upcoming: [DI-22217] - Minor styling changes and fixed failing tests * upcoming: [DI-22217] - minor changes: changed the key when mapping over AlertListingTableLabelMap, used shorthand --- .../pr-11346-changed-1733145182722.md | 5 ++ packages/api-v4/src/cloudpulse/alerts.ts | 19 ++++- packages/api-v4/src/cloudpulse/types.ts | 2 +- .../pr-11346-added-1733145106911.md | 5 ++ .../src/factories/cloudpulse/alerts.ts | 2 +- .../AlertsLanding/AlertsDefinitionLanding.tsx | 14 +--- .../Alerts/AlertsListing/AlertActionMenu.tsx | 36 ++++++++++ .../AlertsListing/AlertListing.test.tsx | 62 ++++++++++++++++ .../Alerts/AlertsListing/AlertListing.tsx | 72 +++++++++++++++++++ .../AlertsListing/AlertTableRow.test.tsx | 29 ++++++++ .../Alerts/AlertsListing/AlertTableRow.tsx | 52 ++++++++++++++ .../Alerts/AlertsListing/constants.ts | 22 ++++++ packages/manager/src/mocks/serverHandlers.ts | 28 +++++++- .../manager/src/queries/cloudpulse/alerts.ts | 23 +++++- .../manager/src/queries/cloudpulse/queries.ts | 12 +++- .../src/queries/cloudpulse/requests.ts | 16 +++++ 16 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11346-changed-1733145182722.md create mode 100644 packages/manager/.changeset/pr-11346-added-1733145106911.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts create mode 100644 packages/manager/src/queries/cloudpulse/requests.ts diff --git a/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md new file mode 100644 index 00000000000..7f6129d47ae --- /dev/null +++ b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 3c6f909b9db..586be5fbd20 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,7 +1,14 @@ import { createAlertDefinitionSchema } from '@linode/validation'; -import Request, { setURL, setMethod, setData } from '../request'; +import Request, { + setURL, + setMethod, + setData, + setParams, + setXFilter, +} from '../request'; import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from '../constants'; +import { Params, Filter, ResourcePage } from '../types'; export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, @@ -16,3 +23,11 @@ export const createAlertDefinition = ( setMethod('POST'), setData(data, createAlertDefinitionSchema) ); + +export const getAlertDefinitions = (params?: Params, filters?: Filter) => + Request>( + setURL(`${API_ROOT}/monitor/alert-definitions`), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 765a1d71ba1..465220792b4 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -3,7 +3,7 @@ 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 AlertDefinitionType = 'default' | 'custom'; +export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; export interface Dashboard { id: number; diff --git a/packages/manager/.changeset/pr-11346-added-1733145106911.md b/packages/manager/.changeset/pr-11346-added-1733145106911.md new file mode 100644 index 00000000000..6b4d5327cef --- /dev/null +++ b/packages/manager/.changeset/pr-11346-added-1733145106911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +AlertListing component and AlertTableRow component with Unit Tests ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index a0bc2b6edf7..4a7bb721854 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -22,7 +22,7 @@ export const alertFactory = Factory.Sync.makeFactory({ polling_interval_seconds: 0, trigger_occurrences: 0, }, - type: 'default', + type: 'user', updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index 352ded4e3e2..0f037732ece 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -1,29 +1,21 @@ -import { Paper, Typography } from '@linode/ui'; import * as React from 'react'; import { Route, Switch } from 'react-router-dom'; +import { AlertListing } from '../AlertsListing/AlertListing'; import { CreateAlertDefinition } from '../CreateAlert/CreateAlertDefinition'; export const AlertDefinitionLanding = () => { return ( } + component={CreateAlertDefinition} path="/monitor/alerts/definitions/create" /> ); }; - -const AlertDefinition = () => { - return ( - - Alert Definition - - ); -}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx new file mode 100644 index 00000000000..8e379ba3a08 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { AlertDefinitionType } from '@linode/api-v4'; + +export interface ActionHandlers { + // These handlers will be enhanced based on the alert type and actions required + /** + * Callback for delete action + */ + handleDelete: () => void; + + /** + * Callback for show details action + */ + handleDetails: () => void; +} + +export interface AlertActionMenuProps { + /** + * Type of the alert + */ + alertType?: AlertDefinitionType; + /** + * Handlers for alert actions like delete, show details etc., + */ + handlers?: ActionHandlers; +} + +/** + * The handlers and alertType are made optional only temporarily, they will be enabled but they are dependent on another feature which will be part of next PR + */ +export const AlertActionMenu = () => { + return ; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx new file mode 100644 index 00000000000..e0e2bf18e41 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; + +import { alertFactory } from 'src/factories/cloudpulse/alerts'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertListing } from './AlertListing'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertDefinitionsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAllAlertDefinitionsQuery: queryMocks.useAllAlertDefinitionsQuery, + }; +}); + +const mockResponse = alertFactory.buildList(3); + +describe('Alert Listing', () => { + it('should render the error message', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: undefined, + error: 'an error happened', + isError: true, + isLoading: false, + }); + const { getAllByText } = renderWithTheme(); + getAllByText('Error in fetching the alerts.'); + }); + + it('should render the alert landing table with items', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + const { getByText } = renderWithTheme(); + expect(getByText('Alert Name')).toBeInTheDocument(); + expect(getByText('Service')).toBeInTheDocument(); + expect(getByText('Status')).toBeInTheDocument(); + expect(getByText('Last Modified')).toBeInTheDocument(); + expect(getByText('Created By')).toBeInTheDocument(); + }); + + it('should render the alert row', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + + const { getByText } = renderWithTheme(); + expect(getByText(mockResponse[0].label)).toBeInTheDocument(); + expect(getByText(mockResponse[1].label)).toBeInTheDocument(); + expect(getByText(mockResponse[2].label)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx new file mode 100644 index 00000000000..f2684332333 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -0,0 +1,72 @@ +import { Paper } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; +import { useAllAlertDefinitionsQuery } from 'src/queries/cloudpulse/alerts'; + +import { AlertTableRow } from './AlertTableRow'; +import { AlertListingTableLabelMap } from './constants'; + +export const AlertListing = () => { + // These are dummy order value and handleOrder methods, will replace them in the next PR + const order = 'asc'; + const handleOrderChange = () => { + return 'asc'; + }; + const { data: alerts, isError, isLoading } = useAllAlertDefinitionsQuery(); + if (alerts?.length === 0) { + return ( + + + + + + ); + } + return ( + + + + + {AlertListingTableLabelMap.map((value) => ( + + {value.colName} + + ))} + + + + + {isError && ( + + )} + {isLoading && } + {alerts?.map((alert) => ( + + ))} + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx new file mode 100644 index 00000000000..232b12e801a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { alertFactory } from 'src/factories/cloudpulse/alerts'; +import { capitalize } from 'src/utilities/capitalize'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { AlertTableRow } from './AlertTableRow'; + +describe('Alert Row', () => { + it('should render an alert row', async () => { + const alert = alertFactory.build(); + const renderedAlert = ; + const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); + expect(getByText(alert.label)).toBeVisible(); + }); + + /** + * As of now the styling for the status 'enabled' is decided, in the future if they decide on the + other styles possible status values, will update them and test them accordingly. + */ + it('should render the status field in green color if status is enabled', () => { + const statusValue = 'enabled'; + const alert = alertFactory.build({ status: statusValue }); + const renderedAlert = ; + const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); + const statusElement = getByText(capitalize(statusValue)); + expect(getComputedStyle(statusElement).color).toBe('rgb(0, 176, 80)'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx new file mode 100644 index 00000000000..ec6dac7b024 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -0,0 +1,52 @@ +import { Typography } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import * as React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { capitalize } from 'src/utilities/capitalize'; + +import { AlertActionMenu } from './AlertActionMenu'; + +import type { Alert } from '@linode/api-v4'; + +interface Props { + /** + * alert details used by the component to fill the row details + */ + alert: Alert; +} + +export const AlertTableRow = (props: Props) => { + const { alert } = props; + const { created_by, id, label, service_type, status, updated } = alert; + const theme = useTheme(); + return ( + + {label} + {service_type} + + + {capitalize(status)} + + + + + + {created_by} + + {/* handlers are supposed to be passed to this AlertActionMenu, + it is dependent on other feature and will added as that feature in the next PR + */} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts new file mode 100644 index 00000000000..057155cffb4 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -0,0 +1,22 @@ +export const AlertListingTableLabelMap = [ + { + colName: 'Alert Name', + label: 'alertName', + }, + { + colName: 'Service', + label: 'service', + }, + { + colName: 'Status', + label: 'status', + }, + { + colName: 'Last Modified', + label: 'lastModified', + }, + { + colName: 'Created By', + label: 'createdBy', + }, +]; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f0df0bde8e2..88c33b8d5d8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2346,7 +2346,7 @@ export const handlers = [ http.post( '*/monitor/services/:service_type/alert-definitions', async ({ request }) => { - const types: AlertDefinitionType[] = ['custom', 'default']; + const types: AlertDefinitionType[] = ['system', 'user']; const status: AlertStatusType[] = ['enabled', 'disabled']; const severity: AlertSeverityType[] = [0, 1, 2, 3]; const users = ['user1', 'user2', 'user3']; @@ -2365,6 +2365,32 @@ export const handlers = [ return HttpResponse.json(response); } ), + http.get('*/monitor/alert-definitions', async ({ request }) => { + const customAlerts = alertFactory.buildList(2, { + severity: 0, + type: 'user', + }); + const customAlertsWithServiceType = alertFactory.buildList(2, { + service_type: 'dbaas', + severity: 1, + type: 'user', + }); + const defaultAlerts = alertFactory.buildList(1, { type: 'system' }); + const defaultAlertsWithServiceType = alertFactory.buildList(1, { + service_type: 'dbaas', + severity: 3, + type: 'system', + }); + const alerts = [ + ...defaultAlerts, + ...alertFactory.buildList(3, { status: 'disabled' }), + ...customAlerts, + ...defaultAlertsWithServiceType, + ...alertFactory.buildList(3), + ...customAlertsWithServiceType, + ]; + return HttpResponse.json(makeResourcePage(alerts)); + }), http.get('*/monitor/services', () => { const response: ServiceTypesList = { data: [ diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 0da27a07093..a744f80d719 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,6 +1,12 @@ import { createAlertDefinition } from '@linode/api-v4/lib/cloudpulse'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { queryPresets } from '../base'; import { queryFactory } from './queries'; import type { @@ -8,7 +14,7 @@ import type { AlertServiceType, CreateAlertDefinitionPayload, } from '@linode/api-v4/lib/cloudpulse'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, Filter, Params } from '@linode/api-v4/lib/types'; export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { const queryClient = useQueryClient(); @@ -19,3 +25,16 @@ export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { }, }); }; + +export const useAllAlertDefinitionsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery({ + ...queryFactory.alerts._ctx.all(params, filter), + ...queryPresets.longLived, + enabled, + placeholderData: keepPreviousData, + }); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index dc9205f0ad4..41f280bf37d 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -11,6 +11,7 @@ import { databaseQueries } from '../databases/databases'; import { getAllLinodesRequest } from '../linodes/requests'; import { volumeQueries } from '../volumes/volumes'; import { fetchCloudPulseMetrics } from './metrics'; +import { getAllAlertsRequest } from './requests'; import type { CloudPulseMetricsRequest, @@ -23,7 +24,16 @@ const key = 'Clousepulse'; export const queryFactory = createQueryKeys(key, { alerts: { - // This query key is a placeholder , it will be updated once the relevant queries are added + contextQueries: { + alert: { + // This query key is a placeholder , it will be updated once the relevant queries are added + queryKey: null, + }, + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllAlertsRequest(params, filter), + queryKey: [params, filter], + }), + }, queryKey: null, }, dashboardById: (dashboardId: number) => ({ diff --git a/packages/manager/src/queries/cloudpulse/requests.ts b/packages/manager/src/queries/cloudpulse/requests.ts new file mode 100644 index 00000000000..148b9f0bdd4 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/requests.ts @@ -0,0 +1,16 @@ +import { getAlertDefinitions } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { Alert, Filter, Params } from '@linode/api-v4'; + +export const getAllAlertsRequest = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getAlertDefinitions( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); From ac15267b6e0f93de7a43f842ca75987dda151933 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:08:39 -0500 Subject: [PATCH 10/11] refactor: [M3-8783] - Migrate /volumes to Tanstack Router (#11154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial commit: wire new router * Migrate test to use new helper * volumes landing routing * doin the filtering * wire actions * cleanup * cleanup * moar cleanup * more work on params * usePaginationV2 * cleanup * oops fix types * fix e2e * useOrderV2 * and... dont forget the util 🤦 * revert unwarranted change * useOrderV2 test * console error * A bit more cleanup * usePaginationV2 test * usePaginationV2 test * route level error handling * xFilter util * xFilter util improvements * post rebase fix * testing xQuery builder * moar testing and cleanup * Added changeset: Migrate `/volumes` to Tanstack router * Save progress * fix one test * fix remaining test * feedback @jaalah-akamai * more work on preloading * save progress * Walking back and using a more progressive approach * cleanup * entity not found logic * post rebase fix * post rebase fix * update loading state * fix the smoke tests * Feedback @bnussman-akamai * JsDocs for Tanstack link components * Improve new useOrder hook * feedback @coliu-akamai @hkhalil-akamai --- .../pr-11154-tech-stories-1730404184309.md | 5 + packages/manager/.eslintrc.cjs | 6 +- packages/manager/.storybook/preview.tsx | 13 +- .../core/volumes/create-volume.smoke.spec.ts | 7 +- .../e2e/core/volumes/upgrade-volume.spec.ts | 9 +- packages/manager/src/MainContent.tsx | 6 +- packages/manager/src/Router.tsx | 2 + .../src/components/TanstackLink.stories.tsx | 38 +++ .../manager/src/components/TanstackLinks.tsx | 83 ++++++ .../TypeToConfirmDialog.tsx | 2 + .../Volumes/AttachVolumeDrawer.test.tsx | 8 +- .../features/Volumes/AttachVolumeDrawer.tsx | 4 +- .../Volumes/CloneVolumeDrawer.test.tsx | 8 +- .../features/Volumes/CloneVolumeDrawer.tsx | 10 +- .../features/Volumes/DeleteVolumeDialog.tsx | 4 +- .../features/Volumes/DetachVolumeDialog.tsx | 4 +- .../Volumes/EditVolumeDrawer.test.tsx | 8 +- .../src/features/Volumes/EditVolumeDrawer.tsx | 12 +- .../features/Volumes/ResizeVolumeDrawer.tsx | 10 +- .../features/Volumes/UpgradeVolumeDialog.tsx | 4 +- .../features/Volumes/VolumeCreate.test.tsx | 12 +- .../src/features/Volumes/VolumeCreate.tsx | 20 +- .../features/Volumes/VolumeDetailsDrawer.tsx | 10 +- .../LinodeVolumeAddDrawer.test.tsx | 4 +- .../Volumes/VolumeDrawer/SizeField.tsx | 2 +- .../features/Volumes/VolumeTableRow.test.tsx | 23 +- .../src/features/Volumes/VolumeTableRow.tsx | 1 + .../manager/src/features/Volumes/Volumes.tsx | 29 -- .../Volumes/VolumesActionMenu.test.tsx | 22 +- .../src/features/Volumes/VolumesLanding.tsx | 224 +++++++++------ .../Volumes/VolumesLandingEmptyState.test.tsx | 6 +- .../Volumes/VolumesLandingEmptyState.tsx | 6 +- .../Volumes/VolumesLandingEmptyStateData.ts | 3 +- .../manager/src/features/Volumes/constants.ts | 1 + .../manager/src/features/Volumes/index.tsx | 2 - packages/manager/src/hooks/useDialogData.ts | 100 +++++++ packages/manager/src/hooks/useOrder.ts | 2 +- .../manager/src/hooks/useOrderV2.test.tsx | 155 +++++++++++ packages/manager/src/hooks/useOrderV2.ts | 120 ++++++++ .../manager/src/hooks/usePaginationV2.test.ts | 258 ++++++++++++++++++ packages/manager/src/hooks/usePaginationV2.ts | 125 +++++++++ .../manager/src/mocks/utilities/response.ts | 2 +- packages/manager/src/routes/index.tsx | 13 +- packages/manager/src/routes/types.ts | 9 + .../{VolumesRoute.tsx => VolumesRoot.tsx} | 2 +- .../manager/src/routes/volumes/constants.ts | 3 + packages/manager/src/routes/volumes/index.ts | 77 +++++- .../src/routes/volumes/volumesLazyRoutes.ts | 12 + .../manager/src/types/ManagerPreferences.ts | 4 + 49 files changed, 1284 insertions(+), 206 deletions(-) create mode 100644 packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md create mode 100644 packages/manager/src/components/TanstackLink.stories.tsx create mode 100644 packages/manager/src/components/TanstackLinks.tsx delete mode 100644 packages/manager/src/features/Volumes/Volumes.tsx create mode 100644 packages/manager/src/features/Volumes/constants.ts delete mode 100644 packages/manager/src/features/Volumes/index.tsx create mode 100644 packages/manager/src/hooks/useDialogData.ts create mode 100644 packages/manager/src/hooks/useOrderV2.test.tsx create mode 100644 packages/manager/src/hooks/useOrderV2.ts create mode 100644 packages/manager/src/hooks/usePaginationV2.test.ts create mode 100644 packages/manager/src/hooks/usePaginationV2.ts rename packages/manager/src/routes/volumes/{VolumesRoute.tsx => VolumesRoot.tsx} (92%) create mode 100644 packages/manager/src/routes/volumes/constants.ts create mode 100644 packages/manager/src/routes/volumes/volumesLazyRoutes.ts diff --git a/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md new file mode 100644 index 00000000000..f1920ce7895 --- /dev/null +++ b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Migrate `/volumes` to Tanstack router ([#11154](https://github.com/linode/manager/pull/11154)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 99870119ff7..3e3b3de5a66 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -89,10 +89,14 @@ module.exports = { { files: [ // for each new features added to the migration router, add its directory here - 'src/features/Betas/*', + 'src/features/Betas/**/*', + 'src/features/Volumes/**/*', ], rules: { 'no-restricted-imports': [ + // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router + // For those cases react-router-dom history.push is still needed + // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports 'error', { paths: [ diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 55197641f90..6de6f42f5ce 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -9,7 +9,10 @@ import { Controls, Stories, } from '@storybook/blocks'; -import { wrapWithTheme } from '../src/utilities/testHelpers'; +import { + wrapWithTheme, + wrapWithThemeAndRouter, +} from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; @@ -42,9 +45,13 @@ export const DocsContainer = ({ children, context }) => { const preview: Preview = { decorators: [ - (Story) => { + (Story, context) => { const isDark = useDarkMode(); - return wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); + return context.parameters.tanStackRouter + ? wrapWithThemeAndRouter(, { + theme: isDark ? 'dark' : 'light', + }) + : wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); }, ], loaders: [ diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 3c645ace145..0ca5c8ce4e2 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -11,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { mockCreateVolume, + mockGetVolume, mockGetVolumes, mockDetachVolume, mockGetVolumeTypesError, @@ -85,6 +86,7 @@ describe('volumes', () => { mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); + mockGetVolume(mockVolume).as('getVolume'); mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { @@ -114,7 +116,7 @@ describe('volumes', () => { mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); - cy.wait(['@createVolume', '@getVolumes']); + cy.wait(['@createVolume', '@getVolume', '@getVolumes']); validateBasicVolume(mockVolume.label); ui.actionMenu @@ -193,6 +195,7 @@ describe('volumes', () => { mockDetachVolume(mockAttachedVolume.id).as('detachVolume'); mockGetVolumes([mockAttachedVolume]).as('getAttachedVolumes'); + mockGetVolume(mockAttachedVolume).as('getVolume'); cy.visitWithLogin('/volumes', { preferenceOverrides, localStorageOverrides, @@ -209,6 +212,8 @@ describe('volumes', () => { ui.actionMenuItem.findByTitle('Detach').click(); + cy.wait('@getVolume'); + ui.dialog .findByTitle(`Detach Volume ${mockAttachedVolume.label}?`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index f6d807e4c15..27f5e379b84 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -10,7 +10,11 @@ import { mockGetLinodeDisks, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; +import { + mockMigrateVolumes, + mockGetVolumes, + mockGetVolume, +} from 'support/intercepts/volumes'; import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { @@ -23,6 +27,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); + mockGetVolume(volume).as('getVolume'); mockMigrateVolumes().as('migrateVolumes'); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' @@ -53,7 +58,7 @@ describe('volume upgrade/migration', () => { .click(); }); - cy.wait(['@migrateVolumes', '@getNotifications']); + cy.wait(['@migrateVolumes', '@getVolume', '@getNotifications']); cy.findByText('UPGRADE PENDING').should('be.visible'); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e0bfc2f2af3..82a2b3eb6d5 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,5 +1,6 @@ import { Box } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; @@ -130,7 +131,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Volumes = React.lazy(() => import('src/features/Volumes')); const Domains = React.lazy(() => import('src/features/Domains').then((module) => ({ default: module.DomainsRoutes, @@ -207,6 +207,7 @@ export const MainContent = () => { const { classes, cx } = useStyles(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); + const queryClient = useQueryClient(); const globalErrors = useGlobalErrors(); @@ -336,8 +337,6 @@ export const MainContent = () => { path="/placement-groups" /> )} - - { */} diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 7d89f4fa297..a8e12fd54f3 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -24,6 +25,7 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, + queryClient: new QueryClient(), }, }); diff --git a/packages/manager/src/components/TanstackLink.stories.tsx b/packages/manager/src/components/TanstackLink.stories.tsx new file mode 100644 index 00000000000..badc132c03f --- /dev/null +++ b/packages/manager/src/components/TanstackLink.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { TanstackLink } from './TanstackLinks'; + +import type { TanstackLinkComponentProps } from './TanstackLinks'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const AsButtonPrimary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsButtonSecondary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsLink: StoryObj = { + render: () => ( + + Home + + ), +}; + +const meta: Meta = { + parameters: { + tanStackRouter: true, + }, + title: 'Foundations/Link/Tanstack Link', +}; +export default meta; diff --git a/packages/manager/src/components/TanstackLinks.tsx b/packages/manager/src/components/TanstackLinks.tsx new file mode 100644 index 00000000000..bea4ddd332d --- /dev/null +++ b/packages/manager/src/components/TanstackLinks.tsx @@ -0,0 +1,83 @@ +import { Button } from '@linode/ui'; +import { omitProps } from '@linode/ui'; +import { LinkComponent } from '@tanstack/react-router'; +import { createLink } from '@tanstack/react-router'; +import * as React from 'react'; + +import { MenuItem } from 'src/components/MenuItem'; + +import type { ButtonProps, ButtonType } from '@linode/ui'; +import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; + +export interface TanstackLinkComponentProps + extends Omit { + linkType: 'link' | ButtonType; + tooltipAnalyticsEvent?: (() => void) | undefined; + tooltipText?: string; +} + +export interface TanStackLinkRoutingProps { + linkType: TanstackLinkComponentProps['linkType']; + params?: TanStackLinkProps['params']; + preload?: TanStackLinkProps['preload']; + search?: TanStackLinkProps['search']; + to: TanStackLinkProps['to']; +} + +const LinkComponent = React.forwardRef< + HTMLButtonElement, + TanstackLinkComponentProps +>((props, ref) => { + const _props = omitProps(props, ['linkType']); + + return