From fdff1e07e1c9f23ebf9872fba869fb7071cbd3e9 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 27 Oct 2025 06:01:09 +0530 Subject: [PATCH 1/3] upcoming: [DI-27571] - Blockstorage onboarding for ACLP Alerts --- .../src/factories/cloudpulse/alerts.ts | 219 ++++++++++++ .../AlertsResources/AlertsResources.tsx | 7 +- .../Alerts/AlertsResources/constants.ts | 13 + ...StorageDimensionFilterAutcomplete.test.tsx | 320 ++++++++++++++++++ ...lockStorageDimensionFilterAutocomplete.tsx | 78 +++++ .../ValueFieldRenderer.test.tsx | 25 +- .../ValueFieldRenderer.tsx | 31 +- .../DimensionFilterValue/constants.ts | 6 +- .../useBlockStorageFetchOptions.ts | 99 ++++++ .../useFirewallFetchOptions.ts | 2 +- .../useObjectStorageFetchOptions.ts | 6 +- .../DimensionFilterValue/utils.test.ts | 38 +-- .../Criteria/DimensionFilterValue/utils.ts | 46 ++- .../shared/CloudPulseResourcesSelect.tsx | 1 + .../CloudPulse/shared/DimensionTransform.ts | 3 + packages/manager/src/mocks/serverHandlers.ts | 62 +++- .../src/queries/cloudpulse/resources.ts | 1 + .../utilities/src/__data__/regionsData.ts | 7 +- 18 files changed, 915 insertions(+), 49 deletions(-) create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useBlockStorageFetchOptions.ts diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index ae09ca13fa9..bd5c92c220d 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -360,3 +360,222 @@ export const objectStorageMetricRules: MetricDefinition[] = [ ], }, ]; + +export const blockStorageMetricRules: MetricDefinition[] = [ + { + label: 'Volume Read Operations', + metric: 'volume_read_ops', + unit: 'Count', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Operations', + metric: 'volume_write_ops', + unit: 'Count', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Bytes', + metric: 'volume_read_bytes', + unit: 'KB', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Bytes', + metric: 'volume_write_bytes', + unit: 'KB', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p99', + metric: 'volume_read_latency_p99', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p95', + metric: 'volume_read_latency_p95', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p90', + metric: 'volume_read_latency_p90', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p50', + metric: 'volume_read_latency_p50', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p99', + metric: 'volume_write_latency_p99', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p95', + metric: 'volume_write_latency_p95', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p90', + metric: 'volume_write_latency_p90', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p50', + metric: 'volume_write_latency_p50', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, +]; + +export const blockStorageMetricCriteria = + Factory.Sync.makeFactory({ + label: 'Volume Read Operations', + metric: 'volume_read_ops', + unit: 'Count', + aggregate_function: 'avg', + operator: 'gt', + threshold: 1000, + dimension_filters: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + operator: 'in', + value: '1,2,3', + }, + { + label: 'linode_id', + dimension_label: 'linode_id', + operator: 'eq', + value: '5', + }, + ], + }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 2b2bbc9b6eb..cbc5daf1d74 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -138,6 +138,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { if ( serviceType === 'firewall' || serviceType === 'objectstorage' || + serviceType === 'blockstorage' || !supportedRegionIds?.length ) { return undefined; @@ -197,7 +198,11 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { ); const regionFilteredResources = React.useMemo(() => { - if (serviceType === 'objectstorage' && resources && supportedRegionIds) { + if ( + (serviceType === 'objectstorage' || serviceType === 'blockstorage') && + resources && + supportedRegionIds + ) { return getOfflineRegionFilteredResources(resources, supportedRegionIds); } return resources; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts index 4c6cabef159..57a793cc4b2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts @@ -104,6 +104,18 @@ export const serviceTypeBasedColumns: ServiceColumns = { sortingKey: 'endpoint', }, ], + blockstorage: [ + { + accessor: ({ label }) => label, + label: 'Entity', + sortingKey: 'label', + }, + { + accessor: ({ region }) => region, + label: 'Region', + sortingKey: 'region', + }, + ], }; export const serviceToFiltersMap: Partial< @@ -125,6 +137,7 @@ export const serviceToFiltersMap: Partial< { component: AlertsRegionFilter, filterKey: 'region' }, { component: AlertsEndpointFilter, filterKey: 'endpoint' }, ], + blockstorage: [{ component: AlertsRegionFilter, filterKey: 'region' }], }; export const applicableAdditionalFilterKeys: AlertAdditionalFilterKey[] = [ 'engineType', // Extendable in future for filter keys like 'tags', 'plan', etc. diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx new file mode 100644 index 00000000000..013d6248fc7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx @@ -0,0 +1,320 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BlockStorageDimensionFilterAutocomplete } from './BlockStorageDimensionFilterAutocomplete'; + +import type { DimensionFilterAutocompleteProps } from './constants'; + +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn(), + useBlockStorageFetchOptions: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery.mockReturnValue({ data: [] }), +})); + +vi.mock('./useBlockStorageFetchOptions', () => ({ + ...vi.importActual('./useBlockStorageFetchOptions'), + useBlockStorageFetchOptions: queryMocks.useBlockStorageFetchOptions, +})); + +describe('', () => { + const defaultProps: DimensionFilterAutocompleteProps = { + name: 'dimension-filter', + dimensionLabel: 'linode_id', + disabled: false, + errorText: undefined, + fieldOnBlur: vi.fn(), + fieldOnChange: vi.fn(), + fieldValue: null, + multiple: false, + placeholderText: 'Select a Value', + entities: [], + scope: null, + selectedRegions: null, + serviceType: 'blockstorage', + type: 'alerts', + values: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with correct label and placeholder', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByLabelText(/Value/i)).toBeVisible(); + expect(screen.getByPlaceholderText('Select a Value')).toBeVisible(); + }); + + it('calls fieldOnBlur when input is blurred', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + renderWithTheme( + + ); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.tab(); + expect(defaultProps.fieldOnBlur).toHaveBeenCalled(); + }); + + it('disables the Autocomplete when disabled is true', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('renders error text from props', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByText('Custom error')).toBeVisible(); + }); + + it('renders API error text when isError is true', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: true, + }); + renderWithTheme( + + ); + expect( + await screen.findByText('Failed to fetch the values.') + ).toBeVisible(); + }); + + it('shows loading state when fetching values', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: true, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByTestId('circle-progress')).toBeVisible(); + }); + + it('calls fieldOnChange with correct value when selecting an option (single)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-1/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1'); + }); + + it('calls fieldOnChange with multiple values when multiple=true', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Select first option + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-1/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1'); + // Rerender with updated form state + rerender( + + ); + // Select second option + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-2/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + + it('does not call fieldOnChange when typing with no options', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + const user = userEvent.setup(); + await user.type(screen.getByRole('combobox'), 'test'); + expect(defaultProps.fieldOnChange).not.toHaveBeenCalled(); + }); + + it('cleans up invalid single value (string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Simulate update to trigger effect + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); + + it('cleans up invalid multi value (comma-separated string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + }); + + it('cleans up all invalid multi values (comma-separated string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(''); + }); + }); +}); \ No newline at end of file diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx new file mode 100644 index 00000000000..0d48cae694d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx @@ -0,0 +1,78 @@ +import { useRegionsQuery } from '@linode/queries'; +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import { useBlockStorageFetchOptions } from './useBlockStorageFetchOptions'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; +import { handleValueChange, resolveSelectedValues } from './utils'; + +import type { DimensionFilterAutocompleteProps } from './constants'; + +export const BlockStorageDimensionFilterAutocomplete = ( + props: DimensionFilterAutocompleteProps +) => { + const { + dimensionLabel, + multiple, + name, + fieldOnChange, + disabled, + fieldOnBlur, + placeholderText, + errorText, + entities, + fieldValue, + scope, + selectedRegions, + serviceType, + type, + } = props; + + const { data: regions } = useRegionsQuery(); + const { values, isLoading, isError } = useBlockStorageFetchOptions({ + entities, + dimensionLabel, + regions, + type, + scope, + selectedRegions, + serviceType, + }); + + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + + return ( + value.value === option.value} + label="Value" + limitTags={1} + loading={!disabled && isLoading && !isError} + multiple={multiple} + onBlur={fieldOnBlur} + onChange={(_, selected, operation) => { + const newValue = handleValueChange( + selected, + operation, + multiple ?? false + ); + fieldOnChange(newValue); + }} + options={values} + placeholder={placeholderText} + sx={{ flex: 1 }} + value={resolveSelectedValues(values, fieldValue, multiple ?? false)} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx index 9be5bb91063..e840c6f8e97 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -13,6 +13,14 @@ import type { } from '@linode/api-v4'; // Mock child components +vi.mock('./BlockStorageDimensionFilterAutocomplete', () => ({ + BlockStorageDimensionFilterAutocomplete: (props: any) => ( +
+ BlockStorage Autocomplete +
+ ), +})); + vi.mock('./FirewallDimensionFilterAutocomplete', () => ({ FirewallDimensionFilterAutocomplete: (props: any) => (
@@ -40,7 +48,9 @@ vi.mock('./DimensionFilterAutocomplete', () => ({ const EQ: DimensionFilterOperatorType = 'eq'; const IN: DimensionFilterOperatorType = 'in'; const NB: CloudPulseServiceType = 'nodebalancer'; - +const CF: CloudPulseServiceType = 'firewall'; +const OS: CloudPulseServiceType = 'objectstorage'; +const BS: CloudPulseServiceType = 'blockstorage'; describe('', () => { const defaultProps = { serviceType: NB, @@ -85,6 +95,7 @@ describe('', () => { ...defaultProps, dimensionLabel: 'linode_id', // assume this is configured with useCustomFetch: 'firewall' operator: IN, + serviceType: CF, }; renderWithTheme(); @@ -96,11 +107,23 @@ describe('', () => { ...defaultProps, dimensionLabel: 'endpoint', // assume this is configured with useCustomFetch: 'objectstorage' operator: IN, + serviceType: OS, }; renderWithTheme(); expect(screen.getByTestId('objectstorage-autocomplete')).toBeVisible(); }); + it('renders BlockStorageDimensionFilter if config.useCustomFetch = blockstorage', () => { + const props = { + ...defaultProps, + serviceType: BS, + dimensionLabel: 'linode_id', // assume this is configured with useCustomFetch: 'blockstorage' + operator: IN, + }; + + renderWithTheme(); + expect(screen.getByTestId('blockstorage-autocomplete')).toBeVisible(); + }); it('calls onChange when typing into TextField', async () => { const user = userEvent.setup(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index 2ac7691b057..5476e710207 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -1,6 +1,7 @@ import { TextField } from '@linode/ui'; import React from 'react'; +import { BlockStorageDimensionFilterAutocomplete } from './BlockStorageDimensionFilterAutocomplete'; import { MULTISELECT_PLACEHOLDER_TEXT, SINGLESELECT_PLACEHOLDER_TEXT, @@ -145,7 +146,35 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { ? MULTISELECT_PLACEHOLDER_TEXT : SINGLESELECT_PLACEHOLDER_TEXT; - switch (config.useCustomFetch) { + // Determine custom fetch behaviour if there are same dimension_labels across service types + const customFetch = Array.isArray(config.useCustomFetch) + ? config.useCustomFetch.includes(serviceType ?? '') + ? serviceType + : undefined + : config.useCustomFetch === serviceType + ? serviceType + : undefined; + + switch (customFetch) { + case 'blockstorage': + return ( + + ); case 'firewall': return ( id + )) || + []; + + // Create a filter for regions based on supported region IDs + const regionFilter: Filter = { + '+or': + supportedRegionIds && supportedRegionIds.length > 0 + ? supportedRegionIds.map((regionId) => ({ + region: regionId, + })) + : undefined, + }; + + const regionFilteredBuckets = getOfflineRegionFilteredResources( + blockStorageResources ?? [], + supportedRegionIds + ); + + const filteredResources = scopeBasedFilteredResources({ + scope: scope ?? null, + resources: regionFilteredBuckets, + entities, + selectedRegions, + }); + + const filteredBlockStorageParentEntityIds = filteredResources?.map( + ({ volumeLinodeId }) => volumeLinodeId + ); + + const idFilter: Filter = { + '+or': filteredBlockStorageParentEntityIds.length + ? filteredBlockStorageParentEntityIds.map((id) => ({ id })) + : undefined, + }; + + const combinedFilter: Filter = { + '+and': [regionFilter, idFilter].filter(Boolean), + }; + + const { + data: linodes, + isError: isLinodesError, + isLoading: isLinodesLoading, + } = useAllLinodesQuery( + {}, + combinedFilter, + serviceType === 'blockstorage' && + dimensionLabel === 'linode_id' && + filteredBlockStorageParentEntityIds.length > 0 && + supportedRegionIds.length > 0 + ); + + const blockStorageLinodes = useMemo( + () => getBlockStorageLinodes(linodes ?? []), + [linodes] + ); + + return { + values: blockStorageLinodes, + isLoading: isLinodesLoading || isBlockStorageLoading, + isError: isBlockStorageError || isLinodesError, + }; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts index e842db0468d..64d2371e15e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts @@ -51,7 +51,7 @@ export function useFirewallFetchOptions( ? supportedRegionIds.map((regionId) => ({ region: regionId, })) - : [{ region: '' }], + : undefined, }; const filterLabels: string[] = [ diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts index d709aca65bd..7a651b4d33a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts @@ -5,7 +5,7 @@ import { getOfflineRegionFilteredResources, } from '../../../Utils/AlertResourceUtils'; import { filterRegionByServiceType } from '../../../Utils/utils'; -import { scopeBasedFilteredBuckets } from './utils'; +import { scopeBasedFilteredResources } from './utils'; import type { FetchOptions, FetchOptionsProps } from './constants'; /** @@ -40,9 +40,9 @@ export function useObjectStorageFetchOptions( ); // Filtering the buckets based on the scope - const filteredBuckets = scopeBasedFilteredBuckets({ + const filteredBuckets = scopeBasedFilteredResources({ scope: scope ?? null, - buckets: regionFilteredBuckets, + resources: regionFilteredBuckets, entities, selectedRegions, }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts index 099c3bcd7d8..80c6f9787a8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -10,7 +10,7 @@ import { getStaticOptions, handleValueChange, resolveSelectedValues, - scopeBasedFilteredBuckets, + scopeBasedFilteredResources, } from './utils'; import type { Linode } from '@linode/api-v4'; @@ -243,17 +243,17 @@ describe('Utils', () => { ]; it('returns all buckets for account scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'account', - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); it('filters buckets by entity IDs for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, entities: ['bucket-1', 'bucket-3'], }); expect(result).toEqual([ @@ -263,26 +263,26 @@ describe('Utils', () => { }); it('returns empty array if no entities match for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, entities: ['bucket-99'], }); expect(result).toEqual([]); }); it('returns empty array if entities is undefined for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, }); expect(result).toEqual([]); }); it('filters buckets by region IDs for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, selectedRegions: ['us-east', 'eu-central'], }); expect(result).toEqual([ @@ -292,34 +292,34 @@ describe('Utils', () => { }); it('returns empty array if no regions match for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, selectedRegions: ['ap-south'], }); expect(result).toEqual([]); }); it('returns empty array if selectedRegions is undefined for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, }); expect(result).toEqual([]); }); it('returns all buckets for null scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: null, - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); it('returns all buckets for unrecognized scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: null, - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index 5aee1f0f177..ab8be225fab 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -180,15 +180,15 @@ export const getVPCSubnets = (vpcs: VPC[]): Item[] => { ); }; -interface ScopeBasedFilteredBucketsProps { +interface ScopeBasedFilteredResourcesProps { /** - * The full list of available CloudPulse resources (buckets). + * A list of entity IDs to filter by when scope is `entity`. */ - buckets: CloudPulseResources[]; + entities?: string[]; /** - * A list of entity IDs (bucket IDs) to filter by when scope is `entity`. + * The full list of available CloudPulse resources. */ - entities?: string[]; + resources: CloudPulseResources[]; /** * The scope of the alert definition (`account`, `entity`, `region`, or `null`). */ @@ -199,31 +199,45 @@ interface ScopeBasedFilteredBucketsProps { selectedRegions?: null | string[]; } -/** - * Filters a list of Object Storage buckets based on the given alert definition scope. +/* Filters a list of Resource objects based on the given alert definition scope. * * @param props - Object containing filter parameters. - * @returns A filtered list of buckets based on the provided scope. + * @returns A filtered list of resources based on the provided scope. */ -export const scopeBasedFilteredBuckets = ( - props: ScopeBasedFilteredBucketsProps +export const scopeBasedFilteredResources = ( + props: ScopeBasedFilteredResourcesProps ): CloudPulseResources[] => { - const { scope, buckets, selectedRegions, entities } = props; + const { scope, resources, selectedRegions, entities } = props; switch (scope) { case 'account': - return buckets; + return resources; case 'entity': return entities - ? buckets.filter((bucket) => entities.includes(bucket.id)) + ? resources.filter((resource) => entities.includes(resource.id)) : []; case 'region': return selectedRegions - ? buckets.filter((bucket) => - selectedRegions.includes(bucket.region ?? '') + ? resources.filter((resource) => + selectedRegions.includes(resource.region ?? '') ) : []; default: - return buckets; + return resources; } }; + +/** + * Extracts linode items from firewall resources by merging entities. + * @param resources - List of firewall resources with entity mappings. + * @returns - Flattened list of linode ID/label pairs as options. + */ +export const getBlockStorageLinodes = ( + linodes: Linode[] +): Item[] => { + if (!linodes) return []; + return linodes.map((linode) => ({ + label: transformDimensionValue('blockstorage', 'linode_id', linode.label), + value: String(linode.id), + })); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index ed1950ee874..a6b234c03cc 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -23,6 +23,7 @@ export interface CloudPulseResources { label: string; region?: string; tags?: string[]; + volumeLinodeId?: string; } export interface CloudPulseResourcesSelectProps { diff --git a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts index c7ccdae7870..d90b7c1d669 100644 --- a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts +++ b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts @@ -38,4 +38,7 @@ export const DIMENSION_TRANSFORM_CONFIG: Partial< objectstorage: { endpoint: TRANSFORMS.original, }, + blockstorage: { + linode_id: TRANSFORMS.original, + }, }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 14d3237a9b9..6d6717e1e60 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -41,6 +41,8 @@ import { alertFactory, alertRulesFactory, appTokenFactory, + blockStorageMetricCriteria, + blockStorageMetricRules, contactFactory, credentialFactory, creditPaymentResponseFactory, @@ -1680,7 +1682,22 @@ export const handlers = [ 'resizing', ]; const volumes = statuses.map((status) => - volumeFactory.build({ status, region: 'ap-west' }) + volumeFactory.build({ status, region: 'ap-west', linode_id: 1 }) + ); + volumes.push( + ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 2 }) + ); + volumes.push( + ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 3 }) + ); + volumes.push( + ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 4 }) + ); + volumes.push( + ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 5 }) + ); + volumes.push( + ...volumeFactory.buildList(5, { region: 'eu-central', linode_id: 1 }) ); return HttpResponse.json(makeResourcePage(volumes)); }), @@ -3016,6 +3033,16 @@ export const handlers = [ service_type: 'objectstorage', entity_ids: ['obj-bucket-804.ap-west.linodeobjects.com'], }), + alertFactory.build({ + id: 300, + type: 'user', + label: 'block-storage - testing', + service_type: 'blockstorage', + entity_ids: ['1', '2', '4', '3', '5', '6', '7', '8', '9', '10'], + rule_criteria: { + rules: [blockStorageMetricCriteria.build()], + }, + }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), @@ -3053,6 +3080,20 @@ export const handlers = [ }) ); } + if (params.id === '300' && params.serviceType === 'blockstorage') { + return HttpResponse.json( + alertFactory.build({ + id: 300, + type: 'user', + label: 'block-storage - testing', + service_type: 'blockstorage', + entity_ids: ['1', '2', '4', '3', '5', '6', '7', '8', '9', '10'], + rule_criteria: { + rules: [blockStorageMetricCriteria.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -3105,6 +3146,20 @@ export const handlers = [ }) ); } + if (params.id === '300' && params.serviceType === 'blockstorage') { + return HttpResponse.json( + alertFactory.build({ + id: 300, + type: 'user', + label: 'block-storage - testing', + service_type: 'blockstorage', + entity_ids: ['1', '2', '4', '3', '5', '6', '7', '8', '9', '10'], + rule_criteria: { + rules: [blockStorageMetricCriteria.build()], + }, + }) + ); + } const body: any = request.json(); return HttpResponse.json( alertFactory.build({ @@ -3190,7 +3245,7 @@ export const handlers = [ evaluation_period_seconds: [300], polling_interval_seconds: [300], scope: - serviceType === 'objectstorage' + serviceType === 'objectstorage' || serviceType === 'blockstorage' ? ['entity', 'account', 'region'] : ['entity'], }), @@ -3573,6 +3628,9 @@ export const handlers = [ if (params.serviceType === 'objectstorage') { return HttpResponse.json({ data: objectStorageMetricRules }); } + if (params.serviceType === 'blockstorage') { + return HttpResponse.json({ data: blockStorageMetricRules }); + } return HttpResponse.json(response); } ), diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 59641f942b5..405b5ea8820 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -62,6 +62,7 @@ export const useResourcesQuery = ( endpoint: resource.s3_endpoint, entities, clusterSize: resource.cluster_size, + volumeLinodeId: String(resource.linode_id), }; }); }, diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts index 13bb19985e6..1a01eeb6866 100644 --- a/packages/utilities/src/__data__/regionsData.ts +++ b/packages/utilities/src/__data__/regionsData.ts @@ -29,7 +29,7 @@ export const regions: Region[] = [ site_type: 'core', status: 'ok', monitors: { - alerts: ['Cloud Firewall', 'Object Storage'], + alerts: ['Cloud Firewall', 'Object Storage', 'Block Storage'], metrics: [ 'Object Storage', 'Cloud Firewall', @@ -663,7 +663,10 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Linodes'], metrics: ['NodeBalancers'] }, + monitors: { + alerts: ['Linodes', 'Block Storage'], + metrics: ['NodeBalancers'], + }, }, { capabilities: [ From c818e77e17ebe38fd33d56acfaf8fec3b6c1a86c Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:34:53 +0530 Subject: [PATCH 2/3] Addressing review comments --- .../ValueFieldRenderer.test.tsx | 15 +++-- .../ValueFieldRenderer.tsx | 63 ++++++------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx index e840c6f8e97..d632857c973 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -7,6 +7,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ValueFieldRenderer } from './ValueFieldRenderer'; +import type { DimensionFilterAutocompleteProps } from './constants'; import type { CloudPulseServiceType, DimensionFilterOperatorType, @@ -14,7 +15,9 @@ import type { // Mock child components vi.mock('./BlockStorageDimensionFilterAutocomplete', () => ({ - BlockStorageDimensionFilterAutocomplete: (props: any) => ( + BlockStorageDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => (
BlockStorage Autocomplete
@@ -22,7 +25,9 @@ vi.mock('./BlockStorageDimensionFilterAutocomplete', () => ({ })); vi.mock('./FirewallDimensionFilterAutocomplete', () => ({ - FirewallDimensionFilterAutocomplete: (props: any) => ( + FirewallDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => (
Firewall Autocomplete
@@ -30,7 +35,9 @@ vi.mock('./FirewallDimensionFilterAutocomplete', () => ({ })); vi.mock('./ObjectStorageDimensionFilterAutocomplete', () => ({ - ObjectStorageDimensionFilterAutocomplete: (props: any) => ( + ObjectStorageDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => (
ObjectStorage Autocomplete
@@ -38,7 +45,7 @@ vi.mock('./ObjectStorageDimensionFilterAutocomplete', () => ({ })); vi.mock('./DimensionFilterAutocomplete', () => ({ - DimensionFilterAutocomplete: (props: any) => ( + DimensionFilterAutocomplete: (props: DimensionFilterAutocompleteProps) => (
DimensionFilter Autocomplete
diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index 5476e710207..3bb1ce9d033 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -146,6 +146,21 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { ? MULTISELECT_PLACEHOLDER_TEXT : SINGLESELECT_PLACEHOLDER_TEXT; + // Common props shared across all autocomplete components + const commonAutocompleteProps = { + dimensionLabel, + disabled, + errorText, + fieldOnBlur: onBlur, + fieldOnChange: onChange, + fieldValue: value, + multiple: config.multiple, + name, + placeholderText: config.placeholder ?? autocompletePlaceholder, + serviceType: serviceType ?? null, + type, + }; + // Determine custom fetch behaviour if there are same dimension_labels across service types const customFetch = Array.isArray(config.useCustomFetch) ? config.useCustomFetch.includes(serviceType ?? '') @@ -159,73 +174,33 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { case 'blockstorage': return ( ); case 'firewall': return ( ); case 'objectstorage': return ( ); default: return ( ); From 2a1282a4ae0af4238c9cf3f4fd87a8d725d3c551 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:47:25 +0530 Subject: [PATCH 3/3] add changeset --- .../.changeset/pr-13048-upcoming-features-1762334225681.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13048-upcoming-features-1762334225681.md diff --git a/packages/manager/.changeset/pr-13048-upcoming-features-1762334225681.md b/packages/manager/.changeset/pr-13048-upcoming-features-1762334225681.md new file mode 100644 index 00000000000..78dd28d0530 --- /dev/null +++ b/packages/manager/.changeset/pr-13048-upcoming-features-1762334225681.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Alerting: Onboarding Blockstorage service for ACLP Alerts ([#13048](https://github.com/linode/manager/pull/13048))