From 741a11d01d2ce2832187effd19aed852d4641177 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 8 May 2023 12:03:43 -0400 Subject: [PATCH] refactor: [M3-6492, M3-6315] - React Query - Linode Detail - Backups (#9079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React Query for Linode Backups 🚀 - Modernizes the Linode Backups section of the Linodes Details page - Breaks up Backup related code into more components with more understandable names - Adds `available` to the LinodeBackup type because it was missing - Resolves Sentry errors caused by this section of the app (M3-6315) - Makes some minor UI and UX improvements 🎨 --------- Co-authored-by: Banks Nussman --- packages/api-v4/src/linodes/types.ts | 1 + .../cypress/e2e/linodes/backup-linode.spec.ts | 2 +- packages/manager/src/App.tsx | 5 +- packages/manager/src/factories/linodes.ts | 26 + .../LinodesCreate/SelectBackupPanel.tsx | 14 +- .../LinodeBackup/BackupTableRow.tsx | 22 +- .../LinodeBackup/BackupsPlaceholder.tsx | 8 +- .../LinodeBackup/CancelBackupsDialog.tsx | 74 ++ .../LinodeBackup/CaptureSnapshot.test.tsx | 50 + .../LinodeBackup/CaptureSnapshot.tsx | 114 +++ .../CaptureSnapshotConfirmationDialog.tsx | 48 + .../DestructiveSnapshotDialog.tsx | 72 -- .../LinodeBackup/EnableBackupsDialog.tsx | 117 +-- .../LinodeBackup/LinodeBackup.tsx | 886 ------------------ .../LinodeBackup/LinodeBackupActionMenu.tsx | 19 +- .../LinodeBackup/LinodeBackups.test.tsx | 71 ++ .../LinodeBackup/LinodeBackups.tsx | 216 +++++ .../RestoreToLinodeDrawer.test.tsx | 39 +- .../LinodeBackup/RestoreToLinodeDrawer.tsx | 278 +++--- .../LinodeBackup/ScheduleSettings.test.tsx | 62 ++ .../LinodeBackup/ScheduleSettings.tsx | 174 ++++ .../LinodesDetail/LinodeBackup/index.ts | 3 - .../LinodeDetailHeader.tsx | 4 +- .../LinodesDetail/LinodesDetailNavigation.tsx | 2 +- .../manager/src/queries/linodes/backups.ts | 58 ++ .../manager/src/queries/linodes/linodes.ts | 15 + 26 files changed, 1148 insertions(+), 1232 deletions(-) create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.test.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshotConfirmationDialog.tsx delete mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/DestructiveSnapshotDialog.tsx delete mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/ScheduleSettings.test.tsx create mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx delete mode 100644 packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/index.ts create mode 100644 packages/manager/src/queries/linodes/backups.ts diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index a5b91c01a86..636a19dbeec 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -101,6 +101,7 @@ export interface LinodeBackup { finished: string; configs: string[]; disks: LinodeBackupDisk[]; + available: boolean; } export type LinodeBackupType = 'auto' | 'snapshot'; diff --git a/packages/manager/cypress/e2e/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/linodes/backup-linode.spec.ts index 3d45d195da7..096f7013108 100644 --- a/packages/manager/cypress/e2e/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/linodes/backup-linode.spec.ts @@ -70,7 +70,7 @@ describe('linode backups', () => { getClick('[data-qa-confirm="true"]'); } cy.wait('@enableBackups').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage('A snapshot is being taken'); + ui.toast.assertMessage('Starting to capture snapshot'); deleteLinodeById(linode.id); }); }); diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index af34e928c53..53bb30404a5 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -171,7 +171,10 @@ export class App extends React.Component { events$ .filter( - ({ event }) => event.action.startsWith('linode') && !event._initial + ({ event }) => + (event.action.startsWith('linode') || + event.action.startsWith('backups')) && + !event._initial ) .subscribe(linodeEventsHandler); diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index d3f62ea1ad6..c4f7ae7c2ac 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -3,6 +3,7 @@ import { CreateLinodeRequest, Linode, LinodeAlerts, + LinodeBackup, LinodeBackups, LinodeIPsResponse, LinodeSpecs, @@ -216,3 +217,28 @@ export const createLinodeRequestFactory = Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + region: 'us-central', + type: 'auto', + status: 'successful', + available: true, + created: '2023-05-03T04:00:47', + updated: '2023-05-03T04:04:07', + finished: '2023-05-03T04:02:11', + label: null, + configs: ['Restore 319718 - My Alpine 3.17 Disk Profile'], + disks: [ + { + label: 'Restore 319718 - Alpine 3.17 Disk', + size: 25088, + filesystem: 'ext4', + }, + { + label: 'Restore 319718 - 512 MB Swap Image', + size: 512, + filesystem: 'swap', + }, + ], +}); diff --git a/packages/manager/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx b/packages/manager/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx index bf6b9aa23e2..dfc99441917 100644 --- a/packages/manager/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx +++ b/packages/manager/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx @@ -14,13 +14,25 @@ import Grid from '@mui/material/Unstable_Grid2'; import Notice from 'src/components/Notice'; import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard'; import SelectionCard from 'src/components/SelectionCard'; -import { aggregateBackups } from 'src/features/linodes/LinodesDetail/LinodeBackup'; import { formatDate } from 'src/utilities/formatDate'; import { withProfile, WithProfileProps, } from 'src/containers/profile.container'; +export const aggregateBackups = ( + backups: LinodeBackupsResponse +): LinodeBackup[] => { + const manualSnapshot = + backups?.snapshot.in_progress?.status === 'needsPostProcessing' + ? backups?.snapshot.in_progress + : backups?.snapshot.current; + return ( + backups && + [...backups.automatic!, manualSnapshot!].filter((b) => Boolean(b)) + ); +}; + export interface LinodeWithBackups extends Linode { currentBackups: LinodeBackupsResponse; } diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupTableRow.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupTableRow.tsx index 970b6bc5bff..9885401c9fb 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupTableRow.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupTableRow.tsx @@ -1,5 +1,5 @@ import { LinodeBackup } from '@linode/api-v4/lib/linodes'; -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { StatusIcon, Status } from 'src/components/StatusIcon/StatusIcon'; @@ -7,13 +7,13 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { parseAPIDate } from 'src/utilities/date'; import { formatDuration } from 'src/utilities/formatDuration'; -import LinodeBackupActionMenu from './LinodeBackupActionMenu'; +import { LinodeBackupActionMenu } from './LinodeBackupActionMenu'; interface Props { backup: LinodeBackup; disabled: boolean; - handleRestore: (backup: LinodeBackup) => void; - handleDeploy: (backup: LinodeBackup) => void; + handleRestore: () => void; + handleDeploy: () => void; } const typeMap = { @@ -41,12 +41,8 @@ const statusIconMap: Record = { userAborted: 'error', }; -const BackupTableRow: React.FC = (props) => { - const { backup, disabled, handleRestore } = props; - - const onDeploy = () => { - props.handleDeploy(props.backup); - }; +const BackupTableRow = (props: Props) => { + const { backup, disabled, handleRestore, handleDeploy } = props; return ( @@ -71,7 +67,9 @@ const BackupTableRow: React.FC = (props) => { {formatDuration( Duration.fromMillis( - parseAPIDate(backup.finished).toMillis() - + (backup.finished + ? parseAPIDate(backup.finished).toMillis() + : DateTime.now().toMillis()) - parseAPIDate(backup.created).toMillis() ) )} @@ -91,7 +89,7 @@ const BackupTableRow: React.FC = (props) => { backup={backup} disabled={disabled} onRestore={handleRestore} - onDeploy={onDeploy} + onDeploy={handleDeploy} /> diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx index af7db2cdeaf..511a2cd5070 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import Typography from 'src/components/core/Typography'; import { Currency } from 'src/components/Currency'; import Placeholder from 'src/components/Placeholder'; import LinodePermissionsError from '../LinodePermissionsError'; -import EnableBackupsDialog from './EnableBackupsDialog'; +import { EnableBackupsDialog } from './EnableBackupsDialog'; interface Props { backupsMonthlyPrice?: number; @@ -13,7 +13,7 @@ interface Props { linodeId: number; } -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles()(() => ({ empty: { '& svg': { transform: 'scale(0.75)', @@ -22,7 +22,7 @@ const useStyles = makeStyles(() => ({ })); export const BackupsPlaceholder = (props: Props) => { - const classes = useStyles(); + const { classes } = useStyles(); const { backupsMonthlyPrice, linodeId, disabled } = props; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx new file mode 100644 index 00000000000..d5872d7f3bd --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { useSnackbar } from 'notistack'; +import { resetEventsPolling } from 'src/eventsPolling'; +import { useLinodeBackupsCancelMutation } from 'src/queries/linodes/backups'; +import { sendBackupsDisabledEvent } from 'src/utilities/ga'; +import Typography from 'src/components/core/Typography'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import ActionsPanel from 'src/components/ActionsPanel/ActionsPanel'; +import Button from 'src/components/Button/Button'; + +interface Props { + isOpen: boolean; + onClose: () => void; + linodeId: number; +} + +export const CancelBackupsDialog = (props: Props) => { + const { isOpen, onClose, linodeId } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { + mutateAsync: cancelBackups, + error, + isLoading, + } = useLinodeBackupsCancelMutation(linodeId); + + const onCancelBackups = async () => { + await cancelBackups(); + enqueueSnackbar('Backups are being canceled for this Linode', { + variant: 'info', + }); + onClose(); + resetEventsPolling(); + sendBackupsDisabledEvent(); + }; + + return ( + + + + + } + > + + Canceling backups associated with this Linode will delete all existing + backups. Are you sure? + + + Note: + Once backups for this Linode have been canceled, you cannot re-enable + them for 24 hours. + + + ); +}; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.test.tsx new file mode 100644 index 00000000000..80433030a4a --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.test.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { rest, server } from 'src/mocks/testServer'; +import { linodeFactory } from 'src/factories/linodes'; +import { CaptureSnapshot } from './CaptureSnapshot'; +import userEvent from '@testing-library/user-event'; + +describe('CaptureSnapshot', () => { + it('renders heading and copy', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json(linodeFactory.build({ id: 1, backups: { enabled: true } })) + ); + }) + ); + + const { getByText } = renderWithTheme( + + ); + + getByText('Manual Snapshot'); + getByText( + /You can make a manual backup of your Linode by taking a snapshot./ + ); + }); + it('a confirmation dialog should open when you attempt to take a snapshot', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json(linodeFactory.build({ id: 1, backups: { enabled: true } })) + ); + }) + ); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + userEvent.type(getByLabelText('Name Snapshot'), 'my-linode-snapshot'); + + userEvent.click(getByText('Take Snapshot')); + + expect( + getByText( + /Taking a snapshot will back up your Linode in its current state, overriding your previous snapshot. Are you sure?/ + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx new file mode 100644 index 00000000000..11040286778 --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import Notice from 'src/components/Notice/Notice'; +import FormControl from 'src/components/core/FormControl'; +import Paper from 'src/components/core/Paper'; +import Typography from 'src/components/core/Typography'; +import { Theme } from '@mui/material/styles'; +import { makeStyles } from 'tss-react/mui'; +import { useLinodeBackupSnapshotMutation } from 'src/queries/linodes/backups'; +import { useSnackbar } from 'notistack'; +import { useFormik } from 'formik'; +import TextField from 'src/components/TextField'; +import { CaptureSnapshotConfirmationDialog } from './CaptureSnapshotConfirmationDialog'; +import Button from 'src/components/Button'; +import { resetEventsPolling } from 'src/eventsPolling'; +import { getErrorMap } from 'src/utilities/errorUtils'; + +interface Props { + linodeId: number; + isReadOnly: boolean; +} + +const useStyles = makeStyles()((theme: Theme) => ({ + snapshotFormControl: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-end', + flexWrap: 'wrap', + '& > div': { + width: 'auto', + marginRight: theme.spacing(2), + }, + '& button': { + marginTop: theme.spacing(4), + }, + }, + snapshotNameField: { + minWidth: 275, + }, +})); + +export const CaptureSnapshot = ({ linodeId, isReadOnly }: Props) => { + const { classes } = useStyles(); + const { enqueueSnackbar } = useSnackbar(); + + const { + mutateAsync: takeSnapshot, + error: snapshotError, + isLoading: isSnapshotLoading, + } = useLinodeBackupSnapshotMutation(linodeId); + + const [ + isSnapshotConfirmationDialogOpen, + setIsSnapshotConfirmationDialogOpen, + ] = React.useState(false); + + const snapshotForm = useFormik({ + initialValues: { label: '' }, + async onSubmit(values, formikHelpers) { + await takeSnapshot(values); + enqueueSnackbar('Starting to capture snapshot', { + variant: 'info', + }); + setIsSnapshotConfirmationDialogOpen(false); + formikHelpers.resetForm(); + resetEventsPolling(); + }, + }); + + const hasErrorFor = getErrorMap(['label'], snapshotError); + + return ( + + + Manual Snapshot + + + You can make a manual backup of your Linode by taking a snapshot. + Creating the manual snapshot can take several minutes, depending on the + size of your Linode and the amount of data you have stored on it. The + manual snapshot will not be overwritten by automatic backups. + + + {hasErrorFor.none && ( + + {hasErrorFor.none} + + )} + + + + setIsSnapshotConfirmationDialogOpen(false)} + onSnapshot={() => snapshotForm.handleSubmit()} + loading={isSnapshotLoading} + /> + + ); +}; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshotConfirmationDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshotConfirmationDialog.tsx new file mode 100644 index 00000000000..c270968a7f5 --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/CaptureSnapshotConfirmationDialog.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import ActionsPanel from 'src/components/ActionsPanel'; +import Button from 'src/components/Button'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import Typography from 'src/components/core/Typography'; + +interface Props { + open: boolean; + error?: string; + loading: boolean; + onClose: () => void; + onSnapshot: () => void; +} + +export const CaptureSnapshotConfirmationDialog = (props: Props) => { + const { open, loading, onClose, error, onSnapshot } = props; + + const actions = ( + + + + + ); + + return ( + + + Taking a snapshot will back up your Linode in its current state, + overriding your previous snapshot. Are you sure? + + + ); +}; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/DestructiveSnapshotDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/DestructiveSnapshotDialog.tsx deleted file mode 100644 index 6a0303dfb91..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/DestructiveSnapshotDialog.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from 'react'; -import ActionsPanel from 'src/components/ActionsPanel'; -import Button from 'src/components/Button'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; - -type ClassNames = 'warningCopy'; - -const styles = (theme: Theme) => - createStyles({ - warningCopy: { - color: theme.color.red, - marginBottom: theme.spacing(2), - }, - }); - -interface Props { - open: boolean; - error?: string; - loading: boolean; - onClose: () => void; - onSnapshot: () => void; -} - -type CombinedProps = Props & WithStyles; - -class DestructiveSnapshotDialog extends React.PureComponent { - renderActions = () => { - return ( - - - - - ); - }; - - render() { - const title = 'Take a snapshot?'; - - return ( - - - Taking a snapshot will back up your Linode in its current state, - overriding your previous snapshot. Are you sure? - - - ); - } -} - -export default withStyles(styles)(DestructiveSnapshotDialog); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 99771ee0118..6bb0dc3f661 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -1,15 +1,14 @@ -import { enableBackups } from '@linode/api-v4/lib/linodes'; -import { useSnackbar } from 'notistack'; import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import Typography from 'src/components/core/Typography'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Currency } from 'src/components/Currency'; -import Notice from 'src/components/Notice'; import { resetEventsPolling } from 'src/eventsPolling'; -import useLinodes from 'src/hooks/useLinodes'; -import { useSpecificTypes } from 'src/queries/types'; +import { useLinodeBackupsEnableMutation } from 'src/queries/linodes/backups'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useTypeQuery } from 'src/queries/types'; +import { useSnackbar } from 'notistack'; interface Props { linodeId: number | undefined; @@ -19,66 +18,58 @@ interface Props { export const EnableBackupsDialog = (props: Props) => { const { linodeId, onClose, open } = props; - /** - * Calculate the monthly backup price here. - * Since this component is used in LinodesLanding - * as well as detail, can't rely on parents knowing - * this information. - */ - const { linodes } = useLinodes(); - const thisLinode = linodes.itemsById[linodeId ?? -1]; - const typesQuery = useSpecificTypes( - thisLinode?.type ? [thisLinode.type] : [] + + const { + mutateAsync: enableBackups, + reset, + isLoading, + error, + } = useLinodeBackupsEnableMutation(linodeId ?? -1); + + const { data: linode } = useLinodeQuery( + linodeId ?? -1, + open && linodeId !== undefined && linodeId > 0 ); - const thisLinodeType = typesQuery[0]?.data; - const price = thisLinodeType?.addons.backups.price.monthly ?? 0; + const { data: type } = useTypeQuery( + linode?.type ?? '', + Boolean(linode?.type) + ); - const [submitting, setSubmitting] = React.useState(false); - const [error, setError] = React.useState(); + const price = type?.addons?.backups?.price?.monthly ?? 0; const { enqueueSnackbar } = useSnackbar(); - const handleEnableBackups = React.useCallback(() => { - setSubmitting(true); - enableBackups(linodeId ?? -1) - .then(() => { - setSubmitting(false); - resetEventsPolling(); - enqueueSnackbar('Backups are being enabled for this Linode.', { - variant: 'success', - }); - onClose(); - }) - .catch((error) => { - setError(error[0].reason); - setSubmitting(false); - }); - }, [linodeId, onClose, enqueueSnackbar]); + const handleEnableBackups = async () => { + await enableBackups(); + resetEventsPolling(); + enqueueSnackbar('Backups are being enabled for this Linode.', { + variant: 'success', + }); + onClose(); + }; React.useEffect(() => { if (open) { - setError(undefined); + reset(); } }, [open]); - const actions = React.useMemo(() => { - return ( - - - - - ); - }, [onClose, submitting, handleEnableBackups]); + const actions = ( + + + + + ); return ( { actions={actions} open={open} onClose={onClose} + error={error?.[0].reason} > - <> - - Are you sure you want to enable backups on this Linode?{` `} - This will add - {` `} - to your monthly bill. - - {error && } - + + Are you sure you want to enable backups on this Linode?{` `} + This will add + {` `} + to your monthly bill. + ); }; - -export default React.memo(EnableBackupsDialog); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx deleted file mode 100644 index 325da57119a..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx +++ /dev/null @@ -1,886 +0,0 @@ -import { GrantLevel } from '@linode/api-v4/lib/account'; -import { - cancelBackups, - Day, - getLinodeBackups, - getType, - LinodeBackup, - LinodeBackupSchedule, - LinodeBackupsResponse, - takeSnapshot, - Window, -} from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; -import { withSnackbar, WithSnackbarProps } from 'notistack'; -import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; -import 'rxjs/add/operator/filter'; -import { Subscription } from 'rxjs/Subscription'; -import ActionsPanel from 'src/components/ActionsPanel'; -import Button from 'src/components/Button'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import FormControl from 'src/components/core/FormControl'; -import FormHelperText from 'src/components/core/FormHelperText'; -import Paper from 'src/components/core/Paper'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import { TableBody } from 'src/components/TableBody'; -import { TableHead } from 'src/components/TableHead'; -import Tooltip from 'src/components/core/Tooltip'; -import Typography from 'src/components/core/Typography'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; -import ErrorState from 'src/components/ErrorState'; -import Notice from 'src/components/Notice'; -import PromiseLoader, { - PromiseLoaderResponse, -} from 'src/components/PromiseLoader'; -import { Table } from 'src/components/Table'; -import { TableCell } from 'src/components/TableCell'; -import { TableRow } from 'src/components/TableRow'; -import TextField from 'src/components/TextField'; -import { events$ } from 'src/events'; -import { resetEventsPolling } from 'src/eventsPolling'; -import { linodeInTransition as isLinodeInTransition } from 'src/features/linodes/transitions'; -import { - LinodeActionsProps, - withLinodeActions, -} from 'src/store/linodes/linode.containers'; -import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; -import { ExtendedType } from 'src/utilities/extendType'; -import { formatDate } from 'src/utilities/formatDate'; -import { sendBackupsDisabledEvent } from 'src/utilities/ga'; -import getAPIErrorFor from 'src/utilities/getAPIErrorFor'; -import getUserTimezone from 'src/utilities/getUserTimezone'; -import { initWindows } from 'src/utilities/initWindows'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import { withLinodeDetailContext } from '../linodeDetailContext'; -import LinodePermissionsError from '../LinodePermissionsError'; -import BackupsPlaceholder from './BackupsPlaceholder'; -import BackupTableRow from './BackupTableRow'; -import DestructiveSnapshotDialog from './DestructiveSnapshotDialog'; -import RestoreToLinodeDrawer from './RestoreToLinodeDrawer'; -import { - withProfile, - WithProfileProps, -} from 'src/containers/profile.container'; - -type ClassNames = - | 'paper' - | 'subTitle' - | 'snapshotNameField' - | 'snapshotFormControl' - | 'snapshotGeneralError' - | 'scheduleAction' - | 'chooseDay' - | 'cancelButton' - | 'cancelCopy'; - -const styles = (theme: Theme) => - createStyles({ - paper: { - marginBottom: theme.spacing(3), - }, - subTitle: { - marginBottom: theme.spacing(1), - }, - snapshotFormControl: { - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-end', - flexWrap: 'wrap', - '& > div': { - width: 'auto', - marginRight: theme.spacing(2), - }, - '& button': { - marginTop: theme.spacing(4), - }, - }, - scheduleAction: { - padding: 0, - '& button': { - marginLeft: 0, - marginTop: theme.spacing(2), - }, - }, - chooseDay: { - marginRight: theme.spacing(2), - minWidth: 150, - '& .react-select__menu-list': { - maxHeight: 'none', - }, - }, - cancelButton: { - marginBottom: theme.spacing(1), - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(), - marginRight: theme.spacing(), - }, - }, - cancelCopy: { - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(), - marginRight: theme.spacing(), - }, - }, - snapshotNameField: { - minWidth: 275, - }, - snapshotGeneralError: { - minWidth: '100%', - }, - }); - -interface ContextProps { - linodeID: number; - linodeRegion: string; - linodeType: null | string; - backupsEnabled: boolean; - backupsSchedule: LinodeBackupSchedule; - linodeInTransition: boolean; - linodeLabel: string; - permissions: GrantLevel; -} - -interface PreloadedProps { - backups: PromiseLoaderResponse; - type: PromiseLoaderResponse; -} - -interface State { - backups: LinodeBackupsResponse; - getBackupsTimer: NodeJS.Timeout | null; - snapshotForm: { - label: string; - errors?: APIError[]; - }; - settingsForm: { - window: Window; - day: Day; - errors?: APIError[]; - loading: boolean; - }; - restoreDrawer: { - open: boolean; - backupCreated: string; - backupID?: number; - }; - dialogOpen: boolean; - dialogError?: string; - loading: boolean; - cancelBackupsAlertOpen: boolean; - enabling: boolean; -} - -type CombinedProps = PreloadedProps & - LinodeActionsProps & - WithStyles & - RouteComponentProps<{}> & - ContextProps & - WithSnackbarProps & - WithProfileProps; - -const isReadOnly = (permissions: GrantLevel) => { - return permissions === 'read_only'; -}; - -export const aggregateBackups = ( - backups: LinodeBackupsResponse -): LinodeBackup[] => { - const manualSnapshot = - backups?.snapshot.in_progress?.status === 'needsPostProcessing' - ? backups?.snapshot.in_progress - : backups?.snapshot.current; - return ( - backups && - [...backups.automatic!, manualSnapshot!].filter((b) => Boolean(b)) - ); -}; - -/* tslint:disable-next-line */ -class _LinodeBackup extends React.Component { - state: State = { - backups: this.props.backups.response, - getBackupsTimer: null, - snapshotForm: { - label: '', - }, - settingsForm: { - window: this.props.backupsSchedule.window || 'Scheduling', - day: this.props.backupsSchedule.day || 'Scheduling', - loading: false, - }, - restoreDrawer: { - open: false, - backupCreated: '', - }, - dialogOpen: false, - dialogError: undefined, - loading: false, - cancelBackupsAlertOpen: false, - enabling: false, - }; - - windows: string[][] = []; - days: string[][] = []; - - eventSubscription: Subscription; - - mounted: boolean = false; - - componentDidMount() { - this.mounted = true; - this.eventSubscription = events$ - .filter(({ event }) => - [ - 'linode_snapshot', - 'backups_enable', - 'backups_cancel', - 'backups_restore', - ].includes(event.action) - ) - .filter(({ event }) => !event._initial && event.status === 'finished') - .subscribe((_) => { - getLinodeBackups(this.props.linodeID) - .then((data) => { - this.setState({ backups: data }); - }) - .catch(() => { - /* @todo: how do we want to display this error? */ - this.setState({ enabling: false }); - }); - }); - } - - // update backup status column from processing -> success/failure - componentDidUpdate() { - if (this.state.backups.snapshot.in_progress === null) { - return; - } - if ( - this.state.backups.snapshot.in_progress.status === - 'needsPostProcessing' && - this.state.getBackupsTimer === null - ) { - this.setState({ - getBackupsTimer: setTimeout( - () => - getLinodeBackups(this.props.linodeID).then((data) => { - this.setState({ - ...this.state, - backups: data, - getBackupsTimer: null, - }); - }), - 15000 - ), - }); - } - } - - componentWillUnmount() { - this.mounted = false; - this.eventSubscription.unsubscribe(); - } - - constructor(props: CombinedProps) { - super(props); - - this.windows = initWindows( - getUserTimezone(props.profile.data?.timezone), - true - ); - - this.days = [ - ['Choose a day', 'Scheduling'], - ['Sunday', 'Sunday'], - ['Monday', 'Monday'], - ['Tuesday', 'Tuesday'], - ['Wednesday', 'Wednesday'], - ['Thursday', 'Thursday'], - ['Friday', 'Friday'], - ['Saturday', 'Saturday'], - ]; - } - - cancelBackups = () => { - const { enqueueSnackbar } = this.props; - cancelBackups(this.props.linodeID) - .then(() => { - enqueueSnackbar('Backups are being canceled for this Linode', { - variant: 'info', - }); - // Just in case the user immediately disables backups - // and enabling is still true: - this.setState({ enabling: false }); - resetEventsPolling(); - // GA Event - sendBackupsDisabledEvent(); - }) - .catch((errorResponse) => { - getAPIErrorOrDefault( - errorResponse, - 'There was an error disabling backups' - ) - /** @todo move this error to the actual modal */ - .forEach((err: APIError) => - enqueueSnackbar(err.reason, { - variant: 'error', - }) - ); - }); - if (!this.mounted) { - return; - } - this.setState({ cancelBackupsAlertOpen: false }); - }; - - takeSnapshot = () => { - const { linodeID, enqueueSnackbar } = this.props; - const { snapshotForm } = this.state; - this.setState({ loading: true }); - takeSnapshot(linodeID, snapshotForm.label) - .then(() => { - enqueueSnackbar('A snapshot is being taken', { - variant: 'info', - }); - this.closeDestructiveDialog(); - this.setState({ - snapshotForm: { label: '', errors: undefined }, - loading: false, - }); - resetEventsPolling(); - }) - .catch((errorResponse) => { - this.setState({ - snapshotForm: { - ...this.state.snapshotForm, - errors: getAPIErrorOrDefault( - errorResponse, - 'There was an error taking a snapshot' - ), - }, - dialogOpen: this.state.dialogOpen, - loading: false, - dialogError: getAPIErrorOrDefault( - errorResponse, - 'There was an error taking a snapshot' - )[0].reason, - }); - }); - }; - - closeDestructiveDialog = () => { - this.setState({ - dialogOpen: false, - dialogError: undefined, - }); - }; - - saveSettings = () => { - const { - linodeID, - enqueueSnackbar, - linodeActions: { updateLinode }, - } = this.props; - const { settingsForm } = this.state; - - this.setState((state) => ({ - settingsForm: { ...state.settingsForm, loading: true, errors: undefined }, - })); - - updateLinode({ - linodeId: linodeID, - backups: { - enabled: true, - schedule: { - day: settingsForm.day, - window: settingsForm.window, - }, - }, - }) - .then(() => { - this.setState((state) => ({ - settingsForm: { ...state.settingsForm, loading: false }, - })); - - enqueueSnackbar('Backup settings saved', { - variant: 'success', - }); - }) - .catch((err) => { - this.setState( - (state) => ({ - settingsForm: { - ...state.settingsForm, - loading: false, - errors: getAPIErrorOrDefault(err), - }, - }), - () => { - scrollErrorIntoView(); - } - ); - }); - }; - - openRestoreDrawer = (backupID: number, backupCreated: string) => { - this.setState({ - restoreDrawer: { open: true, backupID, backupCreated }, - }); - }; - - closeRestoreDrawer = () => { - this.setState({ - restoreDrawer: { open: false, backupID: undefined, backupCreated: '' }, - }); - }; - - handleSelectBackupWindow = (e: Item) => { - this.setState({ - settingsForm: { - ...this.state.settingsForm, - window: e.value as Window, - }, - }); - }; - - handleSelectBackupTime = (e: Item) => { - this.setState({ - settingsForm: { - ...this.state.settingsForm, - day: e.value as Day, - }, - }); - }; - - inputHasChanged = ( - initialValue: LinodeBackupSchedule, - newValue: LinodeBackupSchedule - ) => { - return ( - newValue.day === 'Scheduling' || - newValue.window === 'Scheduling' || - (newValue.day === initialValue.day && - newValue.window === initialValue.window) - ); - }; - - handleSnapshotNameChange = (e: React.ChangeEvent) => { - this.setState({ snapshotForm: { label: e.target.value } }); - }; - - handleSnapshotDialogDisplay = () => { - // If there's no label, don't open the modal. Show an error in the form. - if (!this.state.snapshotForm.label) { - this.setState({ - snapshotForm: { - ...this.state.snapshotForm, - errors: [{ field: 'label', reason: 'Label is required.' }], - }, - }); - return; - } - this.setState({ - dialogOpen: true, - dialogError: undefined, - }); - }; - - handleCloseBackupsAlert = () => { - this.setState({ cancelBackupsAlertOpen: false }); - }; - - handleOpenBackupsAlert = () => { - this.setState({ cancelBackupsAlertOpen: true }); - }; - - handleDeploy = (backup: LinodeBackup) => { - const { history, linodeID, linodeType } = this.props; - history.push( - '/linodes/create' + - `?type=Backups&backupID=${backup.id}&linodeID=${linodeID}&typeID=${linodeType}` - ); - }; - - handleRestore = (backup: LinodeBackup) => { - this.openRestoreDrawer( - backup.id, - formatDate(backup.created, { - timezone: this.props.profile.data?.timezone, - }) - ); - }; - - handleRestoreSubmit = () => { - this.closeRestoreDrawer(); - this.props.enqueueSnackbar('Backup restore started', { - variant: 'info', - }); - }; - - Table = ({ backups }: { backups: LinodeBackup[] }): JSX.Element | null => { - const { classes, permissions } = this.props; - const disabled = isReadOnly(permissions); - - return ( - - - - - Label - Status - Date Created - Duration - Disks - Space Required - - - - - {backups.map((backup: LinodeBackup, idx: number) => ( - - ))} - -
-
- ); - }; - - SnapshotForm = (): JSX.Element | null => { - const { classes, linodeInTransition, permissions } = this.props; - const { snapshotForm } = this.state; - const hasErrorFor = getErrorMap(['label'], snapshotForm.errors); - - const disabled = isReadOnly(permissions); - - return ( - - - - Manual Snapshot - - - You can make a manual backup of your Linode by taking a snapshot. - Creating the manual snapshot can take several minutes, depending on - the size of your Linode and the amount of data you have stored on - it. The manual snapshot will not be overwritten by automatic - backups. - - - {hasErrorFor.none && ( - - {hasErrorFor.none} - - )} - - -
- -
-
-
-
- -
- ); - }; - - SettingsForm = (): JSX.Element | null => { - const { classes, backupsSchedule, permissions, profile } = this.props; - const { settingsForm } = this.state; - const getErrorFor = getAPIErrorFor( - { - day: 'backups.day', - window: 'backups.window', - schedule: 'backups.schedule.window', - }, - settingsForm.errors - ); - const errorText = - getErrorFor('none') || - getErrorFor('backups.day') || - getErrorFor('backups.window') || - getErrorFor('backups.schedule.window') || - getErrorFor('backups.schedule.day'); - - const timeSelection = this.windows.map((window: Window[]) => { - const label = window[0]; - return { label, value: window[1] }; - }); - - const daySelection = this.days.map((day: string[]) => { - const label = day[0]; - return { label, value: day[1] }; - }); - - const defaultTimeSelection = timeSelection.find((eachOption) => { - return eachOption.value === settingsForm.window; - }); - - const defaultDaySelection = daySelection.find((eachOption) => { - return eachOption.value === settingsForm.day; - }); - - return ( - - - Settings - - - Configure when automatic backups are initiated. The Linode Backup - Service will generate a backup between the selected hours every day, - and will overwrite the previous daily backup. The selected day is when - the backup is promoted to the weekly slot. Up to two weekly backups - are saved. - - - - - Time displayed in{' '} - {getUserTimezone(profile.data?.timezone).replace('_', ' ')} - - - - - - {errorText && {errorText}} - - ); - }; - - Management = (): JSX.Element | null => { - const { classes, linodeID, linodeRegion, permissions } = this.props; - const disabled = isReadOnly(permissions); - - const { backups: backupsResponse } = this.state; - const backups = aggregateBackups(backupsResponse); - - return ( - - {disabled && } - {backups.length ? ( - - ) : ( - - - Automatic and manual backups will be listed here - - - )} - - - - - Please note that when you cancel backups associated with this Linode, - this will remove all existing backups. - - - - - Canceling backups associated with this Linode will delete all - existing backups. Are you sure? - - - Note: - Once backups for this Linode have been canceled, you cannot - re-enable them for 24 hours. - - - - ); - }; - - renderConfirmCancellationActions = () => { - return ( - - - - - ); - }; - - render() { - const { backupsEnabled, permissions, type } = this.props; - - if (this.props.backups.error) { - /** @todo remove promise loader and source backups from Redux */ - return ( - - ); - } - - const backupsMonthlyPrice = - type.response?.addons?.backups?.price?.monthly ?? 0; - - return ( -
- {backupsEnabled ? ( - - ) : ( - - )} -
- ); - } -} - -const preloaded = PromiseLoader({ - backups: (props) => getLinodeBackups(props.linodeID), - type: ({ linodeType }) => { - if (!linodeType) { - return Promise.resolve(undefined); - } - - return getType(linodeType); - }, -}); - -const styled = withStyles(styles); - -const linodeContext = withLinodeDetailContext(({ linode }) => ({ - backupsEnabled: linode.backups.enabled, - backupsSchedule: linode.backups.schedule, - linodeID: linode.id, - linodeInTransition: isLinodeInTransition(linode.status), - linodeLabel: linode.label, - linodeRegion: linode.region, - linodeType: linode.type, - permissions: linode._permissions, -})); - -export default compose( - linodeContext, - preloaded, - styled, - withRouter, - withSnackbar, - withLinodeActions, - withProfile -)(_LinodeBackup); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackupActionMenu.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackupActionMenu.tsx index 06eb55da537..bf036fa0f8a 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackupActionMenu.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackupActionMenu.tsx @@ -1,19 +1,16 @@ -import { LinodeBackup } from '@linode/api-v4/lib/linodes'; import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { LinodeBackup } from '@linode/api-v4/lib/linodes'; import ActionMenu, { Action } from 'src/components/ActionMenu'; interface Props { backup: LinodeBackup; disabled: boolean; - onRestore: (backup: LinodeBackup) => void; - onDeploy: (backup: LinodeBackup) => void; + onRestore: () => void; + onDeploy: () => void; } -type CombinedProps = Props & RouteComponentProps<{}>; - -export const LinodeBackupActionMenu: React.FC = (props) => { - const { backup, disabled, onRestore, onDeploy } = props; +export const LinodeBackupActionMenu = (props: Props) => { + const { disabled, onRestore, onDeploy } = props; const disabledProps = { disabled, tooltip: disabled @@ -25,14 +22,14 @@ export const LinodeBackupActionMenu: React.FC = (props) => { { title: 'Restore to Existing Linode', onClick: () => { - onRestore(backup); + onRestore(); }, ...disabledProps, }, { title: 'Deploy New Linode', onClick: () => { - onDeploy(backup); + onDeploy(); }, ...disabledProps, }, @@ -45,5 +42,3 @@ export const LinodeBackupActionMenu: React.FC = (props) => { /> ); }; - -export default withRouter(LinodeBackupActionMenu); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx new file mode 100644 index 00000000000..13771e0ea3a --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { LinodeBackups } from './LinodeBackups'; +import { rest, server } from 'src/mocks/testServer'; +import { backupFactory, linodeFactory } from 'src/factories'; +import { LinodeBackupsResponse } from '@linode/api-v4'; + +// I'm so sorry, but I don't know a better way to mock react-router-dom params. +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({ linodeId: 1 })), +})); + +describe('LinodeBackups', () => { + it('renders a list of different types of backups if backups are enabled', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json(linodeFactory.build({ id: 1, backups: { enabled: true } })) + ); + }), + rest.get('*/linode/instances/1/backups', (req, res, ctx) => { + const response: LinodeBackupsResponse = { + automatic: backupFactory.buildList(1, { label: null, type: 'auto' }), + snapshot: { + in_progress: backupFactory.build({ + label: 'in-progress-test-backup', + created: '2023-05-03T04:00:05', + finished: '2023-05-03T04:02:06', + type: 'snapshot', + }), + current: backupFactory.build({ + label: 'current-snapshot', + type: 'snapshot', + status: 'needsPostProcessing', + }), + }, + }; + return res(ctx.json(response)); + }) + ); + + const { findByText, getByText } = renderWithTheme(); + + // Verify an automated backup renders + await findByText('current-snapshot'); + getByText('Automatic'); + + // Verify an `in_progress` snapshot renders + getByText('in-progress-test-backup'); + getByText('2 minutes, 1 second'); + + // Verify an `current` snapshot renders + getByText('current-snapshot'); + getByText('Processing'); + }); + + it('renders BackupsPlaceholder is backups are not enabled on this linode', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json(linodeFactory.build({ id: 1, backups: { enabled: false } })) + ); + }) + ); + + const { findByText } = renderWithTheme(); + + await findByText('Enable Backups'); + }); +}); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx new file mode 100644 index 00000000000..8fd56ea67b7 --- /dev/null +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { Table } from 'src/components/Table'; +import TableRowEmptyState from 'src/components/TableRowEmptyState'; +import Button from 'src/components/Button'; +import Paper from 'src/components/core/Paper'; +import { TableBody } from 'src/components/TableBody'; +import { TableHead } from 'src/components/TableHead'; +import Typography from 'src/components/core/Typography'; +import ErrorState from 'src/components/ErrorState'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import LinodePermissionsError from '../LinodePermissionsError'; +import BackupsPlaceholder from './BackupsPlaceholder'; +import BackupTableRow from './BackupTableRow'; +import { Box, Stack } from '@mui/material'; +import { Theme } from '@mui/material/styles'; +import { LinodeBackup } from '@linode/api-v4/lib/linodes'; +import { useHistory, useParams } from 'react-router-dom'; +import { RestoreToLinodeDrawer } from './RestoreToLinodeDrawer'; +import { useTypeQuery } from 'src/queries/types'; +import { makeStyles } from 'tss-react/mui'; +import { CancelBackupsDialog } from './CancelBackupsDialog'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ScheduleSettings } from './ScheduleSettings'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useLinodeBackupsQuery } from 'src/queries/linodes/backups'; +import { CaptureSnapshot } from './CaptureSnapshot'; + +const useStyles = makeStyles()((theme: Theme) => ({ + cancelButton: { + marginBottom: theme.spacing(1), + [theme.breakpoints.down('md')]: { + marginLeft: theme.spacing(), + marginRight: theme.spacing(), + }, + }, + cancelCopy: { + [theme.breakpoints.down('md')]: { + marginLeft: theme.spacing(), + marginRight: theme.spacing(), + }, + }, +})); + +export const LinodeBackups = () => { + const { linodeId } = useParams<{ linodeId: string }>(); + const id = Number(linodeId); + + const history = useHistory(); + const { classes } = useStyles(); + + const { data: profile } = useProfile(); + const { data: grants } = useGrants(); + + const doesNotHavePermission = + Boolean(profile?.restricted) && + grants?.linode.find( + (grant) => grant.id === linode?.id && grant.permissions !== 'read_write' + ) !== undefined; + + const { data: linode } = useLinodeQuery(id); + const { data: type } = useTypeQuery(linode?.type ?? '', linode !== undefined); + const { data: backups, error, isLoading } = useLinodeBackupsQuery( + id, + Boolean(linode?.backups.enabled) + ); + + const [isRestoreDrawerOpen, setIsRestoreDrawerOpen] = React.useState(false); + + const [ + isCancelBackupsDialogOpen, + setIsCancelBackupsDialogOpen, + ] = React.useState(false); + + const [selectedBackup, setSelectedBackup] = React.useState(); + + const handleDeploy = (backup: LinodeBackup) => { + history.push( + '/linodes/create' + + `?type=Backups&backupID=${backup.id}&linodeID=${linode?.id}&typeID=${linode?.type}` + ); + }; + + const onRestoreBackup = (backup: LinodeBackup) => { + setIsRestoreDrawerOpen(true); + setSelectedBackup(backup); + }; + + if (error) { + return ( + + ); + } + + const backupsMonthlyPrice = type?.addons?.backups?.price?.monthly ?? 0; + + if (isLoading) { + return ; + } + + if (!linode?.backups.enabled) { + return ( + + ); + } + + const hasBackups = + backups !== undefined && + (backups?.automatic.length > 0 || + Boolean(backups?.snapshot.current) || + Boolean(backups?.snapshot.in_progress)); + + return ( + + {doesNotHavePermission && } + + + + + Label + Status + Date Created + Duration + Disks + Space Required + + + + + {hasBackups ? ( + <> + {backups?.automatic.map((backup: LinodeBackup, idx: number) => ( + handleDeploy(backup)} + handleRestore={() => onRestoreBackup(backup)} + /> + ))} + {Boolean(backups?.snapshot.current) && ( + + handleDeploy(backups!.snapshot.current!) + } + handleRestore={() => + onRestoreBackup(backups!.snapshot.current!) + } + /> + )} + {Boolean(backups?.snapshot.in_progress) && ( + + handleDeploy(backups!.snapshot.in_progress!) + } + handleRestore={() => + onRestoreBackup(backups!.snapshot.in_progress!) + } + /> + )} + + ) : ( + + )} + +
+
+ + + + + + Please note that when you cancel backups associated with this Linode, + this will remove all existing backups. + + + setIsRestoreDrawerOpen(false)} + /> + setIsCancelBackupsDialogOpen(false)} + linodeId={id} + /> +
+ ); +}; + +export default LinodeBackups; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.test.tsx index 4896f82dcec..32cd10cd4b8 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.test.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.test.tsx @@ -1,27 +1,30 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { CombinedProps, RestoreToLinodeDrawer } from './RestoreToLinodeDrawer'; +import { RestoreToLinodeDrawer } from './RestoreToLinodeDrawer'; +import { rest, server } from 'src/mocks/testServer'; +import { backupFactory, linodeFactory } from 'src/factories'; describe('RestoreToLinodeDrawer', () => { - const props: CombinedProps = { - open: true, - linodeID: 1234, - linodeRegion: 'us-east', - backupCreated: '12 hours ago', - onClose: jest.fn(), - onSubmit: jest.fn(), - linodesData: [], - linodesLastUpdated: 0, - linodesLoading: false, - getLinodes: jest.fn(), - linodesResults: 0, - }; - it('renders without crashing', async () => { - const { findByText } = renderWithTheme( - + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json(linodeFactory.build({ id: 1, backups: { enabled: true } })) + ); + }) + ); + + const backup = backupFactory.build({ created: '2023-05-03T04:00:47' }); + + const { getByText } = renderWithTheme( + ); - await findByText(/Restore Backup from/); + getByText(`Restore Backup from ${backup.created}`); }); }); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index 258c451185e..b45fbf183c0 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -1,197 +1,167 @@ -import { restoreBackup } from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; -import { compose } from 'recompose'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; import CheckBox from 'src/components/CheckBox'; import FormControl from 'src/components/core/FormControl'; import FormControlLabel from 'src/components/core/FormControlLabel'; import FormHelperText from 'src/components/core/FormHelperText'; -import InputLabel from 'src/components/core/InputLabel'; import Drawer from 'src/components/Drawer'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import Notice from 'src/components/Notice'; -import withLinodes, { - Props as LinodeProps, -} from 'src/containers/withLinodes.container'; -import { useGrants } from 'src/queries/profile'; -import { getPermissionsForLinode } from 'src/store/linodes/permissions/permissions.selector'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import { Grants } from '@linode/api-v4/lib'; +import { useFormik } from 'formik'; +import { LinodeBackup } from '@linode/api-v4/lib/linodes'; +import { useSnackbar } from 'notistack'; +import { resetEventsPolling } from 'src/eventsPolling'; +import { getErrorMap } from 'src/utilities/errorUtils'; +import { useLinodeBackupRestoreMutation } from 'src/queries/linodes/backups'; +import { + useAllLinodesQuery, + useLinodeQuery, +} from 'src/queries/linodes/linodes'; interface Props { open: boolean; - linodeID: number; - linodeRegion: string; - backupCreated: string; - backupID?: number; + linodeId: number; + backup: LinodeBackup | undefined; onClose: () => void; - onSubmit: () => void; } -export type CombinedProps = Props & LinodeProps; +export const RestoreToLinodeDrawer = (props: Props) => { + const { linodeId, backup, open, onClose } = props; + const { enqueueSnackbar } = useSnackbar(); + const { data: linode } = useLinodeQuery(linodeId, open); -const canEditLinode = ( - grants: Grants | undefined, - linodeId: number -): boolean => { - return getPermissionsForLinode(grants, linodeId) === 'read_only'; -}; - -export const RestoreToLinodeDrawer: React.FC = (props) => { const { - onSubmit, - linodeID, - backupID, - open, - backupCreated, - linodesData, - linodeRegion, - } = props; - - const { data: grants } = useGrants(); - - const [overwrite, setOverwrite] = React.useState(false); - const [selectedLinodeId, setSelectedLinodeId] = React.useState( - linodeID + data: linodes, + isLoading: linodesLoading, + error: linodeError, + } = useAllLinodesQuery( + {}, + { + region: linode?.region, + }, + open && linode !== undefined ); - const [errors, setErrors] = React.useState([]); - - const reset = () => { - setOverwrite(false); - setSelectedLinodeId(null); - setErrors([]); - }; - const restoreToLinode = () => { - if (!selectedLinodeId) { - setErrors([ - ...errors, - ...[{ field: 'linode_id', reason: 'You must select a Linode' }], - ]); - scrollErrorIntoView(); - return; - } - restoreBackup(linodeID, Number(backupID), selectedLinodeId, overwrite) - .then(() => { - reset(); - onSubmit(); - }) - .catch((errResponse) => { - setErrors(getAPIErrorOrDefault(errResponse)); - scrollErrorIntoView(); + const { + mutateAsync: restoreBackup, + error, + isLoading, + reset: resetMutation, + } = useLinodeBackupRestoreMutation(); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + overwrite: false, + linode_id: linodeId, + }, + async onSubmit(values) { + await restoreBackup({ + linodeId, + backupId: backup?.id ?? -1, + targetLinodeId: values.linode_id ?? -1, + overwrite: values.overwrite, }); - }; - - const handleToggleOverwrite = () => { - setOverwrite((prevOverwrite) => !prevOverwrite); - }; - - const handleCloseDrawer = () => { - reset(); - props.onClose(); - }; - - const errorResources = { - linode_id: 'Linode', - overwrite: 'Overwrite', - }; - - const hasErrorFor = getAPIErrorsFor(errorResources, errors); + enqueueSnackbar( + `Started restoring Linode ${selectedLinodeOption?.label} from a backup`, + { variant: 'info' } + ); + resetEventsPolling(); + onClose(); + }, + }); + + React.useEffect(() => { + if (open) { + formik.resetForm(); + resetMutation(); + } + }, [open]); - const linodeError = hasErrorFor('linode_id'); - const overwriteError = hasErrorFor('overwrite'); - const generalError = hasErrorFor('none'); + const linodeOptions = + linodes?.map(({ label, id }) => { + return { label, value: id }; + }) ?? []; - const readOnly = canEditLinode(grants, selectedLinodeId ?? -1); - const selectError = Boolean(linodeError) || readOnly; + const selectedLinodeOption = linodeOptions.find( + (option) => option.value === formik.values.linode_id + ); - const linodeOptions = linodesData - .filter((linode) => linode.region === linodeRegion) - .map(({ label, id }) => { - return { label, value: id }; - }); + const errorMap = getErrorMap(['linode_id', 'overwrite'], error); return ( - - - Linode - +
+ {Boolean(errorMap.none) && {errorMap.none}} settingsForm.setFieldValue('day', item.value)} + value={dayOptions.find( + (item) => item.value === settingsForm.values.day + )} + disabled={isReadOnly} + label="Day of Week" + placeholder="Choose a day" + isClearable={false} + name="Day of Week" + noMarginTop + /> + + +