diff --git a/packages/manager/.changeset/pr-13408-changed-1771395532024.md b/packages/manager/.changeset/pr-13408-changed-1771395532024.md new file mode 100644 index 00000000000..98f2f9a8490 --- /dev/null +++ b/packages/manager/.changeset/pr-13408-changed-1771395532024.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve Linode plans' display for Dedicated and GPU tabs ([#13408](https://github.com/linode/manager/pull/13408)) diff --git a/packages/manager/.changeset/pr-13408-upcoming-features-1771395610474.md b/packages/manager/.changeset/pr-13408-upcoming-features-1771395610474.md new file mode 100644 index 00000000000..898c74e0b6b --- /dev/null +++ b/packages/manager/.changeset/pr-13408-upcoming-features-1771395610474.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Blackwell GPU related banners in the Linode Create page ([#13408](https://github.com/linode/manager/pull/13408)) diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index bd92c821f9c..a0eabad7c27 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -143,6 +143,13 @@ const notices = { unavailable: '[data-qa-error="true"]', }; +const GPU_GENERAL_AVAILABILITY_NOTICE = + 'New GPU instances are now generally available. Deploy an RTX 4000 Ada GPU instance in select core compute regions in North America, Europe, and Asia.'; +const GPU_NO_AVAILABILITY_ERROR = + 'GPU Plans are not currently available in this region.'; +const GPU_BLACKWELL_NO_AVAILABILITY_ERROR = + 'NVIDIA RTX PRO 6000 Blackwell GPU plans are currently unavailable in this region or globally unavailable. Try another region or contact Support for assistance.'; + authenticate(); describe('displays linode plans panel based on availability', () => { beforeEach(() => { @@ -399,10 +406,16 @@ describe('displays specific linode plans for GPU', () => { .click(); // GPU tab - // Should display two separate tables + // Confirm that the expected notice/error banners are present: + // + // - General availability notice explaining that Nvidia Ada plans are available. + // - Region availability error explaining that GPU plans are unavailable in the mocked region. + // - Blackwell GPU availability error explaining that Blackwell plans are unavailable. cy.findByText('GPU').click(); cy.get(linodePlansPanel).within(() => { - cy.findAllByRole('alert').should('have.length', 3); + cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible'); + cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible'); + cy.contains(GPU_BLACKWELL_NO_AVAILABILITY_ERROR).should('be.visible'); cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { @@ -440,11 +453,16 @@ describe('displays specific kubernetes plans for GPU', () => { .click(); // GPU tab - // Should display two separate tables + // Confirm that the expected notice/error banners are present: + // + // - General availability notice explaining that Nvidia Ada plans are available. + // - Region availability error explaining that GPU plans are unavailable in the mocked region. + // - Blackwell GPU availability error explaining that Blackwell plans are unavailable. cy.findByText('GPU').click(); cy.get(k8PlansPanel).within(() => { - cy.findAllByRole('alert').should('have.length', 3); - cy.get(notices.unavailable).should('be.visible'); + cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible'); + cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible'); + cy.contains(GPU_BLACKWELL_NO_AVAILABILITY_ERROR).should('be.visible'); cy.findByRole('table', { name: 'List of Linode Plans', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 38a09b0e616..3862ee35e25 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -248,6 +248,7 @@ export interface Flags { ipv6Sharing: boolean; limitsEvolution: LimitsEvolution; linodeCloneFirewall: boolean; + linodeCreateBanner: LinodeCreateBanner; linodeDiskEncryption: boolean; linodeInterfaces: LinodeInterfacesFlag; lkeEnterprise2: LkeEnterpriseFlag; @@ -435,3 +436,8 @@ export type AclpServices = { interface GenerationalPlansFlag extends BaseFeatureFlag { allowedPlans: string[]; } + +interface LinodeCreateBanner extends BaseFeatureFlag { + message?: string; + pendo_id?: string; +} diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index 94a2edd7b2f..297340816ef 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -5,7 +5,10 @@ import * as React from 'react'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { PLAN_PANEL_PAGE_SIZE_OPTIONS } from 'src/features/components/PlansPanel/constants'; +import { + PLAN_FILTER_NO_RESULTS_MESSAGE, + PLAN_PANEL_PAGE_SIZE_OPTIONS, +} from 'src/features/components/PlansPanel/constants'; import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes'; import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants'; @@ -244,7 +247,9 @@ export const KubernetesPlanContainer = ( const plansToDisplay = effectiveFilterResult?.filteredPlans ?? plans; const tableEmptyState = shouldDisplayNoRegionSelectedMessage ? null - : (effectiveFilterResult?.emptyState ?? null); + : plansToDisplay.length === 0 + ? { message: PLAN_FILTER_NO_RESULTS_MESSAGE } + : null; // Feature gate: if pagination is disabled, render the old way if (!isGenerationalPlansEnabled) { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index 6ebf3ca007e..8743bd46ac4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -5,7 +5,7 @@ import { useMutateAccountAgreements, useProfile, } from '@linode/queries'; -import { CircleProgress, Notice, Stack } from '@linode/ui'; +import { CircleProgress, Notice, Stack, Typography } from '@linode/ui'; import { scrollErrorIntoView } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -19,6 +19,7 @@ import React, { useEffect, useRef } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form'; +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; @@ -45,6 +46,7 @@ import { useIsLinodeCloneFirewallEnabled, useIsLinodeInterfacesEnabled, } from 'src/utilities/linodes'; +import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { Actions } from './Actions'; import { AdditionalOptions } from './AdditionalOptions/AdditionalOptions'; @@ -88,7 +90,7 @@ export const LinodeCreate = () => { const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled(); const linodeCreateType = useGetLinodeCreateType(); - const { aclpServices } = useFlags(); + const { aclpServices, linodeCreateBanner } = useFlags(); // In Create flow, alerts always default to 'legacy' mode const [isAclpAlertsBetaCreateFlow, setIsAclpAlertsBetaCreateFlow] = @@ -246,6 +248,26 @@ export const LinodeCreate = () => { return ( + {linodeCreateBanner?.enabled && ( + + + + )} & { + isDisabled: boolean; +}; + const GENERATION_OPTIONS: SelectOption[] = [ - { label: 'All', value: PLAN_FILTER_ALL }, + { label: 'All Available Plans', value: PLAN_FILTER_ALL_AVAILABLE }, + { label: 'All Plans', value: PLAN_FILTER_ALL }, { label: 'G8 Dedicated', value: PLAN_FILTER_GENERATION_G8 }, { label: 'G7 Dedicated', value: PLAN_FILTER_GENERATION_G7 }, { label: 'G6 Dedicated', value: PLAN_FILTER_GENERATION_G6 }, @@ -61,11 +70,40 @@ const DedicatedPlanFiltersComponent = React.memo( const { disabled = false, onResult, plans, resetPagination } = props; // Local state - persists automatically because component stays mounted - const [generation, setGeneration] = - React.useState(PLAN_FILTER_ALL); + const [generation, setGeneration] = React.useState( + PLAN_FILTER_ALL_AVAILABLE + ); const [type, setType] = React.useState(PLAN_FILTER_ALL); + const generationOptions: GenerationOptionWithDisabled[] = + React.useMemo(() => { + const options = GENERATION_OPTIONS.map((option) => ({ + ...option, + isDisabled: filterPlansByGeneration(plans, option.value).every( + (plan) => getIsPlanDisabled(plan) + ), + })); + // Sort options: available first, then all, then by generation (G8 > G7 > G6) + return options.sort((a, b) => { + // "available" always comes first + if (a.value === 'available') return -1; + if (b.value === 'available') return 1; + + // "all" always comes second + if (a.value === 'all') return -1; + if (b.value === 'all') return 1; + + // enabled options before disabled + if (a.isDisabled !== b.isDisabled) { + return Number(a.isDisabled) - Number(b.isDisabled); + } + + // generation order g8 > g7 > g6 + return getGenerationRank(b.value) - getGenerationRank(a.value); + }); + }, [plans]); + const typeFilteringSupported = supportsTypeFiltering(generation); const typeOptions = typeFilteringSupported @@ -74,7 +112,7 @@ const DedicatedPlanFiltersComponent = React.memo( // Disable type filter if: // 1. Panel is disabled, OR - // 2. Selected generation doesn't support type filtering (G7, G6, All) + // 2. Selected generation doesn't support type filtering (G7, G6, All, All Available) const isTypeSelectDisabled = disabled || !typeFilteringSupported; // Track previous filters to detect changes for pagination reset @@ -102,14 +140,9 @@ const DedicatedPlanFiltersComponent = React.memo( }, [generation, resetPagination, type]); const handleGenerationChange = React.useCallback( - ( - _event: React.SyntheticEvent, - option: null | SelectOption - ) => { - // When clearing, default to "All" instead of undefined - const newGeneration = - (option?.value as PlanFilterGeneration | undefined) ?? - PLAN_FILTER_ALL; + (_event: React.SyntheticEvent, option: GenerationOptionWithDisabled) => { + // if option is undefined, default to "All Available" instead + const newGeneration = option?.value ?? PLAN_FILTER_ALL_AVAILABLE; setGeneration(newGeneration); // Reset type filter when generation changes @@ -136,8 +169,10 @@ const DedicatedPlanFiltersComponent = React.memo( }, [generation, plans, type, typeFilteringSupported]); const selectedGenerationOption = React.useMemo(() => { - return GENERATION_OPTIONS.find((opt) => opt.value === generation) ?? null; - }, [generation]); + return ( + generationOptions.find((opt) => opt.value === generation) ?? undefined + ); + }, [generation, generationOptions]); const selectedTypeOption = React.useMemo(() => { const displayType = typeFilteringSupported ? type : PLAN_FILTER_ALL; @@ -156,15 +191,22 @@ const DedicatedPlanFiltersComponent = React.memo( marginTop: -16, }} > - option.isDisabled || false} id="plan-filter-gpu" label="GPU Plans" onChange={handleGpuTypeChange} @@ -127,7 +169,7 @@ const GPUPlanFilterComponent = React.memo( return { filteredPlans, filterUI, - hasActiveFilters: gpuType !== PLAN_FILTER_ALL, + hasActiveFilters: gpuType !== PLAN_FILTER_ALL_AVAILABLE, }; }, [ GPU_OPTIONS_BASED_ON_AVAILABLE_PLANS, @@ -148,8 +190,6 @@ const GPUPlanFilterComponent = React.memo( } ); -GPUPlanFilterComponent.displayName = 'GPUPlanFilterComponent'; - export const createGPUPlanFilterRenderProp = () => { return ({ onResult, diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index f5867e31ec8..cdfc62406f6 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -11,6 +11,7 @@ import { DEDICATED_COMPUTE_INSTANCES_LINK, GPU_COMPUTE_INSTANCES_LINK, HIGH_MEMORY_COMPUTE_INSTANCES_LINK, + PLAN_FILTER_GPU_RTX_PRO_6000, PREMIUM_COMPUTE_INSTANCES_LINK, SHARED_COMPUTE_INSTANCES_LINK, TRANSFER_COSTS_LINK, @@ -18,9 +19,10 @@ import { import { MetalNotice } from './MetalNotice'; import { PlansAvailabilityNotice } from './PlansAvailabilityNotice'; import { PlanNoticeTypography } from './PlansAvailabilityNotice.styles'; -import { planTabInfoContent } from './utils'; +import { getIsPlanDisabled, planTabInfoContent } from './utils'; +import { filterPlansByGpuType } from './utils/planFilters'; -import type { PlanSelectionType } from './types'; +import type { PlanSelectionType, PlanWithAvailability } from './types'; import type { Region } from '@linode/api-v4'; import type { LinodeTypeClass } from '@linode/api-v4/lib/linodes'; import type { Theme } from '@mui/material/styles'; @@ -67,6 +69,14 @@ export const PlanInformation = (props: PlanInformationProps) => { const showGPUEgressBanner = Boolean(useFlags().gpuv2?.egressBanner); const showTransferBanner = Boolean(useFlags().gpuv2?.transferBanner); + const showBlackwellLimitedAvailabilityBanner = + hasSelectedRegion && + plans?.length && + filterPlansByGpuType( + plans as PlanWithAvailability[], + PLAN_FILTER_GPU_RTX_PRO_6000 + ).every((plan) => getIsPlanDisabled(plan)); + const showLimitedAvailabilityBanner = hasSelectedRegion && isSelectedRegionEligibleForPlan && @@ -113,6 +123,24 @@ export const PlanInformation = (props: PlanInformationProps) => { planType={planType} regionsData={regionsData || []} /> + {showBlackwellLimitedAvailabilityBanner && ( + + ({ font: theme.font.bold })} + > + + NVIDIA RTX PRO 6000 Blackwell GPU plans are currently + unavailable + {' '} + in this region or globally unavailable. Try another region or{' '} + + contact Support + {' '} + for assistance. + + + )} ) : null} {planType === 'accelerated' && ( diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index b43ae5a3719..04c0505d589 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -80,6 +80,7 @@ export const G8_DEDICATED_ALL_SLUGS = [ export const PLAN_FILTER_ALL = 'all'; // Filter option values +export const PLAN_FILTER_ALL_AVAILABLE = 'available'; export const PLAN_FILTER_GENERATION_G8 = 'g8'; export const PLAN_FILTER_GENERATION_G7 = 'g7'; export const PLAN_FILTER_GENERATION_G6 = 'g6'; diff --git a/packages/manager/src/features/components/PlansPanel/types/planFilters.ts b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts index 8e3f37b9eba..440db3dc1b1 100644 --- a/packages/manager/src/features/components/PlansPanel/types/planFilters.ts +++ b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts @@ -14,7 +14,7 @@ import type { PlanWithAvailability } from '../types'; /** * Available plan generations for Dedicated CPU filtering */ -export type PlanFilterGeneration = 'all' | 'g6' | 'g7' | 'g8'; +export type PlanFilterGeneration = 'all' | 'available' | 'g6' | 'g7' | 'g8'; /** * Available plan types for filtering within a generation @@ -26,6 +26,7 @@ export type PlanFilterType = 'all' | 'compute-optimized' | 'general-purpose'; */ export type PlanFilterGPU = | 'all' + | 'available' | 'gpu-rtx4000' | 'gpu-rtx6000' | 'gpu-rtxpro6000'; diff --git a/packages/manager/src/features/components/PlansPanel/utils.ts b/packages/manager/src/features/components/PlansPanel/utils.ts index 6ecc1f5801f..56f6108786e 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.ts @@ -408,29 +408,8 @@ export const extractPlansInformation = ({ ); const allDisabledPlans = plansForThisLinodeTypeClass.reduce((acc, plan) => { - const { - planBelongsToDisabledClass, - planHasLimitedAvailability, - planIsDisabled512Gb, - planResizeNotSupported, - planIsSmallerThanUsage, - planIsTooSmall, - planIsTooSmallForAPL, - } = plan; - - // Determine if the plan should be disabled due to - // - belonging to a disabled class - // - having limited availability (API based) - // - being a 512GB plan (hard coded) - if ( - planBelongsToDisabledClass || - planHasLimitedAvailability || - planIsDisabled512Gb || - planResizeNotSupported || - planIsSmallerThanUsage || - planIsTooSmall || - planIsTooSmallForAPL - ) { + const isPlanDisabled = getIsPlanDisabled(plan); + if (isPlanDisabled) { return [...acc, plan]; } @@ -448,6 +427,36 @@ export const extractPlansInformation = ({ }; }; +/** + * + * A utility function to determine if a plan should be disabled based on criteria: + * - belonging to a disabled class + * - having limited availability (API based) + * - being a 512GB plan (hard coded) + * + */ +export const getIsPlanDisabled = (plan: PlanWithAvailability) => { + const { + planBelongsToDisabledClass, + planHasLimitedAvailability, + planIsDisabled512Gb, + planResizeNotSupported, + planIsSmallerThanUsage, + planIsTooSmall, + planIsTooSmallForAPL, + } = plan; + + return ( + planBelongsToDisabledClass || + planHasLimitedAvailability || + planIsDisabled512Gb || + planResizeNotSupported || + planIsSmallerThanUsage || + planIsTooSmall || + planIsTooSmallForAPL + ); +}; + /** * A utility function to determine what the disabled plan reason is. * Defaults to the currently unavailable copy. diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts index 4196e192eb0..451da53b221 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts @@ -65,7 +65,7 @@ describe('planFilters utilities', () => { label: 'RTX PRO 6000 Blackwell x1', }); - const gpuPlans = [rtx4000Plan, rtx6000Plan, rtxPro6000Plan]; + const gpuPlans = [rtxPro6000Plan, rtx4000Plan, rtx6000Plan]; describe('filterPlansByGeneration', () => { it('returns only G8 plans that exist in the allow-list', () => { diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts index 89b8ca003f9..55decf2f008 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts @@ -10,9 +10,11 @@ import { G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS, G8_DEDICATED_GENERAL_PURPOSE_SLUGS, PLAN_FILTER_ALL, + PLAN_FILTER_ALL_AVAILABLE, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, } from '../constants'; +import { getIsPlanDisabled } from '../utils'; import type { PlanFilterGeneration, @@ -51,6 +53,11 @@ export const filterPlansByGeneration = ( return plans; } + // For "Available", return only plans that are not disabled + if (generation === 'available') { + return plans.filter((plan) => !getIsPlanDisabled(plan)); + } + // For G8, use explicit slug list for precise filtering if (generation === 'g8') { const g8Slugs = new Set(G8_DEDICATED_ALL_SLUGS); @@ -92,7 +99,7 @@ export const getGenerationRank = (planId: string): number => { * Filter plans by type within a generation * * @param plans - Array of plans (should be pre-filtered by generation) - * @param generation - The generation context ('all', 'g8', 'g7', or 'g6') + * @param generation - The generation context ('all', 'available', 'g8', 'g7', or 'g6') * @param type - The type to filter by ('all', 'compute-optimized', 'general-purpose') * @returns Filtered array of plans matching the type * @@ -108,11 +115,20 @@ export const filterPlansByType = ( generation: PlanFilterGeneration, type: PlanFilterType ): PlanWithAvailability[] => { - // "All" returns all plans, sorted from newest to oldest generations + // "All" returns all plans, sorted based on availability, from newest to oldest generations if (type === PLAN_FILTER_ALL) { - return [...plans].sort( - (a, b) => getGenerationRank(b.id) - getGenerationRank(a.id) - ); + return [...plans].sort((a, b) => { + const isPlanADisabled = getIsPlanDisabled(a); + const isPlanBDisabled = getIsPlanDisabled(b); + + // Primary sort: Availability (available plans first) + if (isPlanADisabled !== isPlanBDisabled) { + return Number(isPlanADisabled) - Number(isPlanBDisabled); + } + + // Secondary sort: Generation (newest generation first) + return getGenerationRank(b.id) - getGenerationRank(a.id); + }); } // G7, G6, and "All" generation only have "All" option (no sub-types) @@ -189,6 +205,22 @@ export const applyDedicatedPlanFilters = ( // GPU Filtering // ============================================================================ +/** + * Return a numeric rank for a GPU based on plan ID. + * Higher rank = latest gpu(shown first). + * + * Example: + * - "g3-gpu-rtxpro6000-blackwell-1"" -> 3 + * - "g2-gpu-rtx4000a1-s" -> 2 + * - "g1-gpu-rtx6000-1" -> 1 + * - "legacy-plan" -> 0 + */ +export const getGpuRank = (planId: string): number => { + if (planId.includes('rtxpro6000')) return 3; + if (planId.includes('rtx4000')) return 2; + if (planId.includes('rtx6000')) return 1; + return 0; +}; /** * Filter plans by gpu type * @@ -202,7 +234,7 @@ export const applyDedicatedPlanFilters = ( * // Returns all plans with GPU type 'gpu-rtx4000' * * const allDedicatedPlans = filterPlansByGpuType(allPlans, 'all'); - * // Returns all plans as-is (already filtered by plan type in parent) + * // Returns all plans as-is (already filtered by plan type in parent) sorted based on the latest generation * ``` */ export const filterPlansByGpuType = ( @@ -210,9 +242,29 @@ export const filterPlansByGpuType = ( gpuType?: PlanFilterGPU ): PlanWithAvailability[] => { // For "All", return all plans as-is + // For "available", return only plans that are not disabled, sorted in order of blackwell > ada > quadro // The plans array is already filtered to only GPU plans by the parent component if (!gpuType || gpuType === PLAN_FILTER_ALL) { - return plans; + return [...plans].sort((a, b) => { + const isPlanADisabled = getIsPlanDisabled(a); + const isPlanBDisabled = getIsPlanDisabled(b); + + // Primary sort: Availability (available plans first) + if (isPlanADisabled !== isPlanBDisabled) { + return Number(isPlanADisabled) - Number(isPlanBDisabled); + } + + // Secondary sort: Generation (newest generation first) + return getGpuRank(b.id) - getGpuRank(a.id); + }); + } + // For "Available", return only plans that are not disabled + if (gpuType === PLAN_FILTER_ALL_AVAILABLE) { + return plans + .filter((plan) => !getIsPlanDisabled(plan)) + .sort((a, b) => { + return getGenerationRank(b.id) - getGenerationRank(a.id); + }); } return plans.filter((plan) => plan.id.includes(gpuType)); };