From 7735a378a72e3955191b664de8fb16e825949bfb Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:12:10 -0400 Subject: [PATCH] upcoming: [M3-8751& M3-8610] - Image Service Gen 2 final GA tweaks (#11115) * initial work to the table * initial work to the table * save progress * small changes and unit tests * be consistant with flags * add changeset * use new copy for tooltip * put icon before label * Revert "put icon before label" This reverts commit fa4f886c2796db0d291f614dedbd8d9f69cbd464. * put icon before label * remove assertion that is no longer needed for GA changes --------- Co-authored-by: Banks Nussman --- ...r-11115-upcoming-features-1729115799261.md | 5 + .../core/images/manage-image-regions.spec.ts | 3 - .../components/ImageSelect/ImageOption.tsx | 2 +- .../ImageSelectv2/ImageOptionv2.test.tsx | 2 +- .../ImageSelectv2/ImageOptionv2.tsx | 2 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + .../ImagesCreate/CreateImageTab.test.tsx | 34 ++++++ .../Images/ImagesCreate/CreateImageTab.tsx | 37 +++++-- .../Images/ImagesLanding/ImageRow.test.tsx | 100 +++++++++++++----- .../Images/ImagesLanding/ImageRow.tsx | 46 ++++++-- .../Images/ImagesLanding/ImagesLanding.tsx | 16 +-- 12 files changed, 192 insertions(+), 57 deletions(-) create mode 100644 packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md diff --git a/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md b/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md new file mode 100644 index 00000000000..e06f14e10c4 --- /dev/null +++ b/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 9ec97afd8fb..fd2c8cd8787 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -47,9 +47,6 @@ describe('Manage Image Replicas', () => { // Verify total size is rendered cy.findByText(`0.1 GB`).should('be.visible'); // 100 / 1024 = 0.09765 - // Verify capabilities are rendered - cy.findByText('Distributed').should('be.visible'); - // Verify the number of regions is rendered and click it cy.findByText(`${image.regions.length} Regions`) .should('be.visible') diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 362382fe9cd..e6b8ecef3d6 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -81,7 +81,7 @@ export const ImageOption = (props: ImageOptionProps) => { )} {flags.metadata && data.isCloudInitCompatible && ( - + )} diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index c5e0726ddc4..4c6ddfaab35 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -33,7 +33,7 @@ describe('ImageOptionv2', () => { ); expect( - getByLabelText('This image is compatible with cloud-init.') + getByLabelText('This image supports our Metadata service via cloud-init.') ).toBeVisible(); }); it('renders a distributed icon if image has the "distributed-sites" capability', () => { diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index f34e5da413f..a2f6a2638b7 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -46,7 +46,7 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { )} {flags.metadata && image.capabilities.includes('cloud-init') && ( - +
diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 581371a7740..317ad7b4081 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -25,6 +25,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, + { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index a157f0c3dc2..2af7a792b13 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -107,6 +107,7 @@ export interface Flags { gecko2: GeckoFeatureFlag; gpuv2: gpuV2; imageServiceGen2: boolean; + imageServiceGen2Ga: boolean; ipv6Sharing: boolean; linodeDiskEncryption: boolean; mainContentBanner: MainContentBanner; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index aac0cc5349c..70d6c4adbc7 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -163,6 +163,40 @@ describe('CreateImageTab', () => { ); }); + it('should render a notice if the user selects a Linode in a region that does not support image storage and Image Service Gen 2 GA is enabled', async () => { + const region = regionFactory.build({ capabilities: [] }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(, { + flags: { imageServiceGen2: true, imageServiceGen2Ga: true }, + }); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + await findByText( + 'This Linode’s region doesn’t support local image storage.', + { exact: false } + ); + }); + it('should render an encryption notice if disk encryption is enabled and the Linode is not in a distributed compute region', async () => { const region = regionFactory.build({ site_type: 'core' }); const linode = linodeFactory.build({ region: region.id }); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 0c565f74d7a..b2a07c59764 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -14,7 +14,6 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/uti import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; @@ -140,11 +139,20 @@ export const CreateImageTab = () => { const isRawDisk = selectedDisk?.filesystem === 'raw'; - const { data: regionsData } = useRegionsQuery(); + const { data: regions } = useRegionsQuery(); - const linodeIsInDistributedRegion = getIsDistributedRegion( - regionsData ?? [], - selectedLinode?.region ?? '' + const selectedLinodeRegion = regions?.find( + (r) => r.id === selectedLinode?.region + ); + + const linodeIsInDistributedRegion = + selectedLinodeRegion?.site_type === 'distributed'; + + /** + * The 'Object Storage' capability indicates a region can store images + */ + const linodeRegionSupportsImageStorage = selectedLinodeRegion?.capabilities.includes( + 'Object Storage' ); /* @@ -220,7 +228,24 @@ export const CreateImageTab = () => { required value={selectedLinodeId} /> - {linodeIsInDistributedRegion && ( + {selectedLinode && + !linodeRegionSupportsImageStorage && + flags.imageServiceGen2 && + flags.imageServiceGen2Ga && ( + + This Linode’s region doesn’t support local image storage. This + image will be stored in the core compute region that’s{' '} + + geographically closest + + . After it’s stored, you can replicate it to other{' '} + + core compute regions + + . + + )} + {linodeIsInDistributedRegion && !flags.imageServiceGen2Ga && ( This Linode is in a distributed compute region. These regions can't store images. The image is stored in the core compute diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index 1860593c336..e507170eb39 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import * as React from 'react'; +import React from 'react'; import { imageFactory } from 'src/factories'; import { @@ -15,16 +15,6 @@ import type { Handlers } from './ImagesActionMenu'; beforeAll(() => mockMatchMedia()); describe('Image Table Row', () => { - const image = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-sites'], - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - size: 300, - total_size: 600, - }); - const handlers: Handlers = { onCancelFailed: vi.fn(), onDelete: vi.fn(), @@ -35,36 +25,94 @@ describe('Image Table Row', () => { onRetry: vi.fn(), }; - it('should render an image row', async () => { - const { getAllByText, getByLabelText, getByText } = renderWithTheme( + it('should render an image row with Image Service Gen2 enabled', async () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-sites'], + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + size: 300, + total_size: 600, + }); + + const { getByLabelText, getByText } = renderWithTheme( wrapWithTableBody( ) ); // Check to see if the row rendered some data - + expect(getByText(image.label)).toBeVisible(); + expect(getByText(image.id)).toBeVisible(); + expect(getByText('Ready')).toBeVisible(); + expect(getByText('Cloud-init, Distributed')).toBeVisible(); expect(getByText('2 Regions')).toBeVisible(); - expect(getByText('0.29 GB')).toBeVisible(); // 300 / 1024 = 0.292 - expect(getByText('0.59 GB')).toBeVisible(); // 600 / 1024 = 0.585 - - getByText(image.label); - getAllByText('Ready'); - getAllByText('Cloud-init, Distributed'); - getAllByText(image.id); + expect(getByText('0.29 GB')).toBeVisible(); // Size is converted from MB to GB - 300 / 1024 = 0.292 + expect(getByText('0.59 GB')).toBeVisible(); // Size is converted from MB to GB - 600 / 1024 = 0.585 // Open action menu const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); await userEvent.click(actionMenu); - getByText('Edit'); - getByText('Manage Replicas'); - getByText('Deploy to New Linode'); - getByText('Rebuild an Existing Linode'); - getByText('Delete'); + expect(getByText('Edit')).toBeVisible(); + expect(getByText('Manage Replicas')).toBeVisible(); + expect(getByText('Deploy to New Linode')).toBeVisible(); + expect(getByText('Rebuild an Existing Linode')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + }); + + it('should show a cloud-init icon with a tooltip when Image Service Gen 2 GA is enabled and the image supports cloud-init', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init'], + regions: [{ region: 'us-east', status: 'available' }], + }); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } + ) + ); + + expect( + getByLabelText('This image supports our Metadata service via cloud-init.') + ).toBeVisible(); + }); + + it('does not show the compatibility column when Image Service Gen2 GA is enabled', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-sites'], + }); + + const { queryByText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } + ) + ); + + expect(queryByText('Cloud-init, Distributed')).not.toBeInTheDocument(); + }); + + it('should show N/A if multiRegionsEnabled is true, but the Image does not have any regions', () => { + const image = imageFactory.build({ regions: [] }); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true } } + ) + ); + + expect(getByText('N/A')).toBeVisible(); }); it('calls handlers when performing actions', async () => { + const image = imageFactory.build({ + regions: [{ region: 'us-east', status: 'available' }], + }); + const { getByLabelText, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 73cb2527106..c986d0cdbc8 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,10 +1,14 @@ -import * as React from 'react'; +import React from 'react'; +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; import { Hidden } from 'src/components/Hidden'; import { LinkButton } from 'src/components/LinkButton'; +import { Stack } from 'src/components/Stack'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; @@ -44,6 +48,7 @@ export const ImageRow = (props: Props) => { } = image; const { data: profile } = useProfile(); + const flags = useFlags(); const isFailed = status === 'pending_upload' && event?.status === 'failed'; @@ -92,23 +97,42 @@ export const ImageRow = (props: Props) => { return ( - {label} + + {capabilities.includes('cloud-init') && + flags.imageServiceGen2 && + flags.imageServiceGen2Ga ? ( + + +
+ +
+
+ {label} +
+ ) : ( + label + )} +
{getStatusForImage(status)} {multiRegionsEnabled && ( - <> - - + + + {regions.length > 0 ? ( handlers.onManageRegions?.(image)}> {pluralize('Region', 'Regions', regions.length)} - - - - {compatibilitiesList} - - + ) : ( + 'N/A' + )} + + + )} + {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( + + {compatibilitiesList} + )} {getSizeForImage(size, status, event?.status)} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 8311ea04c83..1a52b32b1c4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -450,14 +450,14 @@ export const ImagesLanding = () => { Status {multiRegionsEnabled && ( - <> - - Replicated in - - - Compatibility - - + + Replicated in + + )} + {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( + + Compatibility + )}