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 new file mode 100644 index 00000000000..59436b4e983 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -0,0 +1,270 @@ +import { + eventFactory, + linodeFactory, + notificationFactory, + volumeFactory, +} from '@src/factories'; +import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; +import { ui } from 'support/ui'; + +describe('volume upgrade/migration', () => { + it('can upgrade an unattached volume to NVMe', () => { + const volume = volumeFactory.build(); + + const migrationScheduledNotification = notificationFactory.build({ + type: 'volume_migration_scheduled', + entity: { type: 'volume', id: volume.id }, + }); + + mockGetVolumes([volume]).as('getVolumes'); + mockMigrateVolumes().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'); + + 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', '@getVolumes', '@getNotifications']); + + mockGetEvents([]); + + 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'); + mockMigrateVolumes().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']); + + mockGetEvents([]); + + 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 }, + }); + + mockMigrateVolumes().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/cypress/support/intercepts/volumes.ts b/packages/manager/cypress/support/intercepts/volumes.ts index 622ba081a5a..536da877b34 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 and mocks response. + * + * @returns Cypress chainable. + */ +export const mockMigrateVolumes = (): 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/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index 75397e492bf..4c5f210a201 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, @@ -222,20 +225,21 @@ const LinodeDetailHeader = () => { return ( <> - + + sendEditBreadcrumbEvent(), onCancel: resetEditableLabel, onEdit: handleLinodeLabelUpdate, }, - pathname: `/linodes/${linode?.label}`, + pathname: `/linodes/${linode.label}`, }} onDocsClick={() => { sendLinodeCreateFlowDocsClickEvent('Getting Started'); @@ -251,7 +255,7 @@ const LinodeDetailHeader = () => { openTagDrawer={openTagDrawer} /> { 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 +31,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 +74,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..db87a52f73e --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.tsx @@ -0,0 +1,67 @@ +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 { getUpgradeableVolumeIds } from 'src/features/Volumes/utils'; +import { useNotificationsQuery } from 'src/queries/accountNotifications'; +import { useLinodeVolumesQuery } from 'src/queries/volumes'; + +interface Props { + linodeId: number; +} + +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..ca51ccc58e3 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBenner.test.tsx @@ -0,0 +1,67 @@ +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 } 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, + }); + }); +}); 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; -} 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..ec666f3d261 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -1,24 +1,28 @@ -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 { Link, useHistory } 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 { + getDerivedVolumeStatusFromStatusAndEvent, + getEventProgress, + volumeStatusIconMap, +} from './utils'; import { ActionHandlers, VolumesActionMenu } from './VolumesActionMenu'; +import type { Volume } from '@linode/api-v4'; + export const useStyles = makeStyles()({ - chipWrapper: { - alignSelf: 'center', - }, volumePath: { width: '35%', wordBreak: 'break-all', @@ -31,44 +35,61 @@ interface Props { volume: Volume; } -export const progressFromEvent = (e?: Event) => { - if (!e) { - return undefined; - } +export const VolumeTableRow = React.memo((props: Props) => { + const { classes } = useStyles(); + const { handlers, isDetailsPageRow, volume } = props; - if (e.status === 'started' && e.percent_complete) { - return e.percent_complete; - } + const history = useHistory(); - return undefined; -}; + const { data: regions } = useRegionsQuery(); + const { data: notifications } = useNotificationsQuery(); + const { data: inProgressEvents } = useInProgressEvents(); -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) + const isVolumesLanding = !isDetailsPageRow; + + /** + * 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 ); -}; -export const volumeStatusIconMap: Record = { - active: 'active', - creating: 'other', - migrating: 'other', - offline: 'inactive', - resizing: 'other', -}; + /** + * 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 + ); -export const VolumeTableRow = React.memo((props: Props) => { - const { classes } = useStyles(); - const { data: regions } = useRegionsQuery(); - const { handlers, isDetailsPageRow, volume } = props; + const mostRecentVolumeEvent = inProgressEvents?.find( + (event) => event.entity?.id === volume.id && event.entity.type === 'volume' + ); - const isVolumesLanding = !isDetailsPageRow; + const volumeStatus = getDerivedVolumeStatusFromStatusAndEvent( + volume.status, + mostRecentVolumeEvent + ); + + const isVolumeMigrating = volumeStatus === 'migrating'; + + 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 from the Linode details page + history.push(`/linodes/${volume.linode_id}/storage?upgrade=true`); + } else { + handlers.handleUpgrade(); + } + }; const regionLabel = regions?.find((r) => r.id === volume.region)?.label ?? volume.region; @@ -80,30 +101,42 @@ export const VolumeTableRow = React.memo((props: Props) => { sx={{ alignItems: 'center', display: 'flex', + gap: 2, justifyContent: 'space-between', wrap: 'nowrap', }} > {volume.label} + {isEligibleForUpgradeToNVMe && ( + + )} + {isNVMeUpgradeImminent && !isVolumeMigrating && ( + + )} - - {volume.status.replace('_', ' ')} + + {volumeStatus} {getEventProgress(mostRecentVolumeEvent)} - {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/features/Volumes/utils.test.ts b/packages/manager/src/features/Volumes/utils.test.ts new file mode 100644 index 00000000000..8e4413b8c02 --- /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 status if no event exists', () => { + const volume = volumeFactory.build(); + expect( + getDerivedVolumeStatusFromStatusAndEvent(volume.status, undefined) + ).toBe(volume.status); + }); + + 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', + 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 percentage 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 corresponding 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..f8c33aa11cf --- /dev/null +++ b/packages/manager/src/features/Volumes/utils.ts @@ -0,0 +1,84 @@ +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}%)`; +}; + +/** + * 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[] +) => { + 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; + }, []); +}; 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 = () => { 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..604fd05ddd6 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,19 @@ 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. + setTimeout(() => { + // Refetch notifications after 1.5 seconds. The API needs some time to process. + queryClient.invalidateQueries(notificationsQueryKey); + }, 1500); + }, + }); +};