diff --git a/packages/api-v4/.changeset/pr-11257-removed-1731594995947.md b/packages/api-v4/.changeset/pr-11257-removed-1731594995947.md new file mode 100644 index 00000000000..041f5e6375e --- /dev/null +++ b/packages/api-v4/.changeset/pr-11257-removed-1731594995947.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Removed +--- + +`deleted` from the `ImageStatus` type ([#11257](https://github.com/linode/manager/pull/11257)) diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index cc1572d449b..4a707b361d8 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -1,8 +1,4 @@ -export type ImageStatus = - | 'available' - | 'creating' - | 'deleted' - | 'pending_upload'; +export type ImageStatus = 'available' | 'creating' | 'pending_upload'; export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; diff --git a/packages/manager/.changeset/pr-11257-changed-1731595083756.md b/packages/manager/.changeset/pr-11257-changed-1731595083756.md new file mode 100644 index 00000000000..81f496b0900 --- /dev/null +++ b/packages/manager/.changeset/pr-11257-changed-1731595083756.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve the status column on the Images landing page ([#11257](https://github.com/linode/manager/pull/11257)) diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 3f265228211..49c084aef1b 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -85,7 +85,7 @@ const assertFailed = (label: string, id: string, message: string) => { cy.get(`[data-qa-image-cell="${id}"]`).within(() => { fbtVisible(label); - fbtVisible('Failed'); + fbtVisible('Upload Failed'); fbtVisible('N/A'); }); }; @@ -99,7 +99,7 @@ const assertFailed = (label: string, id: string, message: string) => { const assertProcessing = (label: string, id: string) => { cy.get(`[data-qa-image-cell="${id}"]`).within(() => { fbtVisible(label); - fbtVisible('Processing'); + fbtVisible('Pending Upload'); fbtVisible('Pending'); }); }; @@ -172,7 +172,7 @@ describe('machine image', () => { cy.get(`[data-qa-image-cell="${mockImage.id}"]`).within(() => { cy.findByText(initialLabel).should('be.visible'); - cy.findByText('Ready').should('be.visible'); + cy.findByText('Available').should('be.visible'); ui.actionMenu .findByTitle(`Action menu for Image ${initialLabel}`) @@ -262,7 +262,7 @@ describe('machine image', () => { ui.toast.assertMessage(availableMessage); cy.get(`[data-qa-image-cell="${imageId}"]`).within(() => { fbtVisible(label); - fbtVisible('Ready'); + fbtVisible('Available'); }); }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx index 0e8b07b032f..c7e499e4ae0 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -7,7 +7,7 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { ImageRegionStatus } from '@linode/api-v4'; +import type { ImageRegionStatus, ImageStatus } from '@linode/api-v4'; import type { Status } from 'src/components/StatusIcon/StatusIcon'; type ExtendedImageRegionStatus = 'unsaved' | ImageRegionStatus; @@ -34,9 +34,7 @@ export const ImageRegionRow = (props: Props) => { {status} - + { ); }; -const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Readonly< - Record +export const imageStatusIconMap: Readonly< + Record > = { available: 'active', creating: 'other', pending: 'other', 'pending deletion': 'other', - 'pending replication': 'inactive', + 'pending replication': 'other', + pending_upload: 'other', replicating: 'other', - timedout: 'inactive', + timedout: 'error', unsaved: 'inactive', }; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index e507170eb39..0440e31b822 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -30,7 +30,7 @@ describe('Image Table Row', () => { capabilities: ['cloud-init', 'distributed-sites'], regions: [ { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, + { region: 'us-southeast', status: 'available' }, ], size: 300, total_size: 600, @@ -45,7 +45,7 @@ describe('Image Table Row', () => { // 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('Available')).toBeVisible(); expect(getByText('Cloud-init, Distributed')).toBeVisible(); expect(getByText('2 Regions')).toBeVisible(); expect(getByText('0.29 GB')).toBeVisible(); // Size is converted from MB to GB - 300 / 1024 = 0.292 diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index b3640a1b32a..1aa8bc50842 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -6,15 +6,14 @@ import { Hidden } from 'src/components/Hidden'; import { LinkButton } from 'src/components/LinkButton'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -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'; import { pluralize } from 'src/utilities/pluralize'; import { convertStorageUnit } from 'src/utilities/unitConversions'; import { ImagesActionMenu } from './ImagesActionMenu'; +import { ImageStatus } from './ImageStatus'; import type { Handlers } from './ImagesActionMenu'; import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; @@ -49,29 +48,12 @@ export const ImageRow = (props: Props) => { const { data: profile } = useProfile(); const flags = useFlags(); - const isFailed = status === 'pending_upload' && event?.status === 'failed'; - const compatibilitiesList = multiRegionsEnabled ? capabilities.map((capability) => capabilityMap[capability]).join(', ') : ''; - const getStatusForImage = (status: string) => { - switch (status) { - case 'creating': - return ( - - ); - case 'available': - return 'Ready'; - case 'pending_upload': - return isFailed ? 'Failed' : 'Processing'; - default: - return capitalizeAllWords(status.replace('_', ' ')); - } - }; + const isFailedUpload = + image.status === 'pending_upload' && event?.status === 'failed'; const getSizeForImage = ( size: number, @@ -87,7 +69,7 @@ export const ImageRow = (props: Props) => { }).format(sizeInGB); return `${formattedSizeInGB} GB`; - } else if (isFailed) { + } else if (isFailedUpload) { return 'N/A'; } else { return 'Pending'; @@ -113,7 +95,9 @@ export const ImageRow = (props: Props) => { )} - {getStatusForImage(status)} + + + {multiRegionsEnabled && ( @@ -170,33 +154,3 @@ export const ImageRow = (props: Props) => { ); }; - -export const isImageUpdating = (e?: Event) => { - // Make Typescript happy, since this function can otherwise technically return undefined - if (!e) { - return false; - } - return ( - e?.action === 'disk_imagize' && ['scheduled', 'started'].includes(e.status) - ); -}; - -const progressFromEvent = (e?: Event) => { - return e?.status === 'started' && e?.percent_complete - ? e.percent_complete - : undefined; -}; - -const ProgressDisplay: React.FC<{ - progress: number | undefined; - text: string; -}> = (props) => { - const { progress, text } = props; - const displayProgress = progress ? `${progress}%` : `scheduled`; - - return ( - - {text}: {displayProgress} - - ); -}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageStatus.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.test.tsx new file mode 100644 index 00000000000..9ddc794d746 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { eventFactory, imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImageStatus } from './ImageStatus'; + +describe('ImageStatus', () => { + it('renders the image status', () => { + const image = imageFactory.build({ status: 'available' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Available')).toBeVisible(); + }); + + it('should render the first region status if any region status is not "available"', () => { + const image = imageFactory.build({ + regions: [ + { region: 'us-west', status: 'available' }, + { region: 'us-east', status: 'pending replication' }, + ], + status: 'available', + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Pending Replication')).toBeVisible(); + }); + + it('should render the image status with a percent if an in progress event is happening', () => { + const image = imageFactory.build({ status: 'creating' }); + const event = eventFactory.build({ + percent_complete: 20, + status: 'started', + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Creating (20%)')).toBeVisible(); + }); + + it('should render "Upload Failed" if image is pending_upload, but we have a failed image upload event', () => { + const image = imageFactory.build({ status: 'pending_upload' }); + const event = eventFactory.build({ + action: 'image_upload', + message: 'Image too large when uncompressed', + status: 'failed', + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('Upload Failed')).toBeVisible(); + expect(getByLabelText('Image too large when uncompressed')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx new file mode 100644 index 00000000000..f16a9909552 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx @@ -0,0 +1,72 @@ +import { Stack } from '@linode/ui'; +import React from 'react'; + +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { capitalizeAllWords } from 'src/utilities/capitalize'; + +import { imageStatusIconMap } from './ImageRegions/ImageRegionRow'; + +import type { Event, Image } from '@linode/api-v4'; + +interface Props { + /** + * The most revent event associated with this Image + */ + event: Event | undefined; + /** + * The Image object + */ + image: Image; +} + +export const ImageStatus = (props: Props) => { + const { event, image } = props; + + if ( + event && + event.status === 'failed' && + event.action === 'image_upload' && + image.status === 'pending_upload' + ) { + // If we have a recent image upload failure, we show the user + // that the upload failed and why. + return ( + + + Upload Failed + {event.message && ( + + )} + + ); + } + + const imageRegionStatus = image.regions.find((r) => r.status !== 'available') + ?.status; + + if (imageRegionStatus) { + // If we have any non-available region statuses, expose the first one as the Image's status to the user + return ( + + + {capitalizeAllWords(imageRegionStatus)} + + ); + } + + const showEventProgress = + event && event.status === 'started' && event.percent_complete !== null; + + return ( + + + {capitalizeAllWords(image.status.replace('_', ' '))} + {showEventProgress && ` (${event.percent_complete}%)`} + + ); +};