Skip to content

Commit b0df5d3

Browse files
upcoming: [UIE-10232], [UIE-10060] - Add Blackwell GPU related banners and Improve Linode plans display for Dedicated and GPU tabs (#13408)
* upcoming: [UIE-10232], [UIE-10060] - Add Blackwell GPU related banners and Improve Linode plans display for Dedicated and GPU tabs * dont show blackwell availability banner when plans are empty * added sorting for gpu plans table rows based on availability and latest generation * fix failing test * Fix failing plan selection tests * PR feedback @dwiley-akamai * Added changeset: Improve Linode plans' display for Dedicated and GPU tabs * Added changeset: Add Blackwell GPU related banners in the Linode Create page * PR feedback @tvijay-akamai * added support for deriving pendo id from LD flag * made the getIsPlanDisabled check more shorter --------- Co-authored-by: Joe D'Amore <jdamore@akamai.com>
1 parent ad6a564 commit b0df5d3

File tree

14 files changed

+320
-86
lines changed

14 files changed

+320
-86
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Changed
3+
---
4+
5+
Improve Linode plans' display for Dedicated and GPU tabs ([#13408](https://github.com/linode/manager/pull/13408))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Add Blackwell GPU related banners in the Linode Create page ([#13408](https://github.com/linode/manager/pull/13408))

packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ const notices = {
143143
unavailable: '[data-qa-error="true"]',
144144
};
145145

146+
const GPU_GENERAL_AVAILABILITY_NOTICE =
147+
'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.';
148+
const GPU_NO_AVAILABILITY_ERROR =
149+
'GPU Plans are not currently available in this region.';
150+
const GPU_BLACKWELL_NO_AVAILABILITY_ERROR =
151+
'NVIDIA RTX PRO 6000 Blackwell GPU plans are currently unavailable in this region or globally unavailable. Try another region or contact Support for assistance.';
152+
146153
authenticate();
147154
describe('displays linode plans panel based on availability', () => {
148155
beforeEach(() => {
@@ -399,10 +406,16 @@ describe('displays specific linode plans for GPU', () => {
399406
.click();
400407

401408
// GPU tab
402-
// Should display two separate tables
409+
// Confirm that the expected notice/error banners are present:
410+
//
411+
// - General availability notice explaining that Nvidia Ada plans are available.
412+
// - Region availability error explaining that GPU plans are unavailable in the mocked region.
413+
// - Blackwell GPU availability error explaining that Blackwell plans are unavailable.
403414
cy.findByText('GPU').click();
404415
cy.get(linodePlansPanel).within(() => {
405-
cy.findAllByRole('alert').should('have.length', 3);
416+
cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible');
417+
cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible');
418+
cy.contains(GPU_BLACKWELL_NO_AVAILABILITY_ERROR).should('be.visible');
406419
cy.get(notices.unavailable).should('be.visible');
407420

408421
cy.findByRole('table', {
@@ -440,11 +453,16 @@ describe('displays specific kubernetes plans for GPU', () => {
440453
.click();
441454

442455
// GPU tab
443-
// Should display two separate tables
456+
// Confirm that the expected notice/error banners are present:
457+
//
458+
// - General availability notice explaining that Nvidia Ada plans are available.
459+
// - Region availability error explaining that GPU plans are unavailable in the mocked region.
460+
// - Blackwell GPU availability error explaining that Blackwell plans are unavailable.
444461
cy.findByText('GPU').click();
445462
cy.get(k8PlansPanel).within(() => {
446-
cy.findAllByRole('alert').should('have.length', 3);
447-
cy.get(notices.unavailable).should('be.visible');
463+
cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible');
464+
cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible');
465+
cy.contains(GPU_BLACKWELL_NO_AVAILABILITY_ERROR).should('be.visible');
448466

449467
cy.findByRole('table', {
450468
name: 'List of Linode Plans',

packages/manager/src/featureFlags.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export interface Flags {
248248
ipv6Sharing: boolean;
249249
limitsEvolution: LimitsEvolution;
250250
linodeCloneFirewall: boolean;
251+
linodeCreateBanner: LinodeCreateBanner;
251252
linodeDiskEncryption: boolean;
252253
linodeInterfaces: LinodeInterfacesFlag;
253254
lkeEnterprise2: LkeEnterpriseFlag;
@@ -435,3 +436,8 @@ export type AclpServices = {
435436
interface GenerationalPlansFlag extends BaseFeatureFlag {
436437
allowedPlans: string[];
437438
}
439+
440+
interface LinodeCreateBanner extends BaseFeatureFlag {
441+
message?: string;
442+
pendo_id?: string;
443+
}

packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import * as React from 'react';
55

66
import Paginate from 'src/components/Paginate';
77
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
8-
import { PLAN_PANEL_PAGE_SIZE_OPTIONS } from 'src/features/components/PlansPanel/constants';
8+
import {
9+
PLAN_FILTER_NO_RESULTS_MESSAGE,
10+
PLAN_PANEL_PAGE_SIZE_OPTIONS,
11+
} from 'src/features/components/PlansPanel/constants';
912
import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes';
1013
import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants';
1114

@@ -244,7 +247,9 @@ export const KubernetesPlanContainer = (
244247
const plansToDisplay = effectiveFilterResult?.filteredPlans ?? plans;
245248
const tableEmptyState = shouldDisplayNoRegionSelectedMessage
246249
? null
247-
: (effectiveFilterResult?.emptyState ?? null);
250+
: plansToDisplay.length === 0
251+
? { message: PLAN_FILTER_NO_RESULTS_MESSAGE }
252+
: null;
248253

249254
// Feature gate: if pagination is disabled, render the old way
250255
if (!isGenerationalPlansEnabled) {

packages/manager/src/features/Linodes/LinodeCreate/index.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
useMutateAccountAgreements,
66
useProfile,
77
} from '@linode/queries';
8-
import { CircleProgress, Notice, Stack } from '@linode/ui';
8+
import { CircleProgress, Notice, Stack, Typography } from '@linode/ui';
99
import { scrollErrorIntoView } from '@linode/utilities';
1010
import { useQueryClient } from '@tanstack/react-query';
1111
import {
@@ -19,6 +19,7 @@ import React, { useEffect, useRef } from 'react';
1919
import { FormProvider, useForm } from 'react-hook-form';
2020
import type { SubmitHandler } from 'react-hook-form';
2121

22+
import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner';
2223
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
2324
import { LandingHeader } from 'src/components/LandingHeader';
2425
import { TabPanels } from 'src/components/Tabs/TabPanels';
@@ -45,6 +46,7 @@ import {
4546
useIsLinodeCloneFirewallEnabled,
4647
useIsLinodeInterfacesEnabled,
4748
} from 'src/utilities/linodes';
49+
import { sanitizeHTML } from 'src/utilities/sanitizeHTML';
4850

4951
import { Actions } from './Actions';
5052
import { AdditionalOptions } from './AdditionalOptions/AdditionalOptions';
@@ -88,7 +90,7 @@ export const LinodeCreate = () => {
8890
const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled();
8991
const linodeCreateType = useGetLinodeCreateType();
9092

91-
const { aclpServices } = useFlags();
93+
const { aclpServices, linodeCreateBanner } = useFlags();
9294

9395
// In Create flow, alerts always default to 'legacy' mode
9496
const [isAclpAlertsBetaCreateFlow, setIsAclpAlertsBetaCreateFlow] =
@@ -246,6 +248,26 @@ export const LinodeCreate = () => {
246248
return (
247249
<FormProvider {...form}>
248250
<DocumentTitleSegment segment="Create a Linode" />
251+
{linodeCreateBanner?.enabled && (
252+
<DismissibleBanner
253+
preferenceKey="linode-create-banner"
254+
spacingBottom={8}
255+
variant="info"
256+
{...(linodeCreateBanner?.enabled && {
257+
'data-pendo-id': linodeCreateBanner?.pendo_id,
258+
})}
259+
>
260+
<Typography
261+
dangerouslySetInnerHTML={{
262+
__html: sanitizeHTML({
263+
sanitizingTier: 'flexible',
264+
allowMoreAttrs: ['target'],
265+
text: linodeCreateBanner?.message ?? '',
266+
}),
267+
}}
268+
/>
269+
</DismissibleBanner>
270+
)}
249271
<LandingHeader
250272
breadcrumbProps={{
251273
labelTitle: linodeCreateType,

packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,23 @@
88
* TabPanels keep all tabs mounted in the DOM (only visibility changes).
99
*/
1010

11-
import { Select } from '@linode/ui';
11+
import { Autocomplete, Select } from '@linode/ui';
1212
import * as React from 'react';
1313

1414
import {
1515
PLAN_FILTER_ALL,
16+
PLAN_FILTER_ALL_AVAILABLE,
1617
PLAN_FILTER_GENERATION_G6,
1718
PLAN_FILTER_GENERATION_G7,
1819
PLAN_FILTER_GENERATION_G8,
1920
PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED,
2021
PLAN_FILTER_TYPE_GENERAL_PURPOSE,
2122
} from './constants';
23+
import { getIsPlanDisabled } from './utils';
2224
import {
2325
applyDedicatedPlanFilters,
26+
filterPlansByGeneration,
27+
getGenerationRank,
2428
supportsTypeFiltering,
2529
} from './utils/planFilters';
2630

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

39+
type GenerationOptionWithDisabled = SelectOption<PlanFilterGeneration> & {
40+
isDisabled: boolean;
41+
};
42+
3543
const GENERATION_OPTIONS: SelectOption<PlanFilterGeneration>[] = [
36-
{ label: 'All', value: PLAN_FILTER_ALL },
44+
{ label: 'All Available Plans', value: PLAN_FILTER_ALL_AVAILABLE },
45+
{ label: 'All Plans', value: PLAN_FILTER_ALL },
3746
{ label: 'G8 Dedicated', value: PLAN_FILTER_GENERATION_G8 },
3847
{ label: 'G7 Dedicated', value: PLAN_FILTER_GENERATION_G7 },
3948
{ label: 'G6 Dedicated', value: PLAN_FILTER_GENERATION_G6 },
@@ -61,11 +70,40 @@ const DedicatedPlanFiltersComponent = React.memo(
6170
const { disabled = false, onResult, plans, resetPagination } = props;
6271

6372
// Local state - persists automatically because component stays mounted
64-
const [generation, setGeneration] =
65-
React.useState<PlanFilterGeneration>(PLAN_FILTER_ALL);
73+
const [generation, setGeneration] = React.useState<PlanFilterGeneration>(
74+
PLAN_FILTER_ALL_AVAILABLE
75+
);
6676

6777
const [type, setType] = React.useState<PlanFilterType>(PLAN_FILTER_ALL);
6878

79+
const generationOptions: GenerationOptionWithDisabled[] =
80+
React.useMemo(() => {
81+
const options = GENERATION_OPTIONS.map((option) => ({
82+
...option,
83+
isDisabled: filterPlansByGeneration(plans, option.value).every(
84+
(plan) => getIsPlanDisabled(plan)
85+
),
86+
}));
87+
// Sort options: available first, then all, then by generation (G8 > G7 > G6)
88+
return options.sort((a, b) => {
89+
// "available" always comes first
90+
if (a.value === 'available') return -1;
91+
if (b.value === 'available') return 1;
92+
93+
// "all" always comes second
94+
if (a.value === 'all') return -1;
95+
if (b.value === 'all') return 1;
96+
97+
// enabled options before disabled
98+
if (a.isDisabled !== b.isDisabled) {
99+
return Number(a.isDisabled) - Number(b.isDisabled);
100+
}
101+
102+
// generation order g8 > g7 > g6
103+
return getGenerationRank(b.value) - getGenerationRank(a.value);
104+
});
105+
}, [plans]);
106+
69107
const typeFilteringSupported = supportsTypeFiltering(generation);
70108

71109
const typeOptions = typeFilteringSupported
@@ -74,7 +112,7 @@ const DedicatedPlanFiltersComponent = React.memo(
74112

75113
// Disable type filter if:
76114
// 1. Panel is disabled, OR
77-
// 2. Selected generation doesn't support type filtering (G7, G6, All)
115+
// 2. Selected generation doesn't support type filtering (G7, G6, All, All Available)
78116
const isTypeSelectDisabled = disabled || !typeFilteringSupported;
79117

80118
// Track previous filters to detect changes for pagination reset
@@ -102,14 +140,9 @@ const DedicatedPlanFiltersComponent = React.memo(
102140
}, [generation, resetPagination, type]);
103141

104142
const handleGenerationChange = React.useCallback(
105-
(
106-
_event: React.SyntheticEvent,
107-
option: null | SelectOption<number | string>
108-
) => {
109-
// When clearing, default to "All" instead of undefined
110-
const newGeneration =
111-
(option?.value as PlanFilterGeneration | undefined) ??
112-
PLAN_FILTER_ALL;
143+
(_event: React.SyntheticEvent, option: GenerationOptionWithDisabled) => {
144+
// if option is undefined, default to "All Available" instead
145+
const newGeneration = option?.value ?? PLAN_FILTER_ALL_AVAILABLE;
113146
setGeneration(newGeneration);
114147

115148
// Reset type filter when generation changes
@@ -136,8 +169,10 @@ const DedicatedPlanFiltersComponent = React.memo(
136169
}, [generation, plans, type, typeFilteringSupported]);
137170

138171
const selectedGenerationOption = React.useMemo(() => {
139-
return GENERATION_OPTIONS.find((opt) => opt.value === generation) ?? null;
140-
}, [generation]);
172+
return (
173+
generationOptions.find((opt) => opt.value === generation) ?? undefined
174+
);
175+
}, [generation, generationOptions]);
141176

142177
const selectedTypeOption = React.useMemo(() => {
143178
const displayType = typeFilteringSupported ? type : PLAN_FILTER_ALL;
@@ -156,15 +191,22 @@ const DedicatedPlanFiltersComponent = React.memo(
156191
marginTop: -16,
157192
}}
158193
>
159-
<Select
194+
<Autocomplete
160195
aria-labelledby="plan-filter-generation-label"
161-
clearable
162196
data-testid="plan-filter-generation"
197+
disableClearable
163198
disabled={disabled}
199+
getOptionDisabled={(option) => option.isDisabled || false}
164200
id="plan-filter-generation"
201+
isOptionEqualToValue={(option, value) => {
202+
if (!option || !value) {
203+
return false;
204+
}
205+
return option.value === value.value;
206+
}}
165207
label="Dedicated Plans"
166208
onChange={handleGenerationChange}
167-
options={GENERATION_OPTIONS}
209+
options={generationOptions}
168210
placeholder="Select a plan"
169211
sx={{ width: 360 }}
170212
value={selectedGenerationOption}
@@ -188,7 +230,7 @@ const DedicatedPlanFiltersComponent = React.memo(
188230
return {
189231
filteredPlans,
190232
filterUI,
191-
hasActiveFilters: generation !== PLAN_FILTER_ALL,
233+
hasActiveFilters: generation !== PLAN_FILTER_ALL_AVAILABLE,
192234
};
193235
}, [
194236
disabled,

0 commit comments

Comments
 (0)