diff --git a/packages/api-v4/.changeset/pr-11048-changed-1728076842800.md b/packages/api-v4/.changeset/pr-11048-changed-1728076842800.md new file mode 100644 index 00000000000..2d1166729f1 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11048-changed-1728076842800.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Specify the fork restore payload and return types ([#11048](https://github.com/linode/manager/pull/11048)) diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 7396c890a6e..53e5cbea952 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -22,6 +22,7 @@ import { Engine, SSLFields, UpdateDatabasePayload, + DatabaseFork, } from './types'; /** @@ -248,14 +249,8 @@ export const legacyRestoreWithBackup = ( * * Fully restore a backup to the cluster */ -export const restoreWithBackup = ( - engine: Engine, - fork: { - source: number; - restore_time?: string; - } -) => - Request<{}>( +export const restoreWithBackup = (engine: Engine, fork: DatabaseFork) => + Request( setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/instances`), setMethod('POST'), setData({ fork }) diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 71e0f2d9a64..feb7987fde2 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -47,6 +47,11 @@ export interface DatabaseBackup { created: string; } +export interface DatabaseFork { + source: number; + restore_time?: string; +} + export interface DatabaseCredentials { username: string; password: string; diff --git a/packages/manager/.changeset/pr-11048-upcoming-features-1728076876251.md b/packages/manager/.changeset/pr-11048-upcoming-features-1728076876251.md new file mode 100644 index 00000000000..26a86cb07aa --- /dev/null +++ b/packages/manager/.changeset/pr-11048-upcoming-features-1728076876251.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS GA enhancements to backups tab and beta fixes ([#11048](https://github.com/linode/manager/pull/11048)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts index 547fe03bfbf..2b00ab590c0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts @@ -1,21 +1,15 @@ import { styled } from '@mui/material/styles'; -import { DateCalendar, TimePicker } from '@mui/x-date-pickers'; - -import { Box } from 'src/components/Box'; +import { DateCalendar } from '@mui/x-date-pickers'; import { Typography } from 'src/components/Typography'; +import { makeStyles } from 'tss-react/mui'; -export const StyledTimePicker = styled(TimePicker)(() => ({ - '.MuiInputAdornment-root': { marginRight: '0' }, - '.MuiInputBase-input': { padding: '8px 0 8px 12px' }, - '.MuiInputBase-root': { borderRadius: '0', padding: '0px' }, - - 'button.MuiButtonBase-root': { - marginRight: '0', - padding: '8px', +export const useStyles = makeStyles()(() => ({ + timeAutocomplete: { + width: '140px', + '.MuiBox-root': { + marginTop: '0', + }, }, - height: '34px', - marginTop: '8px', - width: '120px', })); export const StyledDateCalendar = styled(DateCalendar, { @@ -56,25 +50,6 @@ export const StyledDateCalendar = styled(DateCalendar, { width: '260px', })); -export const StyledBox = styled(Box)(({ theme }) => ({ - '& h6': { - fontSize: '0.875rem', - }, - '& span': { - marginBottom: '5px', - marginTop: '7px', - }, - alignItems: 'flex-start', - - border: '1px solid #F4F4F4', - color: theme.name === 'light' ? '#555555' : theme.color.headline, - display: 'flex', - flexDirection: 'column', - height: '100%', - padding: '8px 15px', - background: theme.name === 'light' ? '#FBFBFB' : theme.color.grey2, -})); - export const StyledTypography = styled(Typography)(() => ({ lineHeight: '20px', marginTop: '4px', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index 081dbc98cf3..0abbd2a4cad 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -95,10 +95,9 @@ describe('Database Backups', () => { }); }); - it('should disable the restore button if no oldest_restore_time is returned', async () => { + it('should enable the restore button if disabled = false', async () => { const mockDatabase = databaseFactory.build({ - oldest_restore_time: undefined, - platform: 'rdbms-default', + platform: 'rdbms-legacy', }); const backups = databaseBackupFactory.buildList(7); @@ -115,19 +114,20 @@ describe('Database Backups', () => { ); const { findAllByText } = renderWithTheme( - + ); const buttonSpans = await findAllByText('Restore'); - expect(buttonSpans.length).toEqual(1); + expect(buttonSpans.length).toEqual(7); buttonSpans.forEach((span: HTMLSpanElement) => { const button = span.closest('button'); - expect(button).toBeDisabled(); + expect(button).toBeEnabled(); }); }); - it('should enable the restore button if disabled = false', async () => { + it('should disable the restore button if no oldest_restore_time is returned', async () => { const mockDatabase = databaseFactory.build({ - platform: 'rdbms-legacy', + oldest_restore_time: undefined, + platform: 'rdbms-default', }); const backups = databaseBackupFactory.buildList(7); @@ -144,17 +144,34 @@ describe('Database Backups', () => { ); const { findAllByText } = renderWithTheme( - + ); const buttonSpans = await findAllByText('Restore'); - expect(buttonSpans.length).toEqual(7); + expect(buttonSpans.length).toEqual(1); buttonSpans.forEach((span: HTMLSpanElement) => { const button = span.closest('button'); - expect(button).toBeEnabled(); + expect(button).toBeDisabled(); }); }); - it('should render a time picker when it is a new database', async () => { + it('should render a date picker when it is a default database', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-default', + }); + + server.use( + http.get('*/databases/:engine/instances/:id', () => { + return HttpResponse.json(mockDatabase); + }) + ); + + const rendered = renderWithTheme(); + expect( + rendered.container.getElementsByClassName('MuiDateCalendar-root') + ).toBeDefined(); + }); + + it('should render a time picker when it is a default database', async () => { const mockDatabase = databaseFactory.build({ platform: 'rdbms-default', }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 35aafb99e5c..513d881ddf7 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -1,134 +1,102 @@ -import { FormControl } from '@mui/material'; +import type { Engine } from '@linode/api-v4/lib/databases'; +import { + FormControl, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; import Grid from '@mui/material/Grid'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateTime } from 'luxon'; import * as React from 'react'; import { useParams } from 'react-router-dom'; - import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import Select from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableRowError } from 'src/components/TableRowError/TableRowError'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; import { StyledDateCalendar, StyledTypography, + useStyles, } from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style'; -import RestoreLegacyFromBackupDialog from 'src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreLegacyFromBackupDialog'; -import RestoreNewFromBackupDialog from 'src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreNewFromBackupDialog'; -import { isOutsideBackupTimeframe } from 'src/features/Databases/utilities'; -import { useOrder } from 'src/hooks/useOrder'; -import { - useDatabaseBackupsQuery, - useDatabaseQuery, -} from 'src/queries/databases/databases'; - -import { BackupTableRow } from './DatabaseBackupTableRow'; -import type { DatabaseBackup, Engine } from '@linode/api-v4/lib/databases'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { + isDateOutsideBackup, + isTimeOutsideBackup, + useIsDatabasesEnabled, +} from 'src/features/Databases/utilities'; +import { useDatabaseQuery } from 'src/queries/databases/databases'; +import DatabaseBackupsDialog from './DatabaseBackupsDialog'; +import DatabaseBackupsLegacy from './legacy/DatabaseBackupsLegacy'; interface Props { disabled?: boolean; } +export interface TimeOption { + label: string; + value: number; +} + +const TIME_OPTIONS: TimeOption[] = [ + { label: '00:00', value: 0 }, + { label: '01:00', value: 1 }, + { label: '02:00', value: 2 }, + { label: '03:00', value: 3 }, + { label: '04:00', value: 4 }, + { label: '05:00', value: 5 }, + { label: '06:00', value: 6 }, + { label: '07:00', value: 7 }, + { label: '08:00', value: 8 }, + { label: '09:00', value: 9 }, + { label: '10:00', value: 10 }, + { label: '11:00', value: 11 }, + { label: '12:00', value: 12 }, + { label: '13:00', value: 13 }, + { label: '14:00', value: 14 }, + { label: '15:00', value: 15 }, + { label: '16:00', value: 16 }, + { label: '17:00', value: 17 }, + { label: '18:00', value: 18 }, + { label: '19:00', value: 19 }, + { label: '20:00', value: 20 }, + { label: '21:00', value: 21 }, + { label: '22:00', value: 22 }, + { label: '23:00', value: 23 }, +]; + +export type VersionOption = 'newest' | 'dateTime'; + export const DatabaseBackups = (props: Props) => { + const { classes } = useStyles(); const { disabled } = props; const { databaseId, engine } = useParams<{ databaseId: string; engine: Engine; }>(); + const { isV2GAUser } = useIsDatabasesEnabled(); const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); - const [idOfBackupToRestore, setIdOfBackupToRestore] = React.useState< - number | undefined - >(); - const [selectedDate, setSelectedDate] = React.useState(null); - const [selectedTime, setSelectedTime] = React.useState( - DateTime.now().set({ hour: 1, minute: 0, second: 0 }) + const [selectedTime, setSelectedTime] = React.useState( + null + ); + const [versionOption, setVersionOption] = React.useState( + isV2GAUser ? 'newest' : 'dateTime' ); - const [ - selectedRestoreTime, - setSelectedRestoreTime, - ] = React.useState(); - - const id = Number(databaseId); const { data: database, error: databaseError, isLoading: isDatabaseLoading, - } = useDatabaseQuery(engine, id); + } = useDatabaseQuery(engine, Number(databaseId)); const isDefaultDatabase = database?.platform === 'rdbms-default'; - const { - data: backups, - error: backupsError, - isLoading: isBackupsLoading, - } = useDatabaseBackupsQuery(engine, id, !isDefaultDatabase); - - const { handleOrderChange, order, orderBy } = useOrder({ - order: 'desc', - orderBy: 'created', - }); - - const onRestoreLegacyDatabase = (id: number) => { - setIdOfBackupToRestore(id); - setIsRestoreDialogOpen(true); - }; - - const backupToRestoreLegacy = backups?.data.find( - (backup) => backup.id === idOfBackupToRestore - ); - - const sorter = (a: DatabaseBackup, b: DatabaseBackup) => { - if (order === 'asc') { - return new Date(b.created).getTime() - new Date(a.created).getTime(); - } - return new Date(a.created).getTime() - new Date(b.created).getTime(); - }; - - const renderTableBody = () => { - if (databaseError) { - return ; - } - if (backupsError) { - return ; - } - if (isDatabaseLoading || isBackupsLoading) { - return ; - } - if (backups?.results === 0) { - return ; - } - if (backups) { - return backups.data - .sort(sorter) - .map((backup) => ( - - )); - } - return null; - }; - const oldestBackup = database?.oldest_restore_time ? DateTime.fromISO(database.oldest_restore_time) : null; @@ -137,15 +105,30 @@ export const DatabaseBackups = (props: Props) => { ? 'You can restore a backup after the first backup is completed.' : ''; - const onRestoreNewDatabase = (selectedDate: DateTime | null) => { - const day = selectedDate?.toISODate(); - const time = selectedTime?.toISOTime({ includeOffset: false }); - const selectedDateTime = `${day}T${time}Z`; + const onRestoreDatabase = () => { + setIsRestoreDialogOpen(true); + }; + + const handleDateChange = (newDate: DateTime) => { + const isSelectedTimeInvalid = isTimeOutsideBackup( + selectedTime?.value, + newDate, + oldestBackup! + ); + // If the user has selcted a time then changes the date, + // that date + time might now be outside of the backup timeframe. + // Reset selectedTime to null so user can select a valid time. + if (isSelectedTimeInvalid) { + setSelectedTime(null); + } - const selectedTimestamp = new Date(selectedDateTime).toISOString(); + setSelectedDate(newDate); + }; - setSelectedRestoreTime(selectedTimestamp); - setIsRestoreDialogOpen(true); + const handleOnVersionOptionChange = (_: any, value: string) => { + setVersionOption(value as VersionOption); + setSelectedDate(null); + setSelectedTime(null); }; return isDefaultDatabase ? ( @@ -157,49 +140,58 @@ export const DatabaseBackups = (props: Props) => { version-specific binary backups, which when combined with binary logs allow for consistent recovery to a specific point in time (PITR). - {/* TODO: Uncomment when the all data is available (Number of Full Backups, Newest Full Backup, Oldest Full Backup) */} - {/* - - - - Number of Full Backups - - {backups?.data.length} - - - - - - Newest Full Backup - {newestBackup} (UTC) - - - - - Oldest Full Backup - {oldestBackup} (UTC) - - - - */} Restore a Backup - Select a date and time within the last 10 days you want to create a fork - from. + {isV2GAUser ? ( + + The newest full backup plus incremental is selected by default. Or, + select any date and time within the last 10 days you want to create + a fork from. + + ) : ( + + Select a date and time within the last 10 days you want to create a + forkfrom. + + )} {unableToRestoreCopy && ( )} + {isV2GAUser && ( + + } + data-qa-dbaas-radio="Newest" + disabled={disabled} + label="Newest full backup plus incremental" + value="newest" + /> + } + data-qa-dbaas-radio="DateTime" + disabled={disabled} + label="Specific date & time" + value="dateTime" + /> + + )} Date - isOutsideBackupTimeframe(date, oldestBackup) + isDateOutsideBackup(date, oldestBackup?.startOf('day')) } - onChange={(newDate) => setSelectedDate(newDate)} + onChange={handleDateChange} value={selectedDate} /> @@ -208,29 +200,31 @@ export const DatabaseBackups = (props: Props) => { Time (UTC) {/* TODO: Replace Time Select to the own custom date-time picker component when it's ready */} -