diff --git a/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md b/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md new file mode 100644 index 00000000000..3e22f0af003 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse-Metrics: Update `entity_ids` type in `CloudPulseMetricsRequest` for metrics api in endpoints dahsboard ([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 87e1d9ed382..07dca57b539 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -159,7 +159,7 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; associated_entity_region?: string; - entity_ids: number[] | string[]; + entity_ids: number[] | string[] | undefined; entity_region?: string; filters?: Filters[]; group_by?: string[]; diff --git a/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md b/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md new file mode 100644 index 00000000000..3f2f8c5bc68 --- /dev/null +++ b/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Update `FilterConfig.ts` to handle integration of endpoints dashboard for object-storage service in metrics page([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 876b4cc17db..36974ed6071 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -150,7 +150,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { } = useCloudPulseJWEtokenQuery( dashboard?.service_type, getJweTokenPayload(), - Boolean(resources) && !isDashboardLoading && !isDashboardApiError + !isDashboardLoading && !isDashboardApiError ); if (isDashboardApiError) { diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts index e880eaf6d17..adfee38ea85 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts @@ -87,7 +87,7 @@ describe('useGlobalDimensions method test', () => { it('should return non-empty options and defaultValue if no common dimensions', () => { queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: dashboardFactory.build(), + data: dashboardFactory.build({ id: 1 }), isLoading: false, }); queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ @@ -106,7 +106,7 @@ describe('useGlobalDimensions method test', () => { it('should return non-empty options and defaultValue from preferences', () => { queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: dashboardFactory.build(), + data: dashboardFactory.build({ id: 1 }), isLoading: false, }); queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ @@ -123,6 +123,22 @@ describe('useGlobalDimensions method test', () => { isLoading: false, }); }); + + it('should not return default option in case of endpoints-only dashboard', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: dashboardFactory.build({ id: 10 }), + isLoading: false, + }); + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: { + data: metricDefinitions, + }, + isLoading: false, + }); + const result = useGlobalDimensions(10, 'objectstorage'); + // Verify if options contain the default option - 'entityId' or not + expect(result.options).toEqual([{ label: 'Dim 2', value: 'Dim 2' }]); + }); }); describe('useWidgetDimension method test', () => { diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts index 7444f8bc916..6281b6a70ca 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -2,7 +2,10 @@ import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboar import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; import { ASSOCIATED_ENTITY_METRIC_MAP } from '../Utils/constants'; -import { getAssociatedEntityType } from '../Utils/FilterConfig'; +import { + getAssociatedEntityType, + isEndpointsOnlyDashboard, +} from '../Utils/FilterConfig'; import type { GroupByOption } from './CloudPulseGroupByDrawer'; import type { @@ -62,10 +65,12 @@ export const useGlobalDimensions = ( metricDefinition?.data ?? [], dashboard ); - const commonDimensions = [ - defaultOption, - ...getCommonDimensions(metricDimensions), - ]; + const baseDimensions = getCommonDimensions(metricDimensions); + const shouldIncludeDefault = !isEndpointsOnlyDashboard(dashboardId ?? 0); + + const commonDimensions = shouldIncludeDefault + ? [defaultOption, ...baseDimensions] + : baseDimensions; const commonGroups = getCommonGroups( preference ? preference : (dashboard?.group_by ?? []), diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index 7caed886877..c94d37f5d19 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -328,7 +328,7 @@ describe('getTimeDurationFromPreset method', () => { expect(result).toEqual([123]); }); - it('should return entity ids for objectstorage service type', () => { + it('should return entity ids for objectstorage buckets dashboard', () => { const result = getEntityIds( [{ id: 'bucket-1', label: 'bucket-name-1' }], ['bucket-1'], diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 786cc2906ea..fb0816b6c8d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -354,7 +354,9 @@ export const getCloudPulseMetricRequest = ( presetDuration === undefined ? { end: duration.end, start: duration.start } : undefined, - entity_ids: getEntityIds(resources, entityIds, widget, serviceType), + entity_ids: !entityIds.length + ? undefined + : getEntityIds(resources, entityIds, widget, serviceType), filters: undefined, group_by: !groupBy?.length ? undefined : groupBy, relative_time_duration: presetDuration, diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 5cc58111f72..6a6ddece891 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -34,7 +34,6 @@ import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import { deepEqual } from './utils'; -import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { CloudPulseServiceTypeFilters } from './models'; @@ -686,13 +685,15 @@ describe('filterUsingDependentFilters', () => { }); describe('filterEndpointsUsingRegion', () => { - const mockData: CloudPulseEndpoints[] = [ + const mockData: CloudPulseResources[] = [ { ...objectStorageEndpointsFactory.build({ region: 'us-east' }), + id: 'us-east-1.linodeobjects.com', label: 'us-east-1.linodeobjects.com', }, { ...objectStorageEndpointsFactory.build({ region: 'us-west' }), + id: 'us-west-1.linodeobjects.com', label: 'us-west-1.linodeobjects.com', }, ]; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 97fed2e4b40..824c31a199a 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -15,7 +15,6 @@ import type { } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect'; -import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseFirewallNodebalancersSelectProps, CloudPulseNodebalancers, @@ -404,12 +403,14 @@ export const getEndpointsProperties = ( preferences ), handleEndpointsSelection: handleEndpointsChange, + dashboardId: dashboard.id, label, placeholder, serviceType: dashboard.service_type, region: dependentFilters?.[REGION], savePreferences: !isServiceAnalyticsIntegration, xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + hasRestrictedSelections: config.configuration.hasRestrictedSelections, }; }; @@ -733,9 +734,9 @@ export const filterUsingDependentFilters = ( * @returns The filtered endpoints */ export const filterEndpointsUsingRegion = ( - data?: CloudPulseEndpoints[], + data?: CloudPulseResources[], regionFilter?: CloudPulseMetricsFilter -): CloudPulseEndpoints[] | undefined => { +): CloudPulseResources[] | undefined => { if (!data) { return data; } diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts index 1075a85fd5b..606d68011b4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts @@ -1,6 +1,7 @@ import { getAssociatedEntityType, getResourcesFilterConfig, + isEndpointsOnlyDashboard, } from './FilterConfig'; describe('getResourcesFilterConfig', () => { @@ -34,3 +35,14 @@ describe('getAssociatedEntityType', () => { expect(getAssociatedEntityType(8)).toBe('nodebalancer'); }); }); + +describe('isEndpointsOnlyDashboard', () => { + it('should return true when the dashboard is an endpoints only dashboard', () => { + // Dashboard ID 10 is an endpoints only dashboard + expect(isEndpointsOnlyDashboard(10)).toBe(true); + }); + it('should return false when the dashboard is not an endpoints only dashboard', () => { + // Dashboard ID 6 is not an endpoints only dashboard, rather a buckets dashboard + expect(isEndpointsOnlyDashboard(6)).toBe(false); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 1d4c19a56e0..0f6debbc6bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -11,12 +11,12 @@ import { RESOURCE_ID, } from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import { filterKubernetesClusters } from './utils'; +import { filterKubernetesClusters, getValidSortedEndpoints } from './utils'; import type { AssociatedEntityType } from '../shared/types'; import type { CloudPulseServiceTypeFiltersConfiguration } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; -import type { KubernetesCluster } from '@linode/api-v4'; +import type { KubernetesCluster, ObjectStorageBucket } from '@linode/api-v4'; const TIME_DURATION = 'Time Range'; @@ -420,6 +420,8 @@ export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly + getValidSortedEndpoints(resources), }, name: 'Endpoints', }, @@ -442,6 +444,45 @@ export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly = + { + capability: capabilityServiceTypeMapping['objectstorage'], + filters: [ + { + configuration: { + filterKey: REGION, + children: [ENDPOINT], + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + name: 'Region', + priority: 1, + neededInViews: [CloudPulseAvailableViews.central], + }, + name: 'Region', + }, + { + configuration: { + dimensionKey: 'endpoint', + dependency: [REGION], + filterKey: ENDPOINT, + filterType: 'string', + isFilterable: true, + isMetricsFilter: false, + isMultiSelect: true, + hasRestrictedSelections: true, + name: 'Endpoints', + priority: 2, + neededInViews: [CloudPulseAvailableViews.central], + filterFn: (resources: ObjectStorageBucket[]) => + getValidSortedEndpoints(resources), + }, + name: 'Endpoints', + }, + ], + serviceType: 'objectstorage', + }; + export const BLOCKSTORAGE_CONFIG: Readonly = { capability: capabilityServiceTypeMapping['blockstorage'], filters: [ @@ -522,6 +563,7 @@ export const FILTER_CONFIG: Readonly< [7, BLOCKSTORAGE_CONFIG], [8, FIREWALL_NODEBALANCER_CONFIG], [9, LKE_CONFIG], + [10, ENDPOINT_DASHBOARD_CONFIG], ]); /** @@ -534,8 +576,13 @@ export const getResourcesFilterConfig = ( if (!dashboardId) { return undefined; } - // Get the associated entity type for the dashboard + // Get the resources filter configuration for the dashboard const filterConfig = FILTER_CONFIG.get(dashboardId); + if (isEndpointsOnlyDashboard(dashboardId)) { + return filterConfig?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + )?.configuration; + } return filterConfig?.filters.find( (filter) => filter.configuration.filterKey === RESOURCE_ID )?.configuration; @@ -553,3 +600,23 @@ export const getAssociatedEntityType = ( } return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; }; + +/** + * @param dashboardId id of the dashboard + * @returns whether dashboard is an endpoints only dashboard + */ +export const isEndpointsOnlyDashboard = (dashboardId: number): boolean => { + const filterConfig = FILTER_CONFIG.get(dashboardId); + if (!filterConfig) { + return false; + } + const endpointsFilter = filterConfig?.filters.find( + (filter) => filter.name === 'Endpoints' + ); + if (endpointsFilter) { + // Verify if the dashboard has buckets filter, if not then it is an endpoints only dashboard + return !filterConfig.filters.some((filter) => filter.name === 'Buckets'); + } + + return false; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts index 1f68344cf67..caff614be54 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -133,6 +133,18 @@ it('test constructDimensionFilters method', () => { expect(result[0].filterValue).toEqual('primary'); }); +it('test constructDimensionFilters method for endpoints only dashboard', () => { + const result = constructDimensionFilters({ + dashboardObj: { ...mockDashboard, id: 10, service_type: 'objectstorage' }, + filterValue: {}, + resource: 'us-east-1.linodeobjects.com', + groupBy: [], + }); + expect(result.length).toEqual(1); + expect(result[0].filterKey).toEqual('endpoint'); + expect(result[0].filterValue).toEqual(['us-east-1.linodeobjects.com']); +}); + it('test checkIfFilterNeededInMetricsCall method', () => { let result = checkIfFilterNeededInMetricsCall('region', 2); expect(result).toEqual(false); diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index edce614f656..b50354b1c52 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -1,5 +1,6 @@ import { defaultTimeDuration } from './CloudPulseDateTimePickerUtils'; -import { FILTER_CONFIG } from './FilterConfig'; +import { ENDPOINT } from './constants'; +import { FILTER_CONFIG, isEndpointsOnlyDashboard } from './FilterConfig'; import { CloudPulseAvailableViews } from './models'; import type { DashboardProperties } from '../Dashboard/CloudPulseDashboard'; @@ -55,7 +56,9 @@ export const getDashboardProperties = ( }), dashboardId: dashboardObj.id, duration: timeDuration ?? defaultTimeDuration(), - resources: [String(resource)], + resources: isEndpointsOnlyDashboard(dashboardObj.id) + ? [] + : [String(resource)], serviceType: dashboardObj.service_type, savePref: false, groupBy, @@ -148,13 +151,20 @@ export const checkIfFilterNeededInMetricsCall = ( export const constructDimensionFilters = ( props: ReusableDashboardFilterUtilProps ): CloudPulseMetricsAdditionalFilters[] => { - const { dashboardObj, filterValue } = props; - return Object.keys(filterValue) + const { dashboardObj, filterValue, resource } = props; + const filters = Object.keys(filterValue) .filter((key) => checkIfFilterNeededInMetricsCall(key, dashboardObj.id)) .map((key) => ({ filterKey: key, filterValue: filterValue[key], })); + if (isEndpointsOnlyDashboard(dashboardObj.id)) { + filters.push({ + filterKey: ENDPOINT, + filterValue: [String(resource)], + }); + } + return filters; }; /** diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index a4ef0497eb6..248210d1f9c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -1,3 +1,4 @@ +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { AssociatedEntityType } from '../shared/types'; import type { Capabilities, @@ -56,6 +57,7 @@ export interface CloudPulseServiceTypeFilters { * As of now, the list of possible custom filters are engine, database type, this union type will be expanded if we start enhancing our custom select config */ export type QueryFunctionType = + | CloudPulseResources[] | DatabaseEngine[] | DatabaseInstance[] | DatabaseType[] @@ -144,6 +146,11 @@ export interface CloudPulseServiceTypeFiltersConfiguration { */ filterType: string; + /** + * If this is true, we will only allow users to select a certain threshold + */ + hasRestrictedSelections?: boolean; + /** * If this is true, we will pass the filter in the metrics api otherwise, we don't */ @@ -157,7 +164,6 @@ export interface CloudPulseServiceTypeFiltersConfiguration { * If this is true, multiselect will be enabled for the filter, only applicable for static and dynamic, not for predefined ones */ isMultiSelect?: boolean; - /** * If this is true, we will pass filter as an optional filter */ @@ -167,6 +173,7 @@ export interface CloudPulseServiceTypeFiltersConfiguration { * If this is true, we will only allow users to select a certain threshold, only applicable for static and dynamic, not for predefined ones */ maxSelections?: number; + /** * The name of the filter */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index dab6f0bea3f..c64562946a9 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -1,7 +1,11 @@ import { regionFactory } from '@linode/utilities'; import { describe, expect, it } from 'vitest'; -import { kubernetesClusterFactory, serviceTypesFactory } from 'src/factories'; +import { + kubernetesClusterFactory, + objectStorageBucketFactoryGen2, + serviceTypesFactory, +} from 'src/factories'; import { firewallEntityfactory, firewallFactory, @@ -29,6 +33,7 @@ import { filterKubernetesClusters, getEnabledServiceTypes, getFilteredDimensions, + getValidSortedEndpoints, isValidFilter, isValidPort, useIsAclpSupportedRegion, @@ -709,3 +714,38 @@ describe('arraysEqual', () => { expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true); }); }); + +describe('getValidSortedEndpoints', () => { + it('should return an empty array when buckets are undefined', () => { + expect(getValidSortedEndpoints(undefined)).toEqual([]); + }); + it('should return the valid and unique sorted endpoints', () => { + const buckets = [ + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'a', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'b', + region: undefined, + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'c', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'c', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: undefined, + region: 'us-east', + }), + ]; + // Only a and c are valid, so they are sorted and returned + expect(getValidSortedEndpoints(buckets)).toEqual([ + { id: 'a', label: 'a', region: 'us-east' }, + { id: 'c', label: 'c', region: 'us-east' }, + ]); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 5978318658b..974a59bc158 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -23,6 +23,7 @@ import { } from './constants'; import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { AssociatedEntityType } from '../shared/types'; import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; import type { @@ -37,6 +38,7 @@ import type { FirewallDeviceEntity, KubernetesCluster, MonitoringCapabilities, + ObjectStorageBucket, ResourcePage, Service, ServiceTypesList, @@ -567,6 +569,29 @@ export const filterKubernetesClusters = ( .sort((a, b) => a.label.localeCompare(b.label)); }; +/** + * @param buckets The list of buckets + * @returns The valid sorted endpoints + */ +export const getValidSortedEndpoints = ( + buckets: ObjectStorageBucket[] | undefined +): CloudPulseResources[] => { + if (!buckets) return []; + + const visitedEndpoints = new Set(); + const uniqueEndpoints: CloudPulseResources[] = []; + + buckets.forEach(({ s3_endpoint: s3Endpoint, region }) => { + if (s3Endpoint && region && !visitedEndpoints.has(s3Endpoint)) { + visitedEndpoints.add(s3Endpoint); + uniqueEndpoints.push({ id: s3Endpoint, label: s3Endpoint, region }); + } + }); + + uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); + return uniqueEndpoints; +}; + /** * @param obj1 The first object to be compared * @param obj2 The second object to be compared diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 1941420d04b..0a3d1d7725a 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -142,8 +142,6 @@ export const RenderWidgets = React.memo( if ( !dashboard.service_type || - // eslint-disable-next-line sonarjs/no-inverted-boolean-check - !(resources.length > 0) || (!isJweTokenFetching && !jweToken?.token) || !resourceList?.length ) { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 556f1c5ad1a..f1b391996b1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -254,6 +254,7 @@ export const CloudPulseCustomSelect = React.memo( errorText={staticErrorText} isOptionEqualToValue={(option, value) => option.label === value.label} label={label || 'Select a Value'} + loading={isLoading} multiple={isMultiSelect} noMarginTop onChange={handleChange} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx index e38acc948bd..238f2c3c0f5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx @@ -2,7 +2,6 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { objectStorageBucketFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; @@ -24,37 +23,53 @@ vi.mock('src/queries/cloudpulse/resources', async () => { const mockEndpointHandler = vi.fn(); const SELECT_ALL = 'Select All'; const ARIA_SELECTED = 'aria-selected'; +const ARIA_DISABLED = 'aria-disabled'; -const mockBuckets: CloudPulseResources[] = [ +const mockEndpoints: CloudPulseResources[] = [ { - id: 'obj-bucket-1.us-east-1.linodeobjects.com', - label: 'obj-bucket-1.us-east-1.linodeobjects.com', + id: 'us-east-1.linodeobjects.com', + label: 'us-east-1.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-1.linodeobjects.com', }, { - id: 'obj-bucket-2.us-east-2.linodeobjects.com', - label: 'obj-bucket-2.us-east-2.linodeobjects.com', + id: 'us-east-2.linodeobjects.com', + label: 'us-east-2.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-2.linodeobjects.com', }, { - id: 'obj-bucket-1.br-gru-1.linodeobjects.com', - label: 'obj-bucket-1.br-gru-1.linodeobjects.com', + id: 'br-gru-1.linodeobjects.com', + label: 'br-gru-1.linodeobjects.com', region: 'us-east', - endpoint: 'br-gru-1.linodeobjects.com', }, ]; +const exceedingmockEndpoints: CloudPulseResources[] = Array.from( + { length: 8 }, + (_, i) => { + const idx = i + 1; + return { + id: `us-east-bucket-${idx}.com`, + label: `us-east-bucket-${idx}.com`, + region: 'us-east', + }; + } +); + describe('CloudPulseEndpointsSelect component tests', () => { beforeEach(() => { vi.clearAllMocks(); - objectStorageBucketFactory.resetSequenceNumber(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); }); it('renders with the correct label and placeholder', () => { renderWithTheme( { it('should render disabled component if the props are undefined', () => { renderWithTheme( { }); it('should render endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toBeVisible(); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toBeVisible(); }); it('should be able to deselect the selected endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { // Check that both endpoints are deselected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); }); it('should select multiple endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { await userEvent.click(await screen.findByRole('button', { name: 'Open' })); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ); // Check that the correct endpoints are selected/not selected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[2].endpoint, + name: mockEndpoints[2].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( @@ -213,6 +211,7 @@ describe('CloudPulseEndpointsSelect component tests', () => { renderWithTheme( { }) ).toBeVisible(); }); + + it('should handle endpoints selection limits correctly', async () => { + const user = userEvent.setup(); + + const allmockEndpoints = [...mockEndpoints, ...exceedingmockEndpoints]; + + queryMocks.useResourcesQuery.mockReturnValue({ + data: allmockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); + + const { queryByRole } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText('Select up to 10 Endpoints')).toBeVisible(); + + // Select the first 10 endpoints + for (let i = 0; i < 10; i++) { + const option = await screen.findByRole('option', { + name: allmockEndpoints[i].id, + }); + await user.click(option); + } + + // Check if we have 10 selected endpoints + const selectedOptions = screen + .getAllByRole('option') + .filter((option) => option.getAttribute(ARIA_SELECTED) === 'true'); + expect(selectedOptions.length).toBe(10); + + // Check that the 11th endpoint is disabled + expect( + screen.getByRole('option', { name: allmockEndpoints[10].id }) + ).toHaveAttribute(ARIA_DISABLED, 'true'); + + // Check "Select All" is not available when there are more endpoints than the limit + expect(queryByRole('option', { name: SELECT_ALL })).not.toBeInTheDocument(); + }); + + it('should handle "Select All" when resource count equals limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: [...mockEndpoints, ...exceedingmockEndpoints.slice(0, 7)], + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: SELECT_ALL })); + await user.click(screen.getByRole('option', { name: 'Deselect All' })); + + // Check all endpoints are deselected + mockEndpoints.forEach((endpoint) => { + expect(screen.getByRole('option', { name: endpoint.id })).toHaveAttribute( + ARIA_SELECTED, + 'false' + ); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index 382ed64eb92..a2837c587da 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -1,11 +1,13 @@ import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; import { Box } from '@mui/material'; -import React, { useMemo } from 'react'; +import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { ENDPOINT, RESOURCE_FILTER_MAP } from '../Utils/constants'; import { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; @@ -13,20 +15,15 @@ import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; -export interface CloudPulseEndpoints { - /** - * The label of the endpoint which is 's3_endpoint' in the response from the API - */ - label: string; +export interface CloudPulseEndpointsSelectProps { /** - * The region of the endpoint + * The dashboard id for the endpoints filter */ - region: string; -} - -export interface CloudPulseEndpointsSelectProps { + dashboardId: number; /** * The default value of the endpoints filter */ @@ -39,6 +36,10 @@ export interface CloudPulseEndpointsSelectProps { * The function to handle the endpoints selection */ handleEndpointsSelection: (endpoints: string[], savePref?: boolean) => void; + /** + * Whether to restrict the selections + */ + hasRestrictedSelections?: boolean; /** * The label of the endpoints filter */ @@ -70,6 +71,7 @@ export const CloudPulseEndpointsSelect = React.memo( const { defaultValue, disabled, + dashboardId, handleEndpointsSelection, label, placeholder, @@ -77,39 +79,32 @@ export const CloudPulseEndpointsSelect = React.memo( serviceType, savePreferences, xFilter, + hasRestrictedSelections, } = props; + const flags = useFlags(); + + // Get the endpoints filter configuration for the dashboard + const endpointsFilterConfig = FILTER_CONFIG.get(dashboardId)?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + ); + const filterFn = endpointsFilterConfig?.configuration.filterFn; + const { - data: buckets, + data: validSortedEndpoints, isError, isLoading, } = useResourcesQuery( disabled !== undefined ? !disabled : Boolean(region && serviceType), serviceType, {}, - - RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {} + RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {}, + undefined, + filterFn ); - const validSortedEndpoints = useMemo(() => { - if (!buckets) return []; - - const visitedEndpoints = new Set(); - const uniqueEndpoints: CloudPulseEndpoints[] = []; - - buckets.forEach(({ endpoint, region }) => { - if (endpoint && region && !visitedEndpoints.has(endpoint)) { - visitedEndpoints.add(endpoint); - uniqueEndpoints.push({ label: endpoint, region }); - } - }); - - uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); - return uniqueEndpoints; - }, [buckets]); - const [selectedEndpoints, setSelectedEndpoints] = - React.useState(); + React.useState(); /** * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below @@ -118,17 +113,49 @@ export const CloudPulseEndpointsSelect = React.memo( */ const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete - const getEndpointsList = React.useMemo(() => { + const getEndpointsList = React.useMemo(() => { return filterEndpointsUsingRegion(validSortedEndpoints, xFilter) ?? []; }, [validSortedEndpoints, xFilter]); + // Maximum endpoints selection limit is fetched from launchdarkly + const maxEndpointsSelectionLimit = React.useMemo(() => { + const obj = flags.aclpResourceTypeMap?.find( + (item: CloudPulseResourceTypeMapFlag) => + item.serviceType === serviceType + ); + return obj?.maxResourceSelections || 10; + }, [serviceType, flags.aclpResourceTypeMap]); + + const endpointsLimitReached = React.useMemo(() => { + return getEndpointsList.length > maxEndpointsSelectionLimit; + }, [getEndpointsList.length, maxEndpointsSelectionLimit]); + + // Disable Select All option if the number of available endpoints are greater than the limit + const disableSelectAll = hasRestrictedSelections + ? endpointsLimitReached + : false; + + const errorText = isError ? `Failed to fetch ${label || 'Endpoints'}.` : ''; + const helperText = + !isError && hasRestrictedSelections + ? `Select up to ${maxEndpointsSelectionLimit} ${label}` + : ''; + + // Check if the number of selected endpoints are greater than or equal to the limit + const maxSelectionsReached = React.useMemo(() => { + return ( + selectedEndpoints && + selectedEndpoints.length >= maxEndpointsSelectionLimit + ); + }, [selectedEndpoints, maxEndpointsSelectionLimit]); + // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { if (disabled && !selectedEndpoints) { return; } // To save default values, go through side effects if disabled is false - if (!buckets || !savePreferences || selectedEndpoints) { + if (!validSortedEndpoints || !savePreferences || selectedEndpoints) { if (selectedEndpoints) { setSelectedEndpoints([]); handleEndpointsSelection([]); @@ -146,7 +173,7 @@ export const CloudPulseEndpointsSelect = React.memo( setSelectedEndpoints(endpoints); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [buckets, region, xFilter, serviceType]); + }, [validSortedEndpoints, region, xFilter, serviceType]); return ( option.label === value.label} label={label || 'Endpoints'} limitTags={1} @@ -198,8 +227,20 @@ export const CloudPulseEndpointsSelect = React.memo( ? StyledListItem : 'li'; + const isMaxSelectionsReached = + maxSelectionsReached && + !isEndpointSelected && + !isSelectAllORDeslectAllOption; + return ( - + <> {option.label} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c5bc0413b1d..d0eb07d5767 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3563,6 +3563,13 @@ export const handlers = [ service_type: 'objectstorage', }) ); + response.data.push( + dashboardFactory.build({ + id: 10, + label: 'Endpoint Dashboard', + service_type: 'objectstorage', + }) + ); } if (params.serviceType === 'blockstorage') { @@ -3983,6 +3990,9 @@ export const handlers = [ } else if (id === '9') { serviceType = 'lke'; dashboardLabel = 'Kubernetes Enterprise Dashboard'; + } else if (id === '10') { + serviceType = 'objectstorage'; + dashboardLabel = 'Endpoint Dashboard'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 1dfbe72d55d..9b4fd8a535a 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -45,7 +45,7 @@ export const useResourcesQuery = ( } const id = resourceType === 'objectstorage' - ? resource.hostname + ? resource.hostname || resource.id : String(resource.id); return { engineType: resource.engine,