From cf411996a2bc3c6fc13a340e71cf2293fa5b3994 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 16:20:13 -0500 Subject: [PATCH 01/14] inital work for re-adding the NVMe upgrade feature --- .../e2e/core/volumes/upgrade-volume.spec.ts | 82 +++++++++++ .../cypress/support/intercepts/volumes.ts | 9 ++ .../LinodeStorage/LinodeVolumes.tsx | 1 + .../features/Volumes/UpgradeVolumeDialog.tsx | 25 ++-- .../features/Volumes/VolumeTableRow.test.tsx | 47 ++++++- .../src/features/Volumes/VolumeTableRow.tsx | 132 +++++++++++++----- .../Volumes/VolumesActionMenu.test.tsx | 1 + .../features/Volumes/VolumesActionMenu.tsx | 1 + .../src/features/Volumes/VolumesLanding.tsx | 15 +- packages/manager/src/queries/volumes.ts | 12 +- .../manager/src/queries/volumesMigrations.ts | 18 ++- 11 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts new file mode 100644 index 00000000000..b7aa41becfe --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -0,0 +1,82 @@ +import { + eventFactory, + notificationFactory, + volumeFactory, +} from '@src/factories'; +import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; +import { + interceptMigrateVolumes, + mockGetVolumes, +} from 'support/intercepts/volumes'; +import { ui } from 'support/ui'; + +describe('volume upgrade/migration', () => { + it('can upgrade an unattached to NVMe', () => { + const volume = volumeFactory.build(); + + const migrationScheduledNotification = notificationFactory.build({ + type: 'volume_migration_scheduled', + entity: { type: 'volume', id: volume.id }, + }); + + mockGetVolumes([volume]).as('getVolumes'); + interceptMigrateVolumes().as('migrateVolumes'); + mockGetNotifications([migrationScheduledNotification]).as( + 'getNotifications' + ); + + cy.visitWithLogin('/volumes'); + + cy.wait(['@getVolumes', '@getNotifications']); + + cy.findByText('UPGRADE TO NVMe') + .should('be.visible') + .should('be.enabled') + .click(); + + const migrationImminentNotification = notificationFactory.build({ + type: 'volume_migration_imminent', + entity: { type: 'volume', id: volume.id }, + }); + mockGetNotifications([migrationImminentNotification]).as( + 'getNotifications' + ); + + ui.dialog.findByTitle(`Upgrade Volume ${volume.label}`).within(() => { + ui.button + .findByTitle('Enter Upgrade Queue') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@migrateVolumes', '@getNotifications']); + + cy.findByText('UPGRADE PENDING').should('be.visible'); + + const mockStartedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume' }, + status: 'started', + }); + + mockGetEvents([mockStartedMigrationEvent]).as('getEvents1'); + + cy.wait('@getEvents1'); + + cy.findByText('migrating').should('be.visible'); + + const mockFinishedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume' }, + status: 'finished', + }); + + mockGetEvents([mockFinishedMigrationEvent]).as('getEvents2'); + mockGetNotifications([]).as('getNotifications'); + + cy.wait(['@getEvents2', '@getVolumes', '@getNotifications']); + + cy.findByText('active').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/volumes.ts b/packages/manager/cypress/support/intercepts/volumes.ts index 622ba081a5a..131183d766f 100644 --- a/packages/manager/cypress/support/intercepts/volumes.ts +++ b/packages/manager/cypress/support/intercepts/volumes.ts @@ -113,3 +113,12 @@ export const interceptDeleteVolume = ( ): Cypress.Chainable => { return cy.intercept('DELETE', apiMatcher(`volumes/${volumeId}`)); }; + +/** + * Intercepts POST request to migrate volumes. + * + * @returns Cypress chainable. + */ +export const interceptMigrateVolumes = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`volumes/migrate`), {}); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx index ca8b429bc93..fb59761f43c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx @@ -144,6 +144,7 @@ export const LinodeVolumes = () => { handleDetails: () => handleDetails(volume), handleEdit: () => handleEdit(volume), handleResize: () => handleResize(volume), + handleUpgrade: () => null, }} isDetailsPageRow key={volume.id} diff --git a/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx b/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx index cb5d3560303..ab854121709 100644 --- a/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx @@ -8,6 +8,8 @@ import { Typography } from 'src/components/Typography'; import { useVolumesMigrateMutation } from 'src/queries/volumesMigrations'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import type { Volume } from '@linode/api-v4'; + interface CopyProps { isManyVolumes?: boolean; label: string; @@ -35,14 +37,13 @@ export const VolumeUpgradeCopy = (props: CopyProps) => { }; interface Props { - id: number; - label: string; onClose: () => void; open: boolean; + volume: Volume | undefined; } export const UpgradeVolumeDialog = (props: Props) => { - const { id, label, onClose, open } = props; + const { onClose, open, volume } = props; const { enqueueSnackbar } = useSnackbar(); const { @@ -52,10 +53,16 @@ export const UpgradeVolumeDialog = (props: Props) => { } = useVolumesMigrateMutation(); const onSubmit = () => { - migrateVolumes([id]).then(() => { - enqueueSnackbar(`Successfully added ${label} to the migration queue.`, { - variant: 'success', - }); + if (!volume) { + return; + } + migrateVolumes([volume.id]).then(() => { + enqueueSnackbar( + `Successfully added ${volume.label} to the migration queue.`, + { + variant: 'success', + } + ); onClose(); }); }; @@ -81,9 +88,9 @@ export const UpgradeVolumeDialog = (props: Props) => { actions={actions} onClose={onClose} open={open} - title={`Upgrade Volume ${label}`} + title={`Upgrade Volume ${volume?.label}`} > - + ); }; diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index 30e32ea53f4..f4b3f367d5b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -1,11 +1,13 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { volumeFactory } from 'src/factories'; +import { notificationFactory, volumeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; -import { VolumeTableRow } from './VolumeTableRow'; import { ActionHandlers } from './VolumesActionMenu'; +import { VolumeTableRow } from './VolumeTableRow'; const attachedVolume = volumeFactory.build({ linode_id: 0, @@ -26,6 +28,7 @@ const handlers: ActionHandlers = { handleDetails: vi.fn(), handleEdit: vi.fn(), handleResize: vi.fn(), + handleUpgrade: vi.fn(), }; describe('Volume table row', () => { @@ -61,6 +64,46 @@ describe('Volume table row', () => { // Make sure there is an attach button expect(getByText('Attach')); }); + + it('should should render an upgrade chip if the volume is eligible for an upgrade', async () => { + const volume = volumeFactory.build({ id: 5 }); + const notification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, + type: 'volume_migration_scheduled', + }); + + server.use( + rest.get('*/account/notifications', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([notification]))); + }) + ); + + const { findByText } = renderWithTheme( + wrapWithTableBody() + ); + + await findByText('UPGRADE TO NVMe'); + }); + + it('should should render an "UPGRADE PENDING" chip if the volume upgrade is imminent', async () => { + const volume = volumeFactory.build({ id: 5 }); + const notification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, + type: 'volume_migration_imminent', + }); + + server.use( + rest.get('*/account/notifications', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([notification]))); + }) + ); + + const { findByText } = renderWithTheme( + wrapWithTableBody() + ); + + await findByText('UPGRADE PENDING'); + }); }); describe('Volume table row - for linodes detail page', () => { diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 1b0921cc45b..0f664bc7feb 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -1,24 +1,24 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { Volume } from '@linode/api-v4/lib/volumes/types'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; +import { Chip } from 'src/components/Chip'; import { Hidden } from 'src/components/Hidden'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Status } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; +import { useNotificationsQuery } from 'src/queries/accountNotifications'; +import { useInProgressEvents } from 'src/queries/events/events'; import { useRegionsQuery } from 'src/queries/regions'; import { ActionHandlers, VolumesActionMenu } from './VolumesActionMenu'; +import type { Event, Volume } from '@linode/api-v4'; + export const useStyles = makeStyles()({ - chipWrapper: { - alignSelf: 'center', - }, volumePath: { width: '35%', wordBreak: 'break-all', @@ -31,30 +31,6 @@ interface Props { volume: Volume; } -export const progressFromEvent = (e?: Event) => { - if (!e) { - return undefined; - } - - if (e.status === 'started' && e.percent_complete) { - return e.percent_complete; - } - - return undefined; -}; - -export const isVolumeUpdating = (e?: Event) => { - // Make Typescript happy, since this function can otherwise technically return undefined - if (!e) { - return false; - } - return ( - e && - ['volume_attach', 'volume_create', 'volume_detach'].includes(e.action) && - ['scheduled', 'started'].includes(e.status) - ); -}; - export const volumeStatusIconMap: Record = { active: 'active', creating: 'other', @@ -63,6 +39,32 @@ export const volumeStatusIconMap: Record = { resizing: 'other', }; +/** + * Given an in-progress event and a volume's status, this function + * returns a volume's status with event info taken into account. + * + * We do this to provide users with a real-time feeling experience + * without having to refetch a volume's status agressivly. + * + * @param status The actual volume status from the volumes endpoint + * @param event An in-progress event for the volume + * @returns a volume status + */ +const getDerivedVolumeStatusFromStatusAndEvent = ( + status: Volume['status'], + event: Event | undefined +): Volume['status'] => { + if (event === undefined) { + return status; + } + + if (event.action === 'volume_migrate' && event.status === 'started') { + return 'migrating'; + } + + return status; +}; + export const VolumeTableRow = React.memo((props: Props) => { const { classes } = useStyles(); const { data: regions } = useRegionsQuery(); @@ -70,6 +72,53 @@ export const VolumeTableRow = React.memo((props: Props) => { const isVolumesLanding = !isDetailsPageRow; + const { data: notifications } = useNotificationsQuery(); + const { data: inProgressEvents } = useInProgressEvents(); + + /** + * Once a migration is scheduled by Linode and eligible for an upgrade, + * the customer will receive a `volume_migration_scheduled` notification + */ + const isEligibleForUpgradeToNVMe = notifications?.some( + (notification) => + notification.type === 'volume_migration_scheduled' && + notification.entity?.id === volume.id + ); + + /** + * Once a migration's scheduled date has passed, the customer will receive + * a `volume_migration_imminent` notification instead of the `volume_migration_scheduled` notification. + * + * This means that the migration will start when it gets picked up by a backend worker. + * The volume's status is set to `migrating` and a `volume_migrate` event is created. + */ + const isNVMeUpgradeImminent = notifications?.some( + (notification) => + notification.type === 'volume_migration_imminent' && + notification.entity?.id === volume.id + ); + + const mostRecentVolumeEvent = inProgressEvents?.find( + (event) => event.entity?.id === volume.id && event.entity.type === 'volume' + ); + + const volumeStatus = getDerivedVolumeStatusFromStatusAndEvent( + volume.status, + mostRecentVolumeEvent + ); + + const isVolumeMigrating = volumeStatus === 'migrating'; + + const handleUpgrade = () => { + if (isDetailsPageRow) { + // If we try to upgrade a volume from the Linode details page, we + // open a dialog that makes the user upgrade all of the attached volumes at once. + // @todo add this + } else { + handlers.handleUpgrade(); + } + }; + const regionLabel = regions?.find((r) => r.id === volume.region)?.label ?? volume.region; @@ -85,25 +134,36 @@ export const VolumeTableRow = React.memo((props: Props) => { }} > {volume.label} + {isEligibleForUpgradeToNVMe && ( + + )} + {isNVMeUpgradeImminent && !isVolumeMigrating && ( + + )} - - {volume.status.replace('_', ' ')} + + {volumeStatus} - {isVolumesLanding && volume.region ? ( + {isVolumesLanding && ( {regionLabel} - ) : null} + )} {volume.size} GB - {!isVolumesLanding ? ( + {!isVolumesLanding && ( {volume.filesystem_path} - ) : null} + )} {isVolumesLanding && ( {volume.linode_id !== null ? ( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx index 2123552f8b3..ed035f5609c 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx @@ -17,6 +17,7 @@ const props: Props = { handleDetails: vi.fn(), handleEdit: vi.fn(), handleResize: vi.fn(), + handleUpgrade: vi.fn(), }, isVolumesLanding: true, volume, diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index 62c7fc4db22..97fb11c9d9d 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -15,6 +15,7 @@ export interface ActionHandlers { handleDetails: () => void; handleEdit: () => void; handleResize: () => void; + handleUpgrade: () => void; } export interface Props { diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index afb576a03b1..438a5196b59 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -23,9 +23,10 @@ import { DeleteVolumeDialog } from './DeleteVolumeDialog'; import { DetachVolumeDialog } from './DetachVolumeDialog'; import { EditVolumeDrawer } from './EditVolumeDrawer'; import { ResizeVolumeDrawer } from './ResizeVolumeDrawer'; +import { UpgradeVolumeDialog } from './UpgradeVolumeDialog'; import { VolumeDetailsDrawer } from './VolumeDetailsDrawer'; -import { VolumeTableRow } from './VolumeTableRow'; import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; +import { VolumeTableRow } from './VolumeTableRow'; import type { Volume } from '@linode/api-v4'; @@ -69,6 +70,7 @@ export const VolumesLanding = () => { const [isAttachDrawerOpen, setIsAttachDrawerOpen] = React.useState(false); const [isDetachDialogOpen, setIsDetachDialogOpen] = React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = React.useState(false); const selectedVolume = volumes?.data.find((v) => v.id === selectedVolumeId); @@ -107,6 +109,11 @@ export const VolumesLanding = () => { setIsAttachDrawerOpen(true); }; + const handleUpgrade = (volume: Volume) => { + setSelectedVolumeId(volume.id); + setIsUpgradeDialogOpen(true); + }; + if (isLoading) { return ; } @@ -177,6 +184,7 @@ export const VolumesLanding = () => { handleDetails: () => handleDetails(volume), handleEdit: () => handleEdit(volume), handleResize: () => handleResize(volume), + handleUpgrade: () => handleUpgrade(volume), }} key={volume.id} volume={volume} @@ -227,6 +235,11 @@ export const VolumesLanding = () => { open={isDetachDialogOpen} volume={selectedVolume} /> + setIsUpgradeDialogOpen(false)} + open={isUpgradeDialogOpen} + volume={selectedVolume} + /> setIsDeleteDialogOpen(false)} open={isDeleteDialogOpen} diff --git a/packages/manager/src/queries/volumes.ts b/packages/manager/src/queries/volumes.ts index bce51491ada..5b4098d1534 100644 --- a/packages/manager/src/queries/volumes.ts +++ b/packages/manager/src/queries/volumes.ts @@ -24,11 +24,12 @@ import { useQueryClient, } from 'react-query'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; +import { queryKey as notificationsQueryKey } from './accountNotifications'; import { updateInPaginatedStore } from './base'; import { queryKey as PROFILE_QUERY_KEY } from './profile'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; export const queryKey = 'volumes'; @@ -185,6 +186,15 @@ export const volumeEventsHandler = ({ queryClient.invalidateQueries([queryKey]); } + if ( + event.action === 'volume_migrate' && + (event.status === 'finished' || event.status === 'failed') + ) { + // if a migration finishes, we want to re-request notifications so that the `volume_migration_imminent` + // notification goes away. + queryClient.invalidateQueries(notificationsQueryKey); + } + if (event.action === 'volume_clone') { // The API gives us no way to know when a cloned volume transitions from // creating to active, so we will just refresh after 10 seconds diff --git a/packages/manager/src/queries/volumesMigrations.ts b/packages/manager/src/queries/volumesMigrations.ts index d59d51fbcb1..12ceb063c00 100644 --- a/packages/manager/src/queries/volumesMigrations.ts +++ b/packages/manager/src/queries/volumesMigrations.ts @@ -4,8 +4,9 @@ import { migrateVolumes, } from '@linode/api-v4/lib/volumes/migrations'; import { VolumesMigrationQueue } from '@linode/api-v4/lib/volumes/types'; -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { queryKey as notificationsQueryKey } from './accountNotifications'; import { queryPresets } from './base'; const queryKey = 'volumes-migrations'; @@ -20,5 +21,16 @@ export const useVolumesMigrationQueueQuery = ( { ...queryPresets.shortLived, enabled } ); -export const useVolumesMigrateMutation = () => - useMutation<{}, APIError[], number[]>(migrateVolumes); +export const useVolumesMigrateMutation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[], number[]>({ + mutationFn: migrateVolumes, + onSuccess: () => { + // If a customer "force" migrates they will then see a + // `volume_migration_imminent` notification instead of + // the `volume_migration_scheduled` notification. + queryClient.invalidateQueries(notificationsQueryKey); + }, + }); +}; From bcc0cb22049b5d0dd1e4664b8e48186fef2670c7 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 16:37:24 -0500 Subject: [PATCH 02/14] expose progress --- .../e2e/core/volumes/upgrade-volume.spec.ts | 19 +++++++++++-------- .../src/features/Volumes/VolumeTableRow.tsx | 14 +++++++++++++- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index b7aa41becfe..e95750e47a7 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -54,17 +54,20 @@ describe('volume upgrade/migration', () => { cy.findByText('UPGRADE PENDING').should('be.visible'); - const mockStartedMigrationEvent = eventFactory.build({ - action: 'volume_migrate', - entity: { id: volume.id, type: 'volume' }, - status: 'started', - }); + for (const percentage of [10, 20, 50, 75]) { + const mockStartedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume' }, + status: 'started', + percent_complete: percentage, + }); - mockGetEvents([mockStartedMigrationEvent]).as('getEvents1'); + mockGetEvents([mockStartedMigrationEvent]).as('getEvents1'); - cy.wait('@getEvents1'); + cy.wait('@getEvents1'); - cy.findByText('migrating').should('be.visible'); + cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + } const mockFinishedMigrationEvent = eventFactory.build({ action: 'volume_migrate', diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 0f664bc7feb..282b34c9c49 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -65,6 +65,18 @@ const getDerivedVolumeStatusFromStatusAndEvent = ( return status; }; +const getEventProgress = (event: Event | undefined) => { + if ( + event === undefined || + event.percent_complete === null || + event.status !== 'started' + ) { + return null; + } + + return `(${event.percent_complete}%)`; +}; + export const VolumeTableRow = React.memo((props: Props) => { const { classes } = useStyles(); const { data: regions } = useRegionsQuery(); @@ -149,7 +161,7 @@ export const VolumeTableRow = React.memo((props: Props) => { - {volumeStatus} + {volumeStatus} {getEventProgress(mostRecentVolumeEvent)} {isVolumesLanding && ( From 111323aaf80148efac4c7b4c0ef144a39239dbf1 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 16:50:01 -0500 Subject: [PATCH 03/14] clean up the cypress test --- .../cypress/e2e/core/volumes/upgrade-volume.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index e95750e47a7..6ef6be377c4 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -62,9 +62,9 @@ describe('volume upgrade/migration', () => { percent_complete: percentage, }); - mockGetEvents([mockStartedMigrationEvent]).as('getEvents1'); + mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); - cy.wait('@getEvents1'); + cy.wait('@getEvents'); cy.findByText(`migrating (${percentage}%)`).should('be.visible'); } @@ -75,10 +75,10 @@ describe('volume upgrade/migration', () => { status: 'finished', }); - mockGetEvents([mockFinishedMigrationEvent]).as('getEvents2'); + mockGetEvents([mockFinishedMigrationEvent]).as('getEvents'); mockGetNotifications([]).as('getNotifications'); - cy.wait(['@getEvents2', '@getVolumes', '@getNotifications']); + cy.wait(['@getEvents', '@getVolumes', '@getNotifications']); cy.findByText('active').should('be.visible'); }); From faed07dadd818a200950fa0004ab8549ece2872b Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 19:19:02 -0500 Subject: [PATCH 04/14] finish up and test --- .../e2e/core/volumes/upgrade-volume.spec.ts | 188 +++++++++++++++++- .../LinodeDetailHeader.tsx | 9 + .../UpgradeVolumesDialog.tsx | 61 +++--- .../LinodesDetail/VolumesUpgradeBanner.tsx | 89 +++++++++ .../VolumesUpgradeBenner.test.tsx | 100 ++++++++++ .../features/Volumes/VolumeTableRow.test.tsx | 59 +++++- .../src/features/Volumes/VolumeTableRow.tsx | 30 ++- .../src/hooks/useToastNotifications.tsx | 4 + 8 files changed, 495 insertions(+), 45 deletions(-) create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index 6ef6be377c4..8ba10a03390 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -1,9 +1,15 @@ import { eventFactory, + linodeFactory, notificationFactory, volumeFactory, } from '@src/factories'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; import { interceptMigrateVolumes, mockGetVolumes, @@ -11,7 +17,7 @@ import { import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { - it('can upgrade an unattached to NVMe', () => { + it('can upgrade an unattached volume to NVMe', () => { const volume = volumeFactory.build(); const migrationScheduledNotification = notificationFactory.build({ @@ -71,7 +77,7 @@ describe('volume upgrade/migration', () => { const mockFinishedMigrationEvent = eventFactory.build({ action: 'volume_migrate', - entity: { id: volume.id, type: 'volume' }, + entity: { id: volume.id, type: 'volume', label: volume.label }, status: 'finished', }); @@ -81,5 +87,183 @@ describe('volume upgrade/migration', () => { cy.wait(['@getEvents', '@getVolumes', '@getNotifications']); cy.findByText('active').should('be.visible'); + + ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + }); + + it('can upgrade an attached volume from the volumes landing page', () => { + const linode = linodeFactory.build(); + const volume = volumeFactory.build({ + linode_id: linode.id, + linode_label: linode.label, + }); + + const migrationScheduledNotification = notificationFactory.build({ + type: 'volume_migration_scheduled', + entity: { type: 'volume', id: volume.id }, + }); + + mockGetVolumes([volume]).as('getVolumes'); + interceptMigrateVolumes().as('migrateVolumes'); + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeDisks(linode.id, []); + mockGetNotifications([migrationScheduledNotification]).as( + 'getNotifications' + ); + mockGetLinodeVolumes(linode.id, [volume]).as('getLinodeVolumes'); + + cy.visitWithLogin('/volumes'); + + cy.wait(['@getVolumes', '@getNotifications']); + + cy.findByText('UPGRADE TO NVMe') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('contain', `/linodes/${linode.id}/storage?upgrade=true`); + + cy.wait(['@getLinode', '@getLinodeVolumes']); + + const migrationImminentNotification = notificationFactory.build({ + type: 'volume_migration_imminent', + entity: { type: 'volume', id: volume.id }, + }); + mockGetNotifications([migrationImminentNotification]).as( + 'getNotifications' + ); + + ui.dialog.findByTitle('Upgrade Volume').within(() => { + cy.findByText( + `A Volume attached to Linode ${linode.label} will be upgraded to high-performance NVMe Block Storage.`, + { exact: false } + ).should('be.visible'); + + ui.button + .findByTitle('Enter Upgrade Queue') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.wait(['@migrateVolumes', '@getNotifications']); + + cy.findByText('UPGRADE PENDING').should('be.visible'); + + for (const percentage of [10, 20, 50, 75]) { + const mockStartedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume' }, + status: 'started', + percent_complete: percentage, + }); + + mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); + + cy.wait('@getEvents'); + + cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + } + + const mockFinishedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume', label: volume.label }, + status: 'finished', + }); + + mockGetEvents([mockFinishedMigrationEvent]).as('getEvents'); + mockGetNotifications([]).as('getNotifications'); + + cy.wait(['@getEvents', '@getLinodeVolumes', '@getNotifications']); + + cy.findByText('active').should('be.visible'); + + ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + }); + + it('can upgrade an attached volume from the linode details page', () => { + const linode = linodeFactory.build(); + const volume = volumeFactory.build({ + linode_id: linode.id, + linode_label: linode.label, + }); + + const migrationScheduledNotification = notificationFactory.build({ + type: 'volume_migration_scheduled', + entity: { type: 'volume', id: volume.id }, + }); + + interceptMigrateVolumes().as('migrateVolumes'); + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeDisks(linode.id, []); + mockGetNotifications([migrationScheduledNotification]).as( + 'getNotifications' + ); + mockGetLinodeVolumes(linode.id, [volume]).as('getLinodeVolumes'); + + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + + cy.wait(['@getLinode', '@getLinodeVolumes', '@getNotifications']); + + ui.button + .findByTitle('Upgrade Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + const migrationImminentNotification = notificationFactory.build({ + type: 'volume_migration_imminent', + entity: { type: 'volume', id: volume.id }, + }); + mockGetNotifications([migrationImminentNotification]).as( + 'getNotifications' + ); + + ui.dialog.findByTitle('Upgrade Volume').within(() => { + cy.findByText( + `A Volume attached to Linode ${linode.label} will be upgraded to high-performance NVMe Block Storage.`, + { exact: false } + ).should('be.visible'); + + ui.button + .findByTitle('Enter Upgrade Queue') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.wait(['@migrateVolumes', '@getNotifications']); + + cy.findByText('UPGRADE PENDING').should('be.visible'); + + for (const percentage of [10, 20, 50, 75]) { + const mockStartedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume' }, + status: 'started', + percent_complete: percentage, + }); + + mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); + + cy.wait('@getEvents'); + + cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + } + + const mockFinishedMigrationEvent = eventFactory.build({ + action: 'volume_migrate', + entity: { id: volume.id, type: 'volume', label: volume.label }, + status: 'finished', + }); + + mockGetEvents([mockFinishedMigrationEvent]).as('getEvents'); + mockGetNotifications([]).as('getNotifications'); + + cy.wait(['@getEvents', '@getLinodeVolumes', '@getNotifications']); + + mockGetEvents([]); + + cy.findByText('active').should('be.visible'); + + ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index 75397e492bf..124aa6ad440 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -35,9 +35,11 @@ import { EnableBackupsDialog } from '../LinodeBackup/EnableBackupsDialog'; import { LinodeRebuildDialog } from '../LinodeRebuild/LinodeRebuildDialog'; import { RescueDialog } from '../LinodeRescue/RescueDialog'; import { LinodeResize } from '../LinodeResize/LinodeResize'; +import { VolumesUpgradeBanner } from '../VolumesUpgradeBanner'; import { HostMaintenance } from './HostMaintenance'; import { MutationNotification } from './MutationNotification'; import Notifications from './Notifications'; +import { UpgradeVolumesDialog } from './UpgradeVolumesDialog'; interface TagDrawerProps { open: boolean; @@ -93,6 +95,7 @@ const LinodeDetailHeader = () => { const [enableBackupsDialogOpen, setEnableBackupsDialogOpen] = React.useState( false ); + const isUpgradeVolumesDialogOpen = queryParams.upgrade === 'true'; const [tagDrawer, setTagDrawer] = React.useState({ open: false, @@ -225,6 +228,7 @@ const LinodeDetailHeader = () => { + { onClose={closeDialogs} open={migrateDialogOpen} /> + void; open: boolean; - upgradeableVolumeIds: number[]; } -const useStyles = makeStyles()((theme: Theme) => ({ - notice: { - borderLeft: `solid 6px ${theme.color.yellow}`, - marginTop: theme.spacing(2), - padding: theme.spacing(), - }, -})); - export const UpgradeVolumesDialog = (props: Props) => { - const { linode, onClose, open, upgradeableVolumeIds } = props; + const { linode, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { classes } = useStyles(); - const queryClient = useQueryClient(); const { error, @@ -43,29 +32,35 @@ export const UpgradeVolumesDialog = (props: Props) => { mutateAsync: migrateVolumes, } = useVolumesMigrateMutation(); - const numUpgradeableVolumes = upgradeableVolumeIds.length; + const { data: volumesData } = useLinodeVolumesQuery(linode.id); + const { data: notifications } = useNotificationsQuery(); + + const volumeIdsEligibleForUpgrade = getUpgradeableVolumeIds( + volumesData?.data ?? [], + notifications ?? [] + ); + + const numUpgradeableVolumes = volumeIdsEligibleForUpgrade.length; const onSubmit = () => { - migrateVolumes(upgradeableVolumeIds).then(() => { + migrateVolumes(volumeIdsEligibleForUpgrade).then(() => { enqueueSnackbar( `Successfully added ${linode.label}\u{2019}s volumes to the migration queue.`, { variant: 'success' } ); - // Re-request notifications so the Upgrade Volume banner on the Linode Detail page disappears. - queryClient.invalidateQueries(queryKey); onClose(); }); }; const actions = ( - + - + ); return ( @@ -80,17 +75,19 @@ export const UpgradeVolumesDialog = (props: Props) => { open={open} title={`Upgrade Volume${numUpgradeableVolumes === 1 ? '' : 's'}`} > - + 1} label={linode.label} type="linode" /> - - As part of the upgrade process, this Linode may be rebooted and will - be returned to its last known state prior to the upgrade. - - + + + As part of the upgrade process, this Linode may be rebooted and will + be returned to its last known state prior to the upgrade. + + + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx new file mode 100644 index 00000000000..9bda73f011e --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Button } from 'src/components/Button/Button'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useNotificationsQuery } from 'src/queries/accountNotifications'; +import { useLinodeVolumesQuery } from 'src/queries/volumes'; + +import type { Notification, Volume } from '@linode/api-v4'; + +interface Props { + linodeId: number; +} + +export const getUpgradeableVolumeIds = ( + volumes: Volume[], + notifications: Notification[] +) => { + const upgradeableVolumeIds: number[] = []; + + for (const volume of volumes) { + if ( + notifications?.some( + (notification) => + notification.entity?.id === volume.id && + notification.type === 'volume_migration_scheduled' + ) + ) { + upgradeableVolumeIds.push(volume.id); + } + } + + return upgradeableVolumeIds; +}; + +export const VolumesUpgradeBanner = ({ linodeId }: Props) => { + const history = useHistory(); + + const { data: volumesData } = useLinodeVolumesQuery(linodeId); + const { data: notifications } = useNotificationsQuery(); + + const volumeIdsEligibleForUpgrade = getUpgradeableVolumeIds( + volumesData?.data ?? [], + notifications ?? [] + ); + + const numUpgradeableVolumes = volumeIdsEligibleForUpgrade.length; + + if (numUpgradeableVolumes === 0) { + return null; + } + + return ( + + + + + {numUpgradeableVolumes === 1 + ? 'A Volume attached to this Linode is ' + : 'Volumes attached to this Linode are '} + eligible for a free upgrade to high performance NVMe Block + Storage.{' '} + + Learn More + + . + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx new file mode 100644 index 00000000000..e98d4095efb --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { notificationFactory, volumeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + VolumesUpgradeBanner, + getUpgradeableVolumeIds, +} from './VolumesUpgradeBanner'; + +describe('VolumesUpgradeBanner', () => { + it('should render if there is an upgradable volume', async () => { + const volume = volumeFactory.build(); + const notification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, + type: 'volume_migration_scheduled', + }); + + server.use( + rest.get('*/linode/instances/:id/volumes', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([volume]))); + }), + rest.get('*/account/notifications', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([notification]))); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('A Volume attached to this Linode is eligible', { + exact: false, + }); + }); + + it('should render if there are many upgradable volumes', async () => { + const volumes = volumeFactory.buildList(5); + + const notifications = [ + notificationFactory.build({ + entity: { id: volumes[0].id, type: 'volume' }, + type: 'volume_migration_scheduled', + }), + notificationFactory.build({ + entity: { id: volumes[1].id, type: 'volume' }, + type: 'volume_migration_scheduled', + }), + ]; + + server.use( + rest.get('*/linode/instances/:id/volumes', (req, res, ctx) => { + return res(ctx.json(makeResourcePage(volumes))); + }), + rest.get('*/account/notifications', (req, res, ctx) => { + return res(ctx.json(makeResourcePage(notifications))); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Volumes attached to this Linode are eligible', { + exact: false, + }); + }); +}); + +describe('getUpgradeableVolumeIds', () => { + it('should return the id of volumes that have a corosponding upgrade notification', () => { + const volumes = [ + volumeFactory.build({ id: 1 }), + volumeFactory.build({ id: 2 }), + volumeFactory.build({ id: 3 }), + ]; + + const notifications = [ + notificationFactory.build({ + entity: { id: 1 }, + type: 'volume_migration_scheduled', + }), + notificationFactory.build({ + entity: { id: 3 }, + type: 'volume_migration_scheduled', + }), + ]; + + expect(getUpgradeableVolumeIds(volumes, notifications)).toStrictEqual([ + 1, + 3, + ]); + }); + + it('should return an empty array given empty data', () => { + expect(getUpgradeableVolumeIds([], [])).toStrictEqual([]); + }); +}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index f4b3f367d5b..64956fb682e 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -1,13 +1,21 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { notificationFactory, volumeFactory } from 'src/factories'; +import { + eventFactory, + notificationFactory, + volumeFactory, +} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { ActionHandlers } from './VolumesActionMenu'; -import { VolumeTableRow } from './VolumeTableRow'; +import { + VolumeTableRow, + getDerivedVolumeStatusFromStatusAndEvent, + getEventProgress, +} from './VolumeTableRow'; const attachedVolume = volumeFactory.build({ linode_id: 0, @@ -140,3 +148,50 @@ describe('Volume table row - for linodes detail page', () => { expect(getByText('Detach')); }); }); + +describe('getDerivedVolumeStatusFromStatusAndEvent', () => { + it('should return the volume staus if no event exists', () => { + const volume = volumeFactory.build(); + expect( + getDerivedVolumeStatusFromStatusAndEvent(volume.status, undefined) + ).toBe(volume.status); + }); + + it('should migrating if a migration event is in progress regardless of what the volume status actually is', () => { + const volume = volumeFactory.build({ status: 'active' }); + const event = eventFactory.build({ + action: 'volume_migrate', + status: 'started', + }); + + expect(getDerivedVolumeStatusFromStatusAndEvent(volume.status, event)).toBe( + 'migrating' + ); + }); +}); + +describe('getEventProgress', () => { + it('should return null if the status is not "started"', () => { + const event = eventFactory.build({ + percent_complete: 20, + status: 'finished', + }); + expect(getEventProgress(event)).toBe(null); + }); + + it('should return null if the API does not return a percentage', () => { + const event = eventFactory.build({ + percent_complete: null, + status: 'started', + }); + expect(getEventProgress(event)).toBe(null); + }); + + it('should return a formatted percenatge if the API returns a percentage and the event is "started"', () => { + const event = eventFactory.build({ + percent_complete: 25, + status: 'started', + }); + expect(getEventProgress(event)).toBe('(25%)'); + }); +}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 282b34c9c49..43a08818d14 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; @@ -50,7 +50,7 @@ export const volumeStatusIconMap: Record = { * @param event An in-progress event for the volume * @returns a volume status */ -const getDerivedVolumeStatusFromStatusAndEvent = ( +export const getDerivedVolumeStatusFromStatusAndEvent = ( status: Volume['status'], event: Event | undefined ): Volume['status'] => { @@ -65,7 +65,16 @@ const getDerivedVolumeStatusFromStatusAndEvent = ( return status; }; -const getEventProgress = (event: Event | undefined) => { +/** + * Returns a nicely formated percentage from an event + * only if the event is in progress and has a percentage. + * + * This allows us to show the user the progress of a + * volume migration. + * + * @returns "(50%)" for example + */ +export const getEventProgress = (event: Event | undefined) => { if ( event === undefined || event.percent_complete === null || @@ -79,14 +88,16 @@ const getEventProgress = (event: Event | undefined) => { export const VolumeTableRow = React.memo((props: Props) => { const { classes } = useStyles(); - const { data: regions } = useRegionsQuery(); const { handlers, isDetailsPageRow, volume } = props; - const isVolumesLanding = !isDetailsPageRow; + const history = useHistory(); + const { data: regions } = useRegionsQuery(); const { data: notifications } = useNotificationsQuery(); const { data: inProgressEvents } = useInProgressEvents(); + const isVolumesLanding = !isDetailsPageRow; + /** * Once a migration is scheduled by Linode and eligible for an upgrade, * the customer will receive a `volume_migration_scheduled` notification @@ -122,10 +133,11 @@ export const VolumeTableRow = React.memo((props: Props) => { const isVolumeMigrating = volumeStatus === 'migrating'; const handleUpgrade = () => { - if (isDetailsPageRow) { - // If we try to upgrade a volume from the Linode details page, we - // open a dialog that makes the user upgrade all of the attached volumes at once. - // @todo add this + if (volume.linode_id !== null) { + // If the volume is attached to a Linode, we force the user + // to upgrade all of the Linode's volumes at once. + // I don't like this, but this is how UX wanted it when this was first built. + history.push(`/linodes/${volume.linode_id}/storage?upgrade=true`); } else { handlers.handleUpgrade(); } diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index 684bd6016d5..22d252cec42 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -138,6 +138,10 @@ const toasts: Toasts = { failure: (e) => `Error detaching Volume ${getLabel(e)}.`, success: (e) => `Volume ${getLabel(e)} successfully detached.`, }, + volume_migrate: { + failure: (e) => `Error upgrading Volume ${getLabel(e)}.`, + success: (e) => `Volume ${getLabel(e)} successfully upgraded.`, + }, }; export const useToastNotifications = () => { From 1b44eb6f33c183b16080a33ad2324334bebb0c4b Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 19:35:39 -0500 Subject: [PATCH 05/14] move utils to seperate file --- .../UpgradeVolumesDialog.tsx | 3 +- .../LinodesDetail/VolumesUpgradeBanner.tsx | 24 +---- .../VolumesUpgradeBenner.test.tsx | 35 +------- .../features/Volumes/VolumeTableRow.test.tsx | 59 +------------ .../src/features/Volumes/VolumeTableRow.tsx | 63 ++----------- .../src/features/Volumes/utils.test.ts | 88 +++++++++++++++++++ .../manager/src/features/Volumes/utils.ts | 78 ++++++++++++++++ 7 files changed, 177 insertions(+), 173 deletions(-) create mode 100644 packages/manager/src/features/Volumes/utils.test.ts create mode 100644 packages/manager/src/features/Volumes/utils.ts diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx index c8bb8b1f70d..8858201acf9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx @@ -7,13 +7,12 @@ import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { VolumeUpgradeCopy } from 'src/features/Volumes/UpgradeVolumeDialog'; +import { getUpgradeableVolumeIds } from 'src/features/Volumes/utils'; import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { useLinodeVolumesQuery } from 'src/queries/volumes'; import { useVolumesMigrateMutation } from 'src/queries/volumesMigrations'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getUpgradeableVolumeIds } from '../VolumesUpgradeBanner'; - import type { Linode } from '@linode/api-v4'; interface Props { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx index 9bda73f011e..a3e1f0b346b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx @@ -7,36 +7,14 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; +import { getUpgradeableVolumeIds } from 'src/features/Volumes/utils'; import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { useLinodeVolumesQuery } from 'src/queries/volumes'; -import type { Notification, Volume } from '@linode/api-v4'; - interface Props { linodeId: number; } -export const getUpgradeableVolumeIds = ( - volumes: Volume[], - notifications: Notification[] -) => { - const upgradeableVolumeIds: number[] = []; - - for (const volume of volumes) { - if ( - notifications?.some( - (notification) => - notification.entity?.id === volume.id && - notification.type === 'volume_migration_scheduled' - ) - ) { - upgradeableVolumeIds.push(volume.id); - } - } - - return upgradeableVolumeIds; -}; - export const VolumesUpgradeBanner = ({ linodeId }: Props) => { const history = useHistory(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx index e98d4095efb..ca51ccc58e3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx @@ -5,10 +5,7 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - VolumesUpgradeBanner, - getUpgradeableVolumeIds, -} from './VolumesUpgradeBanner'; +import { VolumesUpgradeBanner } from './VolumesUpgradeBanner'; describe('VolumesUpgradeBanner', () => { it('should render if there is an upgradable volume', async () => { @@ -68,33 +65,3 @@ describe('VolumesUpgradeBanner', () => { }); }); }); - -describe('getUpgradeableVolumeIds', () => { - it('should return the id of volumes that have a corosponding upgrade notification', () => { - const volumes = [ - volumeFactory.build({ id: 1 }), - volumeFactory.build({ id: 2 }), - volumeFactory.build({ id: 3 }), - ]; - - const notifications = [ - notificationFactory.build({ - entity: { id: 1 }, - type: 'volume_migration_scheduled', - }), - notificationFactory.build({ - entity: { id: 3 }, - type: 'volume_migration_scheduled', - }), - ]; - - expect(getUpgradeableVolumeIds(volumes, notifications)).toStrictEqual([ - 1, - 3, - ]); - }); - - it('should return an empty array given empty data', () => { - expect(getUpgradeableVolumeIds([], [])).toStrictEqual([]); - }); -}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index 64956fb682e..f4b3f367d5b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -1,21 +1,13 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { - eventFactory, - notificationFactory, - volumeFactory, -} from 'src/factories'; +import { notificationFactory, volumeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { ActionHandlers } from './VolumesActionMenu'; -import { - VolumeTableRow, - getDerivedVolumeStatusFromStatusAndEvent, - getEventProgress, -} from './VolumeTableRow'; +import { VolumeTableRow } from './VolumeTableRow'; const attachedVolume = volumeFactory.build({ linode_id: 0, @@ -148,50 +140,3 @@ describe('Volume table row - for linodes detail page', () => { expect(getByText('Detach')); }); }); - -describe('getDerivedVolumeStatusFromStatusAndEvent', () => { - it('should return the volume staus if no event exists', () => { - const volume = volumeFactory.build(); - expect( - getDerivedVolumeStatusFromStatusAndEvent(volume.status, undefined) - ).toBe(volume.status); - }); - - it('should migrating if a migration event is in progress regardless of what the volume status actually is', () => { - const volume = volumeFactory.build({ status: 'active' }); - const event = eventFactory.build({ - action: 'volume_migrate', - status: 'started', - }); - - expect(getDerivedVolumeStatusFromStatusAndEvent(volume.status, event)).toBe( - 'migrating' - ); - }); -}); - -describe('getEventProgress', () => { - it('should return null if the status is not "started"', () => { - const event = eventFactory.build({ - percent_complete: 20, - status: 'finished', - }); - expect(getEventProgress(event)).toBe(null); - }); - - it('should return null if the API does not return a percentage', () => { - const event = eventFactory.build({ - percent_complete: null, - status: 'started', - }); - expect(getEventProgress(event)).toBe(null); - }); - - it('should return a formatted percenatge if the API returns a percentage and the event is "started"', () => { - const event = eventFactory.build({ - percent_complete: 25, - status: 'started', - }); - expect(getEventProgress(event)).toBe('(25%)'); - }); -}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 43a08818d14..d27d77cc763 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -6,7 +6,6 @@ import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { Hidden } from 'src/components/Hidden'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; -import { Status } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; @@ -14,9 +13,14 @@ import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { useInProgressEvents } from 'src/queries/events/events'; import { useRegionsQuery } from 'src/queries/regions'; +import { + getDerivedVolumeStatusFromStatusAndEvent, + getEventProgress, + volumeStatusIconMap, +} from './utils'; import { ActionHandlers, VolumesActionMenu } from './VolumesActionMenu'; -import type { Event, Volume } from '@linode/api-v4'; +import type { Volume } from '@linode/api-v4'; export const useStyles = makeStyles()({ volumePath: { @@ -31,61 +35,6 @@ interface Props { volume: Volume; } -export const volumeStatusIconMap: Record = { - active: 'active', - creating: 'other', - migrating: 'other', - offline: 'inactive', - resizing: 'other', -}; - -/** - * Given an in-progress event and a volume's status, this function - * returns a volume's status with event info taken into account. - * - * We do this to provide users with a real-time feeling experience - * without having to refetch a volume's status agressivly. - * - * @param status The actual volume status from the volumes endpoint - * @param event An in-progress event for the volume - * @returns a volume status - */ -export const getDerivedVolumeStatusFromStatusAndEvent = ( - status: Volume['status'], - event: Event | undefined -): Volume['status'] => { - if (event === undefined) { - return status; - } - - if (event.action === 'volume_migrate' && event.status === 'started') { - return 'migrating'; - } - - return status; -}; - -/** - * Returns a nicely formated percentage from an event - * only if the event is in progress and has a percentage. - * - * This allows us to show the user the progress of a - * volume migration. - * - * @returns "(50%)" for example - */ -export const getEventProgress = (event: Event | undefined) => { - if ( - event === undefined || - event.percent_complete === null || - event.status !== 'started' - ) { - return null; - } - - return `(${event.percent_complete}%)`; -}; - export const VolumeTableRow = React.memo((props: Props) => { const { classes } = useStyles(); const { handlers, isDetailsPageRow, volume } = props; diff --git a/packages/manager/src/features/Volumes/utils.test.ts b/packages/manager/src/features/Volumes/utils.test.ts new file mode 100644 index 00000000000..3aa64c7d841 --- /dev/null +++ b/packages/manager/src/features/Volumes/utils.test.ts @@ -0,0 +1,88 @@ +import { + eventFactory, + notificationFactory, + volumeFactory, +} from 'src/factories'; + +import { + getDerivedVolumeStatusFromStatusAndEvent, + getEventProgress, + getUpgradeableVolumeIds, +} from './utils'; + +describe('getDerivedVolumeStatusFromStatusAndEvent', () => { + it('should return the volume staus if no event exists', () => { + const volume = volumeFactory.build(); + expect( + getDerivedVolumeStatusFromStatusAndEvent(volume.status, undefined) + ).toBe(volume.status); + }); + + it('should migrating if a migration event is in progress regardless of what the volume status actually is', () => { + const volume = volumeFactory.build({ status: 'active' }); + const event = eventFactory.build({ + action: 'volume_migrate', + status: 'started', + }); + + expect(getDerivedVolumeStatusFromStatusAndEvent(volume.status, event)).toBe( + 'migrating' + ); + }); +}); + +describe('getEventProgress', () => { + it('should return null if the status is not "started"', () => { + const event = eventFactory.build({ + percent_complete: 20, + status: 'finished', + }); + expect(getEventProgress(event)).toBe(null); + }); + + it('should return null if the API does not return a percentage', () => { + const event = eventFactory.build({ + percent_complete: null, + status: 'started', + }); + expect(getEventProgress(event)).toBe(null); + }); + + it('should return a formatted percenatge if the API returns a percentage and the event is "started"', () => { + const event = eventFactory.build({ + percent_complete: 25, + status: 'started', + }); + expect(getEventProgress(event)).toBe('(25%)'); + }); +}); + +describe('getUpgradeableVolumeIds', () => { + it('should return the id of volumes that have a corosponding upgrade notification', () => { + const volumes = [ + volumeFactory.build({ id: 1 }), + volumeFactory.build({ id: 2 }), + volumeFactory.build({ id: 3 }), + ]; + + const notifications = [ + notificationFactory.build({ + entity: { id: 1 }, + type: 'volume_migration_scheduled', + }), + notificationFactory.build({ + entity: { id: 3 }, + type: 'volume_migration_scheduled', + }), + ]; + + expect(getUpgradeableVolumeIds(volumes, notifications)).toStrictEqual([ + 1, + 3, + ]); + }); + + it('should return an empty array given empty data', () => { + expect(getUpgradeableVolumeIds([], [])).toStrictEqual([]); + }); +}); diff --git a/packages/manager/src/features/Volumes/utils.ts b/packages/manager/src/features/Volumes/utils.ts new file mode 100644 index 00000000000..e42dd0efdd5 --- /dev/null +++ b/packages/manager/src/features/Volumes/utils.ts @@ -0,0 +1,78 @@ +import type { Event, Notification, Volume } from '@linode/api-v4'; +import type { Status } from 'src/components/StatusIcon/StatusIcon'; + +export const volumeStatusIconMap: Record = { + active: 'active', + creating: 'other', + migrating: 'other', + offline: 'inactive', + resizing: 'other', +}; + +/** + * Given an in-progress event and a volume's status, this function + * returns a volume's status with event info taken into account. + * + * We do this to provide users with a real-time feeling experience + * without having to refetch a volume's status agressivly. + * + * @param status The actual volume status from the volumes endpoint + * @param event An in-progress event for the volume + * @returns a volume status + */ +export const getDerivedVolumeStatusFromStatusAndEvent = ( + status: Volume['status'], + event: Event | undefined +): Volume['status'] => { + if (event === undefined) { + return status; + } + + if (event.action === 'volume_migrate' && event.status === 'started') { + return 'migrating'; + } + + return status; +}; + +/** + * Returns a nicely formated percentage from an event + * only if the event is in progress and has a percentage. + * + * This allows us to show the user the progress of a + * volume migration. + * + * @returns "(50%)" for example + */ +export const getEventProgress = (event: Event | undefined) => { + if ( + event === undefined || + event.percent_complete === null || + event.status !== 'started' + ) { + return null; + } + + return `(${event.percent_complete}%)`; +}; + +export const getUpgradeableVolumeIds = ( + volumes: Volume[], + notifications: Notification[] +) => { + const upgradeableVolumeIds: number[] = []; + + for (const volume of volumes) { + if ( + notifications?.some( + (notification) => + notification.entity?.id === volume.id && + notification.type === 'volume_migration_scheduled' + ) + ) { + upgradeableVolumeIds.push(volume.id); + } + } + + return upgradeableVolumeIds; +}; From 7f52d539ba7de3f082921705bdac25e28ca87b46 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 23 Feb 2024 19:52:00 -0500 Subject: [PATCH 06/14] final tweaks and changeset --- packages/manager/.changeset/pr-10229-added-1708735909784.md | 5 +++++ .../manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 packages/manager/.changeset/pr-10229-added-1708735909784.md diff --git a/packages/manager/.changeset/pr-10229-added-1708735909784.md b/packages/manager/.changeset/pr-10229-added-1708735909784.md new file mode 100644 index 00000000000..b35e801c150 --- /dev/null +++ b/packages/manager/.changeset/pr-10229-added-1708735909784.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Reintroduce NVMe Volume Upgrades ([#10229](https://github.com/linode/manager/pull/10229)) diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index 8ba10a03390..bec61157995 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -86,6 +86,8 @@ describe('volume upgrade/migration', () => { cy.wait(['@getEvents', '@getVolumes', '@getNotifications']); + mockGetEvents([]); + cy.findByText('active').should('be.visible'); ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); @@ -175,6 +177,8 @@ describe('volume upgrade/migration', () => { cy.wait(['@getEvents', '@getLinodeVolumes', '@getNotifications']); + mockGetEvents([]); + cy.findByText('active').should('be.visible'); ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); From 257623de1409ffd0ef1ca21465445800f86f3bff Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 26 Feb 2024 09:08:33 -0500 Subject: [PATCH 07/14] fix some spelling --- packages/manager/src/features/Volumes/VolumeTableRow.tsx | 3 +-- packages/manager/src/features/Volumes/utils.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index d27d77cc763..c9d1a02825b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -84,8 +84,7 @@ export const VolumeTableRow = React.memo((props: Props) => { const handleUpgrade = () => { if (volume.linode_id !== null) { // If the volume is attached to a Linode, we force the user - // to upgrade all of the Linode's volumes at once. - // I don't like this, but this is how UX wanted it when this was first built. + // to upgrade all of the Linode's volumes at once from the Linode details page history.push(`/linodes/${volume.linode_id}/storage?upgrade=true`); } else { handlers.handleUpgrade(); diff --git a/packages/manager/src/features/Volumes/utils.test.ts b/packages/manager/src/features/Volumes/utils.test.ts index 3aa64c7d841..3f4551d5f54 100644 --- a/packages/manager/src/features/Volumes/utils.test.ts +++ b/packages/manager/src/features/Volumes/utils.test.ts @@ -11,14 +11,14 @@ import { } from './utils'; describe('getDerivedVolumeStatusFromStatusAndEvent', () => { - it('should return the volume staus if no event exists', () => { + it('should return the volume status if no event exists', () => { const volume = volumeFactory.build(); expect( getDerivedVolumeStatusFromStatusAndEvent(volume.status, undefined) ).toBe(volume.status); }); - it('should migrating if a migration event is in progress regardless of what the volume status actually is', () => { + it('should return "migrating" if a migration event is in progress regardless of what the volume status actually is', () => { const volume = volumeFactory.build({ status: 'active' }); const event = eventFactory.build({ action: 'volume_migrate', From 3fd28d1027f8217635a0d3bab9c8da26527ac82a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 26 Feb 2024 17:10:12 -0500 Subject: [PATCH 08/14] remove old unused code --- .../src/features/Linodes/LinodesDetail/types.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 packages/manager/src/features/Linodes/LinodesDetail/types.ts diff --git a/packages/manager/src/features/Linodes/LinodesDetail/types.ts b/packages/manager/src/features/Linodes/LinodesDetail/types.ts deleted file mode 100644 index 7a51a5bfb09..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Event, GrantLevel } from '@linode/api-v4/lib/account'; -import { Config, Disk } from '@linode/api-v4/lib/linodes'; - -import { ExtendedType } from 'src/utilities/extendType'; -import { LinodeWithMaintenance } from 'src/utilities/linodes'; - -export interface ExtendedLinode extends LinodeWithMaintenance { - _configs: Config[]; - _disks: Disk[]; - _events: Event[]; - _permissions: GrantLevel; - // Example: "Needs Maintenance" has a higher priority than "Offline". - _statusPriority?: number; - // In the Linodes table, the "Status" column can be sorted by priority. - _type?: ExtendedType | null; -} From 076a6e379a13133c595fe1d90fbccb61f8312e9a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 27 Feb 2024 09:35:20 -0500 Subject: [PATCH 09/14] clean up optional chaining --- .../LinodesDetailHeader/LinodeDetailHeader.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index 124aa6ad440..4c5f210a201 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -225,7 +225,7 @@ const LinodeDetailHeader = () => { return ( <> - + @@ -233,13 +233,13 @@ const LinodeDetailHeader = () => { sendEditBreadcrumbEvent(), onCancel: resetEditableLabel, onEdit: handleLinodeLabelUpdate, }, - pathname: `/linodes/${linode?.label}`, + pathname: `/linodes/${linode.label}`, }} onDocsClick={() => { sendLinodeCreateFlowDocsClickEvent('Getting Started'); @@ -255,7 +255,7 @@ const LinodeDetailHeader = () => { openTagDrawer={openTagDrawer} /> { open={isUpgradeVolumesDialogOpen} /> Date: Tue, 27 Feb 2024 10:19:58 -0500 Subject: [PATCH 10/14] fix: account for api delay --- packages/manager/src/queries/volumesMigrations.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/queries/volumesMigrations.ts b/packages/manager/src/queries/volumesMigrations.ts index 12ceb063c00..604fd05ddd6 100644 --- a/packages/manager/src/queries/volumesMigrations.ts +++ b/packages/manager/src/queries/volumesMigrations.ts @@ -30,7 +30,10 @@ export const useVolumesMigrateMutation = () => { // If a customer "force" migrates they will then see a // `volume_migration_imminent` notification instead of // the `volume_migration_scheduled` notification. - queryClient.invalidateQueries(notificationsQueryKey); + setTimeout(() => { + // Refetch notifications after 1.5 seconds. The API needs some time to process. + queryClient.invalidateQueries(notificationsQueryKey); + }, 1500); }, }); }; From 3950181e41240618b736609a24babdf04db17951 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 27 Feb 2024 10:32:51 -0500 Subject: [PATCH 11/14] use a better banner color --- .../src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx index a3e1f0b346b..db87a52f73e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx @@ -34,7 +34,7 @@ export const VolumesUpgradeBanner = ({ linodeId }: Props) => { return ( - + Date: Tue, 27 Feb 2024 11:19:21 -0500 Subject: [PATCH 12/14] add chip spacing @mjac0bs --- packages/manager/src/features/Volumes/VolumeTableRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index c9d1a02825b..ec666f3d261 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -101,6 +101,7 @@ export const VolumeTableRow = React.memo((props: Props) => { sx={{ alignItems: 'center', display: 'flex', + gap: 2, justifyContent: 'space-between', wrap: 'nowrap', }} From 09486e8c1a0caf5c873fd5ef6fc5058f77b75df1 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 27 Feb 2024 11:25:14 -0500 Subject: [PATCH 13/14] feedback from @jdamore-linode --- .../cypress/e2e/core/volumes/upgrade-volume.spec.ts | 11 ++++------- .../manager/cypress/support/intercepts/volumes.ts | 4 ++-- packages/manager/src/features/Volumes/utils.test.ts | 4 ++-- packages/manager/src/features/Volumes/utils.ts | 8 ++++++++ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index bec61157995..59436b4e983 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -10,10 +10,7 @@ import { mockGetLinodeDisks, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { - interceptMigrateVolumes, - mockGetVolumes, -} from 'support/intercepts/volumes'; +import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { @@ -26,7 +23,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); - interceptMigrateVolumes().as('migrateVolumes'); + mockMigrateVolumes().as('migrateVolumes'); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' ); @@ -106,7 +103,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); - interceptMigrateVolumes().as('migrateVolumes'); + mockMigrateVolumes().as('migrateVolumes'); mockGetLinodeDetails(linode.id, linode).as('getLinode'); mockGetLinodeDisks(linode.id, []); mockGetNotifications([migrationScheduledNotification]).as( @@ -196,7 +193,7 @@ describe('volume upgrade/migration', () => { entity: { type: 'volume', id: volume.id }, }); - interceptMigrateVolumes().as('migrateVolumes'); + mockMigrateVolumes().as('migrateVolumes'); mockGetLinodeDetails(linode.id, linode).as('getLinode'); mockGetLinodeDisks(linode.id, []); mockGetNotifications([migrationScheduledNotification]).as( diff --git a/packages/manager/cypress/support/intercepts/volumes.ts b/packages/manager/cypress/support/intercepts/volumes.ts index 131183d766f..536da877b34 100644 --- a/packages/manager/cypress/support/intercepts/volumes.ts +++ b/packages/manager/cypress/support/intercepts/volumes.ts @@ -115,10 +115,10 @@ export const interceptDeleteVolume = ( }; /** - * Intercepts POST request to migrate volumes. + * Intercepts POST request to migrate volumes and mocks response. * * @returns Cypress chainable. */ -export const interceptMigrateVolumes = (): Cypress.Chainable => { +export const mockMigrateVolumes = (): Cypress.Chainable => { return cy.intercept('POST', apiMatcher(`volumes/migrate`), {}); }; diff --git a/packages/manager/src/features/Volumes/utils.test.ts b/packages/manager/src/features/Volumes/utils.test.ts index 3f4551d5f54..8e4413b8c02 100644 --- a/packages/manager/src/features/Volumes/utils.test.ts +++ b/packages/manager/src/features/Volumes/utils.test.ts @@ -48,7 +48,7 @@ describe('getEventProgress', () => { expect(getEventProgress(event)).toBe(null); }); - it('should return a formatted percenatge if the API returns a percentage and the event is "started"', () => { + it('should return a formatted percentage if the API returns a percentage and the event is "started"', () => { const event = eventFactory.build({ percent_complete: 25, status: 'started', @@ -58,7 +58,7 @@ describe('getEventProgress', () => { }); describe('getUpgradeableVolumeIds', () => { - it('should return the id of volumes that have a corosponding upgrade notification', () => { + it('should return the id of volumes that have a corresponding upgrade notification', () => { const volumes = [ volumeFactory.build({ id: 1 }), volumeFactory.build({ id: 2 }), diff --git a/packages/manager/src/features/Volumes/utils.ts b/packages/manager/src/features/Volumes/utils.ts index e42dd0efdd5..c64ab147767 100644 --- a/packages/manager/src/features/Volumes/utils.ts +++ b/packages/manager/src/features/Volumes/utils.ts @@ -56,6 +56,14 @@ export const getEventProgress = (event: Event | undefined) => { return `(${event.percent_complete}%)`; }; +/** + * Returns an array of IDs of Volumes that are scheduled to be upgraded. + * + * @param volumes - Array from which to retrieve upgradeable Volumes. + * @param notifications - Notifications containing Volume migration statuses. + * + * @returns Array of upgradeable Volume IDs. + */ export const getUpgradeableVolumeIds = ( volumes: Volume[], notifications: Notification[] From adbb04eb30b8b4488abf6c3190afd78872063a89 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 27 Feb 2024 12:19:49 -0500 Subject: [PATCH 14/14] pref: feedback from @jdamore-linode --- .../manager/src/features/Volumes/utils.ts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/manager/src/features/Volumes/utils.ts b/packages/manager/src/features/Volumes/utils.ts index c64ab147767..f8c33aa11cf 100644 --- a/packages/manager/src/features/Volumes/utils.ts +++ b/packages/manager/src/features/Volumes/utils.ts @@ -68,19 +68,17 @@ export const getUpgradeableVolumeIds = ( volumes: Volume[], notifications: Notification[] ) => { - const upgradeableVolumeIds: number[] = []; - - for (const volume of volumes) { - if ( - notifications?.some( - (notification) => - notification.entity?.id === volume.id && - notification.type === 'volume_migration_scheduled' - ) - ) { - upgradeableVolumeIds.push(volume.id); - } - } - - return upgradeableVolumeIds; + return notifications + .filter( + (notification) => notification.type === 'volume_migration_scheduled' + ) + .reduce((volumeIds: number[], notification: Notification) => { + const upgradeableVolume = volumes.find( + (volume) => volume.id === notification.entity?.id + ); + if (upgradeableVolume) { + volumeIds.push(upgradeableVolume.id); + } + return volumeIds; + }, []); };