From cdcd429c0c189de881eb6cadf1d5fb429dc4a9e8 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Thu, 20 Nov 2025 15:47:34 +0530 Subject: [PATCH 01/12] upcoming: [DI-27569] - Integrate endpoints dashboard for object-storage service --- packages/api-v4/src/cloudpulse/types.ts | 2 +- .../Dashboard/CloudPulseDashboardRenderer.tsx | 24 ++- .../Utils/CloudPulseWidgetUtils.test.ts | 16 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 15 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 5 +- .../CloudPulse/Utils/FilterBuilder.ts | 7 +- .../features/CloudPulse/Utils/FilterConfig.ts | 48 ++++- .../src/features/CloudPulse/Utils/models.ts | 9 +- .../features/CloudPulse/Utils/utils.test.ts | 42 ++++- .../src/features/CloudPulse/Utils/utils.ts | 25 +++ .../CloudPulse/Widget/CloudPulseWidget.tsx | 1 + .../shared/CloudPulseCustomSelect.tsx | 1 + .../shared/CloudPulseEndpointsSelect.test.tsx | 168 +++++++++++++----- .../shared/CloudPulseEndpointsSelect.tsx | 102 +++++++---- packages/manager/src/mocks/serverHandlers.ts | 10 ++ .../src/queries/cloudpulse/resources.ts | 2 +- 16 files changed, 373 insertions(+), 104 deletions(-) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4ac6b19d7f8..485b6903072 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -152,7 +152,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/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index c74e2d17614..a3446fd1147 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { + ENDPOINT, PARENT_ENTITY_REGION, REFRESH, REGION, @@ -57,6 +58,21 @@ export const CloudPulseDashboardRenderer = React.memo( ); } + const getResources = () => { + if (dashboard.id === 10) { + return Array.isArray(filterValue[ENDPOINT]) + ? filterValue[ENDPOINT].map(String) + : []; + } + if (Array.isArray(filterValue[RESOURCE_ID])) { + return filterValue[RESOURCE_ID].map(String); + } + if (typeof filterValue[RESOURCE_ID] === 'string') { + return [filterValue[RESOURCE_ID]]; + } + return []; + }; + return ( { [{ id: '123', label: 'linode-1' }], ['123'], widgetFactory.build(), - 'linode' + 2 ); 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'], widgetFactory.build(), - 'objectstorage' + 6 ); expect(result).toEqual(['bucket-1']); }); + + it('should return undefined for objectsorage endpoints dashboard', () => { + const result = getEntityIds( + [{ id: 'endpoint-1', label: 'endpoint-1' }], + ['us-east-1.linodeobjects.com'], + widgetFactory.build(), + 10 + ); + expect(result).toEqual(undefined); + }); }); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 786cc2906ea..d68c10b5843 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -112,6 +112,11 @@ interface GraphDataOptionsProps { } interface MetricRequestProps { + /** + * dashboard id of the widget + */ + dashboardId: number; + /** * time duration for the metrics data */ @@ -345,6 +350,7 @@ export const getCloudPulseMetricRequest = ( groupBy, linodeRegion, region, + dashboardId, serviceType, } = props; const preset = duration.preset; @@ -354,7 +360,7 @@ export const getCloudPulseMetricRequest = ( presetDuration === undefined ? { end: duration.end, start: duration.start } : undefined, - entity_ids: getEntityIds(resources, entityIds, widget, serviceType), + entity_ids: getEntityIds(resources, entityIds, widget, dashboardId), filters: undefined, group_by: !groupBy?.length ? undefined : groupBy, relative_time_duration: presetDuration, @@ -387,9 +393,12 @@ export const getEntityIds = ( resources: CloudPulseResources[], entityIds: string[], widget: Widgets, - serviceType: CloudPulseServiceType + dashboardId: number ) => { - if (serviceType === 'objectstorage') { + if (dashboardId === 10) { + return undefined; + } + if (dashboardId === 6) { return entityIds; } return resources 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.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 1d4c19a56e0..f197dffd902 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'; @@ -442,6 +442,44 @@ 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: { + 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 +560,7 @@ export const FILTER_CONFIG: Readonly< [7, BLOCKSTORAGE_CONFIG], [8, FIREWALL_NODEBALANCER_CONFIG], [9, LKE_CONFIG], + [10, ENDPOINT_DASHBOARD_CONFIG], ]); /** @@ -536,6 +575,11 @@ export const getResourcesFilterConfig = ( } // Get the associated entity type for the dashboard const filterConfig = FILTER_CONFIG.get(dashboardId); + if (dashboardId === 10) { + return filterConfig?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + )?.configuration; + } return filterConfig?.filters.find( (filter) => filter.configuration.filterKey === RESOURCE_ID )?.configuration; 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..5d152b81b89 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, region }) => { + if (s3_endpoint && region && !visitedEndpoints.has(s3_endpoint)) { + visitedEndpoints.add(s3_endpoint); + uniqueEndpoints.push({ id: s3_endpoint, label: s3_endpoint, 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/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index e401c2876cc..505ed210857 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -290,6 +290,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { groupBy: [...globalFilterGroupBy, ...(groupBy ?? [])], linodeRegion, region, + dashboardId, serviceType, }), filters, // any additional dimension filters will be constructed and passed here 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..d3e7defdf38 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -1,32 +1,28 @@ 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 { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; -import { deepEqual } from '../Utils/utils'; +import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { getResourcesFilterConfig } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; 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 { +export interface CloudPulseEndpointsSelectProps { /** - * The label of the endpoint which is 's3_endpoint' in the response from the API + * The dashboard id for the endpoints filter */ - label: string; - /** - * The region of the endpoint - */ - region: string; -} - -export interface CloudPulseEndpointsSelectProps { + dashboardId: number; /** * The default value of the endpoints filter */ @@ -39,6 +35,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 +70,7 @@ export const CloudPulseEndpointsSelect = React.memo( const { defaultValue, disabled, + dashboardId, handleEndpointsSelection, label, placeholder, @@ -77,39 +78,30 @@ export const CloudPulseEndpointsSelect = React.memo( serviceType, savePreferences, xFilter, + hasRestrictedSelections, } = props; + const flags = useFlags(); + + // Get the resources filter configuration for the dashboard + const resourcesFilterConfig = getResourcesFilterConfig(dashboardId); + const filterFn = resourcesFilterConfig?.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 +110,30 @@ 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]); + // 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 +151,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 +211,21 @@ export const CloudPulseEndpointsSelect = React.memo( ? StyledListItem : 'li'; + const isMaxSelectionsReached = + selectedEndpoints && + selectedEndpoints.length >= maxEndpointsSelectionLimit && + !isEndpointSelected && + !isSelectAllORDeslectAllOption; + return ( - + <> {option.label} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index dcf176ec753..c0cb7b6290f 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3547,6 +3547,13 @@ export const handlers = [ service_type: 'objectstorage', }) ); + response.data.push( + dashboardFactory.build({ + id: 10, + label: 'Endpoint Dashboard', + service_type: 'objectstorage', + }) + ); } if (params.serviceType === 'blockstorage') { @@ -3967,6 +3974,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, From 3e786a3bd0f46f711d2adc218e7c5ba9f496793f Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Fri, 21 Nov 2025 09:58:00 +0530 Subject: [PATCH 02/12] upcoming: [DI-27569] - Update endpoints config for buckets dashboard --- .../src/features/CloudPulse/Utils/FilterConfig.ts | 4 +++- .../CloudPulse/shared/CloudPulseEndpointsSelect.tsx | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index f197dffd902..1b19628775a 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -420,6 +420,8 @@ export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly + getValidSortedEndpoints(resources), }, name: 'Endpoints', }, @@ -573,7 +575,7 @@ 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 (dashboardId === 10) { return filterConfig?.filters.find( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index d3e7defdf38..582c499e48b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -5,9 +5,9 @@ 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 { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { @@ -83,9 +83,11 @@ export const CloudPulseEndpointsSelect = React.memo( const flags = useFlags(); - // Get the resources filter configuration for the dashboard - const resourcesFilterConfig = getResourcesFilterConfig(dashboardId); - const filterFn = resourcesFilterConfig?.filterFn; + // 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: validSortedEndpoints, From dc2b127806e55c8a10a0898c9978196ac3a372ea Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 09:42:03 +0530 Subject: [PATCH 03/12] upcoming: [DI-27569] - add util for dashboard type identification, simplify props --- .../Dashboard/CloudPulseDashboard.tsx | 2 +- .../Dashboard/CloudPulseDashboardRenderer.tsx | 24 ++++++------------- .../Utils/CloudPulseWidgetUtils.test.ts | 14 ++--------- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 14 +++++------ .../features/CloudPulse/Utils/FilterConfig.ts | 20 ++++++++++++++++ .../ReusableDashboardFilterUtils.test.ts | 12 ++++++++++ .../Utils/ReusableDashboardFilterUtils.ts | 18 ++++++++++---- .../features/CloudPulse/Utils/utils.test.ts | 12 ++++++++++ .../Widget/CloudPulseWidgetRenderer.tsx | 2 -- 9 files changed, 75 insertions(+), 43 deletions(-) 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/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index a3446fd1147..c74e2d17614 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { - ENDPOINT, PARENT_ENTITY_REGION, REFRESH, REGION, @@ -58,21 +57,6 @@ export const CloudPulseDashboardRenderer = React.memo( ); } - const getResources = () => { - if (dashboard.id === 10) { - return Array.isArray(filterValue[ENDPOINT]) - ? filterValue[ENDPOINT].map(String) - : []; - } - if (Array.isArray(filterValue[RESOURCE_ID])) { - return filterValue[RESOURCE_ID].map(String); - } - if (typeof filterValue[RESOURCE_ID] === 'string') { - return [filterValue[RESOURCE_ID]]; - } - return []; - }; - return ( { [{ id: '123', label: 'linode-1' }], ['123'], widgetFactory.build(), - 2 + 'linode' ); expect(result).toEqual([123]); }); @@ -333,19 +333,9 @@ describe('getTimeDurationFromPreset method', () => { [{ id: 'bucket-1', label: 'bucket-name-1' }], ['bucket-1'], widgetFactory.build(), - 6 + 'objectstorage' ); expect(result).toEqual(['bucket-1']); }); - - it('should return undefined for objectsorage endpoints dashboard', () => { - const result = getEntityIds( - [{ id: 'endpoint-1', label: 'endpoint-1' }], - ['us-east-1.linodeobjects.com'], - widgetFactory.build(), - 10 - ); - expect(result).toEqual(undefined); - }); }); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index d68c10b5843..151c61e3abc 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -3,6 +3,7 @@ import { DateTimeRangePicker } from '@linode/ui'; import { getMetrics } from '@linode/utilities'; import { DIMENSION_TRANSFORM_CONFIG } from '../shared/DimensionTransform'; +import { isEndpointsOnlyDashboard } from './FilterConfig'; import { convertValueToUnit, formatToolTip, @@ -350,8 +351,8 @@ export const getCloudPulseMetricRequest = ( groupBy, linodeRegion, region, - dashboardId, serviceType, + dashboardId, } = props; const preset = duration.preset; const presetDuration = getTimeDurationFromPreset(preset); @@ -360,7 +361,9 @@ export const getCloudPulseMetricRequest = ( presetDuration === undefined ? { end: duration.end, start: duration.start } : undefined, - entity_ids: getEntityIds(resources, entityIds, widget, dashboardId), + entity_ids: isEndpointsOnlyDashboard(dashboardId) + ? undefined + : getEntityIds(resources, entityIds, widget, serviceType), filters: undefined, group_by: !groupBy?.length ? undefined : groupBy, relative_time_duration: presetDuration, @@ -393,12 +396,9 @@ export const getEntityIds = ( resources: CloudPulseResources[], entityIds: string[], widget: Widgets, - dashboardId: number + serviceType: CloudPulseServiceType ) => { - if (dashboardId === 10) { - return undefined; - } - if (dashboardId === 6) { + if (serviceType === 'objectstorage') { return entityIds; } return resources diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 1b19628775a..92c53f94d49 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -599,3 +599,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/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index c64562946a9..ee6e6aaa362 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -25,6 +25,7 @@ import { PORTS_LIMIT_ERROR_MESSAGE, PORTS_RANGE_ERROR_MESSAGE, } from './constants'; +import { isEndpointsOnlyDashboard } from './FilterConfig'; import { arePortsValid, areValidInterfaceIds, @@ -749,3 +750,14 @@ describe('getValidSortedEndpoints', () => { ]); }); }); + +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/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 ) { From 840fdb39601898f2a5e77fa2dc2d5502c027cd75 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 10:01:35 +0530 Subject: [PATCH 04/12] upcoming: [DI-27569] - Add dimensionkey for endpoints filter in endpoints config --- packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 92c53f94d49..0879491b844 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -463,6 +463,7 @@ export const ENDPOINT_DASHBOARD_CONFIG: Readonly }, { configuration: { + dimensionKey: 'endpoint', dependency: [REGION], filterKey: ENDPOINT, filterType: 'string', From 57d178e40642fcddaa0d844dc73c011cbf3c2bb4 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 10:39:38 +0530 Subject: [PATCH 05/12] upcoming: [DI-27569] - Cleanup --- .../manager/src/features/CloudPulse/Utils/FilterConfig.ts | 2 +- packages/manager/src/features/CloudPulse/Utils/utils.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 0879491b844..0f6debbc6bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -578,7 +578,7 @@ export const getResourcesFilterConfig = ( } // Get the resources filter configuration for the dashboard const filterConfig = FILTER_CONFIG.get(dashboardId); - if (dashboardId === 10) { + if (isEndpointsOnlyDashboard(dashboardId)) { return filterConfig?.filters.find( (filter) => filter.configuration.filterKey === ENDPOINT )?.configuration; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 5d152b81b89..974a59bc158 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -581,10 +581,10 @@ export const getValidSortedEndpoints = ( const visitedEndpoints = new Set(); const uniqueEndpoints: CloudPulseResources[] = []; - buckets.forEach(({ s3_endpoint, region }) => { - if (s3_endpoint && region && !visitedEndpoints.has(s3_endpoint)) { - visitedEndpoints.add(s3_endpoint); - uniqueEndpoints.push({ id: s3_endpoint, label: s3_endpoint, region }); + buckets.forEach(({ s3_endpoint: s3Endpoint, region }) => { + if (s3Endpoint && region && !visitedEndpoints.has(s3Endpoint)) { + visitedEndpoints.add(s3Endpoint); + uniqueEndpoints.push({ id: s3Endpoint, label: s3Endpoint, region }); } }); From df75f019a441b9768668b613863e7a81335c528c Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 10:55:47 +0530 Subject: [PATCH 06/12] upcoming: [DI-27569] - Simplify props --- .../features/CloudPulse/Utils/CloudPulseWidgetUtils.ts | 8 +------- .../src/features/CloudPulse/Widget/CloudPulseWidget.tsx | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 151c61e3abc..1a24a317243 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -113,11 +113,6 @@ interface GraphDataOptionsProps { } interface MetricRequestProps { - /** - * dashboard id of the widget - */ - dashboardId: number; - /** * time duration for the metrics data */ @@ -352,7 +347,6 @@ export const getCloudPulseMetricRequest = ( linodeRegion, region, serviceType, - dashboardId, } = props; const preset = duration.preset; const presetDuration = getTimeDurationFromPreset(preset); @@ -361,7 +355,7 @@ export const getCloudPulseMetricRequest = ( presetDuration === undefined ? { end: duration.end, start: duration.start } : undefined, - entity_ids: isEndpointsOnlyDashboard(dashboardId) + entity_ids: !entityIds.length ? undefined : getEntityIds(resources, entityIds, widget, serviceType), filters: undefined, diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 505ed210857..e401c2876cc 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -290,7 +290,6 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { groupBy: [...globalFilterGroupBy, ...(groupBy ?? [])], linodeRegion, region, - dashboardId, serviceType, }), filters, // any additional dimension filters will be constructed and passed here From 5ce7b58eec2844cc4af1ade2574eda4f84bcae69 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 12:07:28 +0530 Subject: [PATCH 07/12] upcoming: [DI-27569] - Move computations outside --- .../shared/CloudPulseEndpointsSelect.tsx | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index 582c499e48b..2687aaa16b1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -129,6 +129,25 @@ export const CloudPulseEndpointsSelect = React.memo( 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) { @@ -161,15 +180,9 @@ export const CloudPulseEndpointsSelect = React.memo( clearOnBlur data-testid="endpoint-select" disabled={disabled} - disableSelectAll={ - hasRestrictedSelections ? endpointsLimitReached : false - } - errorText={isError ? `Failed to fetch ${label || 'Endpoints'}.` : ''} - helperText={ - !isError && hasRestrictedSelections - ? `Select up to ${maxEndpointsSelectionLimit} ${label}` - : '' - } + disableSelectAll={disableSelectAll} + errorText={errorText} + helperText={helperText} isOptionEqualToValue={(option, value) => option.label === value.label} label={label || 'Endpoints'} limitTags={1} @@ -214,8 +227,7 @@ export const CloudPulseEndpointsSelect = React.memo( : 'li'; const isMaxSelectionsReached = - selectedEndpoints && - selectedEndpoints.length >= maxEndpointsSelectionLimit && + maxSelectionsReached && !isEndpointSelected && !isSelectAllORDeslectAllOption; From c1cea5a8462af2b48856ea19644d7472fdae5109 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Mon, 24 Nov 2025 13:15:47 +0530 Subject: [PATCH 08/12] upcoming: [DI-27569] - Fix group_by bug --- .../features/CloudPulse/GroupBy/utils.test.ts | 20 +++++++++++++++++-- .../src/features/CloudPulse/GroupBy/utils.ts | 12 ++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) 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..a015f0ecdeb 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,9 +65,12 @@ export const useGlobalDimensions = ( metricDefinition?.data ?? [], dashboard ); + const baseDimensions = getCommonDimensions(metricDimensions); + const shouldIncludeDefault = !isEndpointsOnlyDashboard(dashboardId ?? 0); + const commonDimensions = [ - defaultOption, - ...getCommonDimensions(metricDimensions), + ...(shouldIncludeDefault ? [defaultOption] : []), + ...baseDimensions, ]; const commonGroups = getCommonGroups( From 54f1d4fbc1bd0ca122454a6e9135a618fa302ff1 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Tue, 25 Nov 2025 14:27:51 +0530 Subject: [PATCH 09/12] upcoming: [DI-27569] - Update imports --- .../src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts | 1 - .../features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 1a24a317243..fb0816b6c8d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -3,7 +3,6 @@ import { DateTimeRangePicker } from '@linode/ui'; import { getMetrics } from '@linode/utilities'; import { DIMENSION_TRANSFORM_CONFIG } from '../shared/DimensionTransform'; -import { isEndpointsOnlyDashboard } from './FilterConfig'; import { convertValueToUnit, formatToolTip, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index 2687aaa16b1..a2837c587da 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -6,8 +6,9 @@ import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { ENDPOINT, RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { From df534e4df93d9d5336bd230fe9d103e309ea6f94 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Tue, 25 Nov 2025 20:07:53 +0530 Subject: [PATCH 10/12] upcoming: [DI-27569] - Move test case --- .../features/CloudPulse/Utils/FilterConfig.test.ts | 12 ++++++++++++ .../src/features/CloudPulse/Utils/utils.test.ts | 12 ------------ 2 files changed, 12 insertions(+), 12 deletions(-) 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/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index ee6e6aaa362..c64562946a9 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -25,7 +25,6 @@ import { PORTS_LIMIT_ERROR_MESSAGE, PORTS_RANGE_ERROR_MESSAGE, } from './constants'; -import { isEndpointsOnlyDashboard } from './FilterConfig'; import { arePortsValid, areValidInterfaceIds, @@ -750,14 +749,3 @@ describe('getValidSortedEndpoints', () => { ]); }); }); - -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); - }); -}); From 74ced22d961ea6e2ea66d454fa86e96bd6c66f8f Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Tue, 25 Nov 2025 20:54:30 +0530 Subject: [PATCH 11/12] upcoming: [DI-27569] - Add changesets --- .../.changeset/pr-13133-upcoming-features-1764084018326.md | 5 +++++ .../.changeset/pr-13133-upcoming-features-1764084041956.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md create mode 100644 packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md 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/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)) From 6149f0bb9519901c4506f2b22e3a91a98fd476b0 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Tue, 25 Nov 2025 21:03:03 +0530 Subject: [PATCH 12/12] upcoming: [DI-27569] - Simplify --- packages/manager/src/features/CloudPulse/GroupBy/utils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts index a015f0ecdeb..6281b6a70ca 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -68,10 +68,9 @@ export const useGlobalDimensions = ( const baseDimensions = getCommonDimensions(metricDimensions); const shouldIncludeDefault = !isEndpointsOnlyDashboard(dashboardId ?? 0); - const commonDimensions = [ - ...(shouldIncludeDefault ? [defaultOption] : []), - ...baseDimensions, - ]; + const commonDimensions = shouldIncludeDefault + ? [defaultOption, ...baseDimensions] + : baseDimensions; const commonGroups = getCommonGroups( preference ? preference : (dashboard?.group_by ?? []),