Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13408-changed-1771395532024.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Improve Linode plans' display for Dedicated and GPU tabs ([#13408](https://github.com/linode/manager/pull/13408))
Original file line number Diff line number Diff line change
@@ -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))
28 changes: 23 additions & 5 deletions packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/manager/src/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export interface Flags {
ipv6Sharing: boolean;
limitsEvolution: LimitsEvolution;
linodeCloneFirewall: boolean;
linodeCreateBanner: LinodeCreateBanner;
linodeDiskEncryption: boolean;
linodeInterfaces: LinodeInterfacesFlag;
lkeEnterprise2: LkeEnterpriseFlag;
Expand Down Expand Up @@ -435,3 +436,8 @@ export type AclpServices = {
interface GenerationalPlansFlag extends BaseFeatureFlag {
allowedPlans: string[];
}

interface LinodeCreateBanner extends BaseFeatureFlag {
message?: string;
pendo_id?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 24 additions & 2 deletions packages/manager/src/features/Linodes/LinodeCreate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -246,6 +248,26 @@ export const LinodeCreate = () => {
return (
<FormProvider {...form}>
<DocumentTitleSegment segment="Create a Linode" />
{linodeCreateBanner?.enabled && (
<DismissibleBanner
preferenceKey="linode-create-banner"
spacingBottom={8}
variant="info"
{...(linodeCreateBanner?.enabled && {
'data-pendo-id': linodeCreateBanner?.pendo_id,
})}
>
<Typography
dangerouslySetInnerHTML={{
__html: sanitizeHTML({
sanitizingTier: 'flexible',
allowMoreAttrs: ['target'],
text: linodeCreateBanner?.message ?? '',
}),
}}
/>
</DismissibleBanner>
)}
Comment on lines +251 to +270
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the banner be present on the Kubernetes Create page too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Product wanted this banner for Linode Create page specifically. Though will check with them on this once again.

<LandingHeader
breadcrumbProps={{
labelTitle: linodeCreateType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@
* TabPanels keep all tabs mounted in the DOM (only visibility changes).
*/

import { Select } from '@linode/ui';
import { Autocomplete, Select } from '@linode/ui';
import * as React from 'react';

import {
PLAN_FILTER_ALL,
PLAN_FILTER_ALL_AVAILABLE,
PLAN_FILTER_GENERATION_G6,
PLAN_FILTER_GENERATION_G7,
PLAN_FILTER_GENERATION_G8,
PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED,
PLAN_FILTER_TYPE_GENERAL_PURPOSE,
} from './constants';
import { getIsPlanDisabled } from './utils';
import {
applyDedicatedPlanFilters,
filterPlansByGeneration,
getGenerationRank,
supportsTypeFiltering,
} from './utils/planFilters';

Expand All @@ -32,8 +36,13 @@ import type { PlanWithAvailability } from './types';
import type { PlanFilterGeneration, PlanFilterType } from './types/planFilters';
import type { SelectOption } from '@linode/ui';

type GenerationOptionWithDisabled = SelectOption<PlanFilterGeneration> & {
isDisabled: boolean;
};

const GENERATION_OPTIONS: SelectOption<PlanFilterGeneration>[] = [
{ 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 },
Expand Down Expand Up @@ -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<PlanFilterGeneration>(PLAN_FILTER_ALL);
const [generation, setGeneration] = React.useState<PlanFilterGeneration>(
PLAN_FILTER_ALL_AVAILABLE
);

const [type, setType] = React.useState<PlanFilterType>(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
Expand All @@ -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
Expand Down Expand Up @@ -102,14 +140,9 @@ const DedicatedPlanFiltersComponent = React.memo(
}, [generation, resetPagination, type]);

const handleGenerationChange = React.useCallback(
(
_event: React.SyntheticEvent,
option: null | SelectOption<number | string>
) => {
// 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
Expand All @@ -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;
Expand All @@ -156,15 +191,22 @@ const DedicatedPlanFiltersComponent = React.memo(
marginTop: -16,
}}
>
<Select
<Autocomplete
aria-labelledby="plan-filter-generation-label"
clearable
data-testid="plan-filter-generation"
disableClearable
disabled={disabled}
getOptionDisabled={(option) => option.isDisabled || false}
id="plan-filter-generation"
isOptionEqualToValue={(option, value) => {
if (!option || !value) {
return false;
}
return option.value === value.value;
}}
label="Dedicated Plans"
onChange={handleGenerationChange}
options={GENERATION_OPTIONS}
options={generationOptions}
placeholder="Select a plan"
sx={{ width: 360 }}
value={selectedGenerationOption}
Expand All @@ -188,7 +230,7 @@ const DedicatedPlanFiltersComponent = React.memo(
return {
filteredPlans,
filterUI,
hasActiveFilters: generation !== PLAN_FILTER_ALL,
hasActiveFilters: generation !== PLAN_FILTER_ALL_AVAILABLE,
};
}, [
disabled,
Expand Down
Loading