Merge pull request #7231 from supabase/feat/point-in-time-recovery

feat/point in time recovery
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-06-27 16:59:55 +08:00
commit 31246ac79e
33 files changed
+1940 -333

No files matched your search

@@ -1,94 +0,0 @@
import * as React from 'react'
import { Button, Modal } from '@supabase/ui'
import { render, unmountComponentAtNode } from 'react-dom'
type ConfirmModalProps = {
title: string
message: string
onConfirm?: () => void
onAsyncConfirm?: () => Promise<void>
variant?: 'danger' | 'warning' | 'success' | undefined
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({
title,
message,
onConfirm,
onAsyncConfirm,
variant = 'danger',
}) => {
const [loading, setLoading] = React.useState(false)
function onCancelClick() {
if (!loading) onClose()
}
async function onConfirmClick() {
if (onAsyncConfirm) {
setLoading(true)
await onAsyncConfirm()
onClose()
} else if (onConfirm) {
onConfirm()
onClose()
}
}
function onClose() {
removeElement()
}
return (
<Modal
variant={variant}
visible={true}
header={title}
showIcon={false}
size="small"
confirmText="OK"
cancelText="Cancel"
onCancel={onCancelClick}
loading={loading}
customFooter={
<>
<div className="flex items-center gap-2">
<Button type="default" onClick={() => onCancelClick()}>
Cancel
</Button>
<Button type="primary" loading={loading} onClick={() => onConfirmClick()}>
Confirm
</Button>
</div>
</>
}
>
<Modal.Content>
<p className="text-scale-1100 py-4 text-sm">{message}</p>
</Modal.Content>
</Modal>
)
}
function removeElement() {
const target = document.getElementById('supabase-ui-confirm-alert')
if (target) {
unmountComponentAtNode(target)
target?.parentNode?.removeChild(target)
}
}
function createElement(props: ConfirmModalProps) {
let divTarget = document.getElementById('supabase-ui-confirm-alert')
if (divTarget) {
render(<ConfirmModal {...props} />, divTarget)
} else {
divTarget = document.createElement('div')
divTarget.id = 'supabase-ui-confirm-alert'
document.body.appendChild(divTarget)
render(<ConfirmModal {...props} />, divTarget)
}
}
export function showConfirmAlert(props: ConfirmModalProps) {
createElement(props)
}
@@ -1,8 +1,7 @@
export * from './BlockKeys';
export * from './ConfirmAlert';
export * from './DropdownControl';
export * from './MonacoEditor';
export * from './NullValue';
export * from './Hooks';
export * from './Shortcuts';
export * from './EmptyValue';
export * from './BlockKeys'
export * from './DropdownControl'
export * from './MonacoEditor'
export * from './NullValue'
export * from './Hooks'
export * from './Shortcuts'
export * from './EmptyValue'
@@ -1,8 +1,9 @@
import { FC, useState, useEffect } from 'react'
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { Button, InputNumber, IconArrowRight, IconArrowLeft, IconLoader } from '@supabase/ui'
import { DropdownControl, showConfirmAlert } from '../../common'
import { DropdownControl } from '../../common'
import { useDispatch, useTrackedState } from '../../../store'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
const updatePage = (payload: number, dispatch: (value: unknown) => void) => {
dispatch({
@@ -41,7 +42,7 @@ const Pagination: FC<PaginationProps> = () => {
const onPreviousPage = () => {
if (state.page > 1) {
if (state.selectedRows.size >= 1) {
showConfirmAlert({
confirmAlert({
title: 'Confirm moving to previous page',
message: 'The currently selected lines will be deselected, do you want to proceed?',
onConfirm: () => {
@@ -61,7 +62,7 @@ const Pagination: FC<PaginationProps> = () => {
const onNextPage = () => {
if (state.page < maxPages) {
if (state.selectedRows.size >= 1) {
showConfirmAlert({
confirmAlert({
title: 'Confirm moving to next page',
message: 'The currently selected lines will be deselected, do you want to proceed?',
onConfirm: () => {
@@ -1,13 +1,13 @@
import * as React from 'react'
import { useDispatch, useTrackedState } from '../../store'
import { Button, Divider, IconDownload, IconPlus, IconX, IconTrash, Typography } from '@supabase/ui'
import { Button, Divider, IconDownload, IconPlus, IconX, IconTrash } from '@supabase/ui'
import { saveAs } from 'file-saver'
import FilterDropdown from './filter'
import SortPopover from './sort'
import StatusLabel from './StatusLabel'
import RefreshButton from './RefreshButton'
import { exportRowsToCsv } from '../../utils'
import { showConfirmAlert } from '../common'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
type HeaderProps = {
onAddColumn?: () => void
@@ -91,7 +91,7 @@ const RowHeader: React.FC<RowHeaderProps> = ({}) => {
const { selectedRows, rows: allRows, editable } = state
const onRowsDelete = () => {
showConfirmAlert({
confirmAlert({
title: 'Confirm to delete',
message: 'Are you sure you want to delete the selected rows? This action cannot be undone.',
onConfirm: async () => {
@@ -1,71 +1,69 @@
import * as React from 'react';
import { Menu, Item, ItemParams, PredicateParams } from 'react-contexify';
import { IconTrash, IconClipboard, IconEdit } from '@supabase/ui';
import { useDispatch, useTrackedState } from '../../store';
import { formatClipboardValue, copyToClipboard } from '../../utils';
import { showConfirmAlert } from '../common';
import * as React from 'react'
import { Menu, Item, ItemParams, PredicateParams } from 'react-contexify'
import { IconTrash, IconClipboard, IconEdit } from '@supabase/ui'
import { useDispatch, useTrackedState } from '../../store'
import { formatClipboardValue, copyToClipboard } from '../../utils'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id';
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id'
type RowContextMenuProps = {};
type RowContextMenuProps = {}
const RowContextMenu: React.FC<RowContextMenuProps> = ({}) => {
const state = useTrackedState();
const dispatch = useDispatch();
const state = useTrackedState()
const dispatch = useDispatch()
function onDeleteRow(p: ItemParams) {
showConfirmAlert({
confirmAlert({
title: 'Confirm to delete',
message:
'Are you sure you want to delete this row? This action cannot be undone.',
message: 'Are you sure you want to delete this row? This action cannot be undone.',
onConfirm: async () => {
const { props } = p;
const { rowIdx } = props;
const row = state.rows[rowIdx];
const { error } = state.rowService!.delete([row]);
const { props } = p
const { rowIdx } = props
const row = state.rows[rowIdx]
const { error } = state.rowService!.delete([row])
if (error) {
if (state.onError) state.onError(error);
if (state.onError) state.onError(error)
} else {
dispatch({ type: 'REMOVE_ROWS', payload: { rowIdxs: [row.idx] } });
dispatch({ type: 'REMOVE_ROWS', payload: { rowIdxs: [row.idx] } })
dispatch({
type: 'SELECTED_ROWS_CHANGE',
payload: { selectedRows: new Set() },
});
})
}
},
});
})
}
function onEditRowClick(p: ItemParams) {
const { props } = p;
const { rowIdx } = props;
const row = state.rows[rowIdx];
if (state.onEditRow) state.onEditRow(row);
const { props } = p
const { rowIdx } = props
const row = state.rows[rowIdx]
if (state.onEditRow) state.onEditRow(row)
}
function isItemHidden({ data }: PredicateParams) {
if (data === 'edit') return state.onEditRow == undefined;
if (data === 'delete') return !state.editable;
return false;
if (data === 'edit') return state.onEditRow == undefined
if (data === 'delete') return !state.editable
return false
}
function onCopyCellContent(p: ItemParams) {
const { props } = p;
const { props } = p
if (!state.selectedCellPosition || !props) {
return;
return
}
const { rowIdx } = props;
const row = state.rows[rowIdx];
const { rowIdx } = props
const row = state.rows[rowIdx]
const columnKey =
state.gridColumns[state.selectedCellPosition?.idx as number].key;
const columnKey = state.gridColumns[state.selectedCellPosition?.idx as number].key
const value = row[columnKey];
const text = formatClipboardValue(value);
const value = row[columnKey]
const text = formatClipboardValue(value)
copyToClipboard(text);
copyToClipboard(text)
}
return (
@@ -85,6 +83,6 @@ const RowContextMenu: React.FC<RowContextMenuProps> = ({}) => {
</Item>
</Menu>
</>
);
};
export default RowContextMenu;
)
}
export default RowContextMenu
@@ -3,7 +3,7 @@ import * as React from 'react'
import { useDispatch, useTrackedState } from '../../store'
import { exportRowsToCsv } from '../../utils'
import { saveAs } from 'file-saver'
import { showConfirmAlert } from '../common'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
type RowMenuProps = {}
@@ -13,7 +13,7 @@ const RowMenu: React.FC<RowMenuProps> = ({}) => {
const { selectedRows, rows: allRows, editable } = state
function onRowsDelete() {
showConfirmAlert({
confirmAlert({
title: 'Confirm to delete',
message: 'Are you sure you want to delete the selected rows? This action cannot be undone.',
onConfirm: async () => {
@@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite'
import { Badge, Button, IconDownload } from '@supabase/ui'
import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { post } from 'lib/common/fetch'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
@@ -17,17 +17,26 @@ interface Props {
const BackupItem: FC<Props> = ({ projectRef, backup, index }) => {
const router = useRouter()
const { ui } = useStore()
const { app, ui } = useStore()
const [isDownloading, setDownloading] = useState<boolean>(false)
const [isRestoring, setRestoring] = useState<boolean>(false)
const projectId = ui.selectedProject?.id ?? -1
async function restore(backup: any) {
setRestoring(true)
try {
post(`${API_URL}/database/${projectRef}/backups/restore`, backup).then(() => {
setTimeout(() => {
router.push('/project/[id]', `/project/${projectRef}`)
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
ui.setNotification({
category: 'success',
message: `Restoring database back to ${dayjs(backup.inserted_at).format(
'DD MMM YYYY HH:mm:ss'
)}`,
})
router.push(`/project/${projectRef}`)
}, 3000)
})
} catch (error) {
@@ -46,7 +55,7 @@ const BackupItem: FC<Props> = ({ projectRef, backup, index }) => {
const res = await post(`${API_URL}/database/${projectRef}/backups/download`, backup)
const { fileUrl } = await res
// triger browser download by create,trigger and remove tempLink
// Trigger browser download by create,trigger and remove tempLink
const tempLink = document.createElement('a')
tempLink.href = fileUrl
document.body.appendChild(tempLink)
@@ -101,18 +110,16 @@ const BackupItem: FC<Props> = ({ projectRef, backup, index }) => {
}
return (
<>
<div
className={`flex h-12 items-center justify-between px-6 ${
index ? 'dark:border-dark border-t' : ''
}`}
>
<p className="text-scale-1200 text-sm ">
{dayjs(backup.inserted_at).format('DD MMM YYYY HH:mm:ss')}
</p>
<div className="">{generateSideButtons(backup)}</div>
</div>
</>
<div
className={`flex h-12 items-center justify-between px-6 ${
index ? 'dark:border-dark border-t' : ''
}`}
>
<p className="text-scale-1200 text-sm ">
{dayjs(backup.inserted_at).format('DD MMM YYYY HH:mm:ss')}
</p>
<div className="">{generateSideButtons(backup)}</div>
</div>
)
}
@@ -0,0 +1,14 @@
import { IconInfo } from '@supabase/ui'
const BackupsEmpty = () => {
return (
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">No backups created yet - check again tomorrow.</p>
</div>
</div>
)
}
export default BackupsEmpty
@@ -0,0 +1,14 @@
import { IconInfo } from '@supabase/ui'
const BackupsError = () => {
return (
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">Failed to retrieve backups for project. Please contact support.</p>
</div>
</div>
)
}
export default BackupsError
@@ -1,48 +1,28 @@
import React, { FC, useState, useEffect } from 'react'
import { FC } from 'react'
import { observer } from 'mobx-react-lite'
import { Typography, IconInfo } from '@supabase/ui'
import { useStore } from 'hooks'
import { STRIPE_PRODUCT_IDS } from 'lib/constants'
import BackupItem from './BackupItem'
import Loading from 'components/ui/Loading'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import Panel from 'components/ui/Panel'
import BackupItem from './BackupItem'
import BackupsError from './BackupsError'
import BackupsEmpty from './BackupsEmpty'
interface Props {}
const BackupsList: FC<Props> = ({}) => {
const { ui, app } = useStore()
const { ui, backups } = useStore()
const projectRef = ui.selectedProject?.ref || 'default'
const [isLoading, setIsLoading] = useState<boolean>(true)
const [projectData, setProjectData] = useState<any>([])
if (backups.isLoading) return <Loading />
if (backups.error) return <BackupsError />
useEffect(() => {
retrieveBackups()
}, [])
const { tierKey } = backups.configuration
const sortedBackups = backups.list()
const retrieveBackups = async () => {
setIsLoading(true)
const projectData = (await app.database.getBackups(projectRef)) as any
setProjectData(projectData)
setIsLoading(false)
}
if (isLoading) return <Loading />
if (projectData.error) {
return (
<div>
<Typography.Text type="secondary">Error loading backups</Typography.Text>
</div>
)
}
// Data Loaded
const { backups, tierId } = projectData
const sortedBackups = backups.sort((a: any, b: any) => b.id - a.id)
if (tierId === STRIPE_PRODUCT_IDS.FREE) {
if (tierKey === 'FREE') {
return (
<UpgradeToPro
primaryText="Free Plan does not include project backups."
@@ -54,15 +34,8 @@ const BackupsList: FC<Props> = ({}) => {
return (
<div className="space-y-6">
{!sortedBackups?.length && tierId !== STRIPE_PRODUCT_IDS.FREE ? (
<div className="block w-full rounded border border-green-500 border-opacity-50 bg-green-500 bg-opacity-5 p-3">
<div className="flex space-x-3">
<div>
<IconInfo className="text-green-500" size="large" />
</div>
<p>No backups created yet. Check again tomorrow.</p>
</div>
</div>
{!sortedBackups?.length && tierKey !== 'FREE' ? (
<BackupsEmpty />
) : (
<Panel>
{sortedBackups?.map((x: any, i: number) => {
@@ -0,0 +1,8 @@
export interface Timezone {
value: string
abbr: string
offset: number
isdst: boolean
text: string
utc: string[]
}
@@ -0,0 +1,27 @@
import dayjs from 'dayjs'
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
import { Timezone } from './PITRBackupSelection.types'
export const getClientTimezone = () => {
const defaultTz = dayjs.tz.guess()
const timezone = ALL_TIMEZONES.find((option) => {
if (option.utc.includes(defaultTz)) return option
else return undefined
})
return timezone
}
export const getTimezoneOffsetText = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(1)
}
export const getTimezoneOffset = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(4)
}
export const convertTimeStringtoUnixS = (datetime: string, timezone: Timezone) => {
// Datetime in the format of YYYY-MM-DDTHH:mm:ss (from datetime-local input)
const timezoneOffset = getTimezoneOffset(timezone)
const datetimestringWithTimezone = `${datetime}${timezoneOffset}`
return dayjs(datetimestringWithTimezone).unix()
}
@@ -0,0 +1,290 @@
import dayjs from 'dayjs'
import { FC, FormEvent, useState } from 'react'
import { useRouter } from 'next/router'
import { IconSearch, Input, Listbox, Popover, Button, Modal } from '@supabase/ui'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { useStore, useFlag } from 'hooks'
import Loading from 'components/ui/Loading'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import BackupsError from '../BackupsError'
import BackupsEmpty from '../BackupsEmpty'
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
import {
getClientTimezone,
getTimezoneOffsetText,
convertTimeStringtoUnixS,
} from './PITRBackupSelection.utils'
interface Props {}
const PITRBackupSelection: FC<Props> = () => {
const router = useRouter()
const { app, ui, backups } = useStore()
const isAvailable = useFlag('pitrBackups')
if (!isAvailable) {
return (
<div className="flex items-center justify-center rounded border border-gray-500 bg-gray-300 py-8">
<p className="text-scale-1000 text-sm">Coming soon</p>
</div>
)
}
const [searchString, setSearchString] = useState<string>('')
const [selectedTimezone, setSelectedTimezone] = useState<any>(getClientTimezone())
const [showConfirmation, setShowConfirmation] = useState<boolean>(false)
const [errors, setErrors] = useState<any>()
const [recoveryPoint, setRecoveryPoint] = useState<string>()
const { configuration } = backups
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref ?? 'default'
if (backups.isLoading) {
return <Loading />
} else if (backups.error) {
return <BackupsError />
}
const hasPhysicalBackups =
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !== null &&
configuration.physicalBackupData.latestPhysicalBackupDateUnix !== null &&
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !==
configuration.physicalBackupData.latestPhysicalBackupDateUnix
if (!configuration.walg_enabled) {
// Using this check as opposed to checking price tier to allow enabling PITR for our own internal projects
return (
<UpgradeToPro
primaryText="Free Plan does not include project backups."
projectRef={projectRef}
secondaryText="Please upgrade to Pro plan for access to point in time recovery backups."
/>
)
} else if (!hasPhysicalBackups) {
return <BackupsEmpty />
}
const timezoneOptions =
searchString.length > 0
? ALL_TIMEZONES.map((option) => option.text).filter((option) =>
option.toLowerCase().includes(searchString.toLowerCase())
)
: ALL_TIMEZONES.map((option) => option.text)
const earliestAvailableBackup = dayjs
.unix(configuration.physicalBackupData.earliestPhysicalBackupDateUnix)
.tz(selectedTimezone.utc[0])
.format('YYYY-MM-DDTHH:mm:ss')
const latestAvailableBackup = dayjs
.unix(configuration.physicalBackupData.latestPhysicalBackupDateUnix)
.tz(selectedTimezone.utc[0])
.format('YYYY-MM-DDTHH:mm:ss')
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Validation
if (!recoveryPoint) {
setErrors({ recoveryPoint: 'Please enter a date to recover your project from' })
} else {
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
configuration.physicalBackupData
const isOutOfRange =
recoveryTimeTargetUnix < earliestPhysicalBackupDateUnix ||
recoveryTimeTargetUnix > latestPhysicalBackupDateUnix
if (isOutOfRange) {
setErrors({
recoveryPoint: 'Selected date is out of range for available points of recovery',
})
} else {
setErrors(undefined)
setShowConfirmation(true)
}
}
}
const onConfirmRestore = async () => {
if (!recoveryPoint) return
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
recovery_time_target_unix: recoveryTimeTargetUnix,
})
if (error) {
ui.setNotification({
error,
category: 'error',
message: `Point in time recovery for project failed`,
})
setShowConfirmation(false)
} else {
setTimeout(() => {
setShowConfirmation(false)
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
ui.setNotification({
category: 'success',
message: `Restoring database back to ${dayjs(recoveryPoint).format(
'DD MMM YYYY, HH:mm:ss'
)} (
${getTimezoneOffsetText(selectedTimezone)})`,
})
router.push(`/project/${projectRef}`)
}, 3000)
}
}
return (
<>
<form onSubmit={onSubmit}>
<div className="space-y-4">
<div className="space-y-4 rounded border border-gray-400 border-opacity-50 bg-gray-300 py-3">
<div className="space-y-4 py-2">
<div className="flex justify-between space-x-8">
<p className="w-2/5 pl-4 text-sm">Select timezone</p>
<div className="w-3/5 px-4">
<Listbox
value={selectedTimezone.text}
onChange={(text) => {
const selectedTimezone = ALL_TIMEZONES.find((option) => option.text === text)
setSelectedTimezone(selectedTimezone)
}}
onBlur={() => setSearchString('')}
>
<div
className={[
'fixed top-0 flex w-full items-center',
'rounded-t-md border-b border-gray-600 bg-gray-500',
'mb-4 space-x-2 px-4 py-2',
].join(' ')}
style={{ zIndex: 1 }}
>
<IconSearch size={14} />
<input
autoFocus
className="placeholder-scale-1000 w-72 bg-transparent text-sm outline-none"
value={searchString}
placeholder={''}
onChange={(e: FormEvent<HTMLInputElement>) =>
setSearchString(e.currentTarget.value)
}
/>
</div>
{/* Whitespace to shift listbox options down for searchfield */}
<div className="h-8" />
{timezoneOptions.map((option) => {
return (
<Listbox.Option key={option} label={option} value={option}>
<div>{option}</div>
</Listbox.Option>
)
})}
{timezoneOptions.length === 0 && (
<Listbox.Option disabled key="no-results" label="" value="">
No timezones found
</Listbox.Option>
)}
</Listbox>
</div>
</div>
<div className="flex justify-between space-x-8">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Earliest point of recovery</p>
</div>
<div className="w-3/5">
<Input
readOnly
disabled
step={1}
type="datetime-local"
className="px-4"
value={earliestAvailableBackup}
/>
</div>
</div>
<div className="flex justify-between space-x-8">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Latest point of recovery</p>
</div>
<div className="w-3/5">
<Input
readOnly
disabled
step={1}
type="datetime-local"
className="px-4"
value={latestAvailableBackup}
/>
</div>
</div>
</div>
<Popover.Seperator />
<div className="flex justify-between space-x-8 py-2">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Recovery point</p>
<p className="text-scale-1100 text-sm">
Select a date and time that you would like to restore your project to
</p>
</div>
<div className="w-3/5">
<Input
step={1}
type="datetime-local"
error={errors?.recoveryPoint}
className="px-4"
onChange={(e) => {
setErrors(undefined)
setRecoveryPoint(e.target.value)
}}
/>
</div>
</div>
<Popover.Seperator />
<div className="flex items-center justify-end px-4">
<Button type="default">Restore</Button>
</div>
</div>
</div>
</form>
<ConfirmationModal
visible={showConfirmation}
size="medium"
header="Confirm to restore"
children={
<Modal.Content>
<div className="space-y-2 py-4">
<div className="space-y-1">
<p className="text-scale-1100 text-sm">
Are you sure you want to restore your database from:
</p>
<p className="text-scale-1200 text-sm">
{dayjs(recoveryPoint).format('DD MMM YYYY, HH:mm:ss')} (
{getTimezoneOffsetText(selectedTimezone)})?
</p>
</div>
<p className="text-scale-1100 text-sm">
This will destroy any new data written since this backup was made.
</p>
</div>
</Modal.Content>
}
buttonLabel="Restore"
buttonLoadingLabel="Restoring"
onSelectCancel={() => setShowConfirmation(false)}
onSelectConfirm={onConfirmRestore}
/>
</>
)
}
export default PITRBackupSelection
@@ -10,6 +10,7 @@ import PublicationsList from './Publications/PublicationsList'
import PublicationsTables from './Publications/PublicationsTables'
import BackupsList from './Backups/BackupsList'
import PITRBackupSelection from './Backups/PITRBackupSelection'
import ConnectionPooling from './Pooling/ConnectionPooling'
@@ -28,6 +29,7 @@ export {
PublicationsList,
PublicationsTables,
BackupsList,
PITRBackupSelection,
ConnectionPooling,
CreateTrigger,
DeleteTrigger,
@@ -14,7 +14,7 @@ interface Props {
}
const DatabaseLayout: FC<Props> = ({ title, children }) => {
const { meta, ui } = useStore()
const { meta, ui, backups } = useStore()
const { isInitialized, isLoading, error } = meta.tables
const project = ui.selectedProject
@@ -33,9 +33,13 @@ const DatabaseLayout: FC<Props> = ({ title, children }) => {
meta.triggers.load()
meta.extensions.load()
meta.publications.load()
backups.load()
}
}, [ui.selectedProject])
// Optimization required: load logic should be at the page level
// e.g backups page is waiting for meta.tables to load finish when it doesnt even need that data
useEffect(() => {
if (!isLoading && !loaded) {
setLoaded(true)
@@ -29,63 +29,63 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
{
name: 'Backups',
key: 'backups',
url: `/project/${ref}/database/backups`,
url: `/project/${ref}/database/backups/scheduled`,
items: [],
},
],
},
...(IS_PLATFORM
? [
{
title: 'Logs',
items: [
{
name: 'API logs',
key: 'api-logs',
url: `/project/${ref}/database/api-logs`,
items: [],
},
{
name: 'Postgres logs',
key: 'postgres-logs',
url: `/project/${ref}/database/postgres-logs`,
items: [],
},
],
},
]
{
title: 'Logs',
items: [
{
name: 'API logs',
key: 'api-logs',
url: `/project/${ref}/database/api-logs`,
items: [],
},
{
name: 'Postgres logs',
key: 'postgres-logs',
url: `/project/${ref}/database/postgres-logs`,
items: [],
},
],
},
]
: []),
...(IS_PLATFORM
? [
{
title: 'Alpha Preview',
isPreview: true,
items: [
{
name: 'Triggers',
key: 'triggers',
url: `/project/${ref}/database/triggers`,
items: [],
},
{
name: 'Functions',
key: 'functions',
url: `/project/${ref}/database/functions`,
items: [],
},
...(showHooksRoute
? [
{
name: 'Function Hooks',
key: 'hooks',
url: `/project/${ref}/database/hooks`,
items: [],
},
]
: []),
],
},
]
{
title: 'Alpha Preview',
isPreview: true,
items: [
{
name: 'Triggers',
key: 'triggers',
url: `/project/${ref}/database/triggers`,
items: [],
},
{
name: 'Functions',
key: 'functions',
url: `/project/${ref}/database/functions`,
items: [],
},
...(showHooksRoute
? [
{
name: 'Function Hooks',
key: 'hooks',
url: `/project/${ref}/database/hooks`,
items: [],
},
]
: []),
],
},
]
: []),
]
}
@@ -12,7 +12,7 @@ interface Props {
project: Project
}
const ProjectRestartingState: FC<Props> = ({ project }) => {
const ConnectingState: FC<Props> = ({ project }) => {
const { app } = useStore()
const checkProjectConnectionIntervalRef = useRef<number>()
@@ -146,4 +146,4 @@ const ProjectRestartingState: FC<Props> = ({ project }) => {
)
}
export default observer(ProjectRestartingState)
export default observer(ConnectingState)
+6 -6
View File
@@ -8,13 +8,13 @@ export default function EmptyPageState({ error }: any) {
}, [])
return (
<div className="w-full h-full flex flex-col space-y-6 items-center justify-center mx-auto">
<div className="w-[320px] flex flex-col items-center justify-center space-y-3">
<div className="mx-auto flex h-full w-full flex-col items-center justify-center space-y-6">
<div className="flex w-[320px] flex-col items-center justify-center space-y-3">
<Typography.Title level={3}>Something went wrong 🤕</Typography.Title>
<Typography.Text className="text-center">
<p className="text-scale-1100 text-center text-sm">
Sorry about that, please try again later or feel free to reach out to us if the problem
persists.
</Typography.Text>
</p>
</div>
<div className="flex items-center space-x-4">
<Link href="/">
@@ -28,9 +28,9 @@ export default function EmptyPageState({ error }: any) {
</a>
</Link>
</div>
<Typography.Text type="secondary" small>
<p className="text-scale-1100 text-sm">
Error: [{error?.code}] {error?.message}
</Typography.Text>
</p>
</div>
)
}
@@ -53,7 +53,7 @@ const ProductMenuItem: FC<Props> = ({
}
return (
<Link href={url}>
<Link href={url} passHref>
<a className="block" target={target}>
{menuItem}
</a>
+6 -6
View File
@@ -11,20 +11,20 @@ interface Props {
const UpgradeToPro: FC<Props> = ({ primaryText, projectRef, secondaryText }) => (
<div
className={[
'block w-full py-4 px-6 border rounded border-opacity-20',
'bg-gray-100 border-gray-600',
'dark:bg-gray-400 dark:border-gray-300',
'block w-full rounded border border-opacity-20 py-4 px-6',
'border-gray-600 bg-gray-100',
'dark:border-gray-300 dark:bg-gray-400',
].join(' ')}
>
<div className="flex space-x-3">
<div className="mt-1">
<IconClock size="large" />
</div>
<div className="flex justify-between w-full items-center">
<div>
<div className="flex w-full items-center justify-between">
<div className="space-y-1">
<p>{primaryText}</p>
<div>
<p className="text-sm text-scale-1100">{secondaryText}</p>
<p className="text-scale-1100 text-sm">{secondaryText}</p>
</div>
</div>
<Link href={`/project/${projectRef}/settings/billing`}>
+7 -7
View File
@@ -22,7 +22,7 @@
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.24",
"@supabase/supabase-js": "^1.35.3",
"@supabase/ui": "0.37.0-alpha.58",
"@supabase/ui": "0.37.0-alpha.65",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
@@ -8365,9 +8365,9 @@
}
},
"node_modules/@supabase/ui": {
"version": "0.37.0-alpha.58",
"resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.58.tgz",
"integrity": "sha512-owL7tiqmNUtIRlv/dZVh29LvcgsftDciDjOjG/3bpZ59xgZl24MmznQ6EIaJaZ6dQzCKIX+LqMLM+QSo/StveA==",
"version": "0.37.0-alpha.65",
"resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.65.tgz",
"integrity": "sha512-11Pm/Q2j7JnhHtZ8uZ/tXF7uHenT4tJU9LCpfiHFgiPI91C9Kt0s4QvLiYehNLr8TxJ/rETz5uM5eODY6t4wmw==",
"dependencies": {
"@headlessui/react": "^1.0.0",
"@mertasan/tailwindcss-variables": "^2.0.1",
@@ -35163,9 +35163,9 @@
}
},
"@supabase/ui": {
"version": "0.37.0-alpha.58",
"resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.58.tgz",
"integrity": "sha512-owL7tiqmNUtIRlv/dZVh29LvcgsftDciDjOjG/3bpZ59xgZl24MmznQ6EIaJaZ6dQzCKIX+LqMLM+QSo/StveA==",
"version": "0.37.0-alpha.65",
"resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.65.tgz",
"integrity": "sha512-11Pm/Q2j7JnhHtZ8uZ/tXF7uHenT4tJU9LCpfiHFgiPI91C9Kt0s4QvLiYehNLr8TxJ/rETz5uM5eODY6t4wmw==",
"requires": {
"@headlessui/react": "^1.0.0",
"@mertasan/tailwindcss-variables": "^2.0.1",
+1 -1
View File
@@ -26,7 +26,7 @@
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.24",
"@supabase/supabase-js": "^1.35.3",
"@supabase/ui": "0.37.0-alpha.58",
"@supabase/ui": "0.37.0-alpha.65",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
+1 -1
View File
@@ -36,8 +36,8 @@ import PageTelemetry from 'components/ui/PageTelemetry'
import FlagProvider from 'components/ui/Flag/FlagProvider'
dayjs.extend(customParseFormat)
dayjs.extend(timezone)
dayjs.extend(utc)
dayjs.extend(timezone)
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const [rootStore] = useState(() => new RootStore())
+1 -1
View File
@@ -16,5 +16,5 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(200).json({ tierId: undefined, backups: [] })
return res.status(200).json({ tierKey: 'FREE', backups: [] })
}
@@ -1,25 +0,0 @@
import { observer } from 'mobx-react-lite'
import { DatabaseLayout } from 'components/layouts'
import { BackupsList } from 'components/interfaces/Database'
import { NextPageWithLayout } from 'types'
const DatabaseBackups: NextPageWithLayout = () => {
return (
<div className="flex">
<div className="my-2 mx-auto w-full max-w-4xl space-y-8 p-4">
<h3 className="text-2xl">Backups</h3>
<div className="space-y-4">
<p className="text-scale-1100 text-sm">
Projects are backed up daily and can be restored at any time.
</p>
<BackupsList />
</div>
</div>
</div>
)
}
DatabaseBackups.getLayout = (page) => <DatabaseLayout title="Database">{page}</DatabaseLayout>
export default observer(DatabaseBackups)
@@ -0,0 +1,45 @@
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { Tabs } from '@supabase/ui'
import { useStore } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import { PITRBackupSelection } from 'components/interfaces/Database'
import { NextPageWithLayout } from 'types'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const { ui } = useStore()
const router = useRouter()
const ref = ui.selectedProject?.ref ?? 'default'
return (
<div className="mx-auto max-w-4xl space-y-6 px-5 pt-12 pb-20">
<h3 className="text-scale-1200 text-xl">Backups</h3>
<Tabs
type="underlined"
activeId="pitr"
onChange={(id: any) => {
if (id === 'scheduled') router.push(`/project/${ref}/database/backups/scheduled`)
}}
>
<Tabs.Panel id="scheduled" label="Scheduled" />
<Tabs.Panel id="pitr" label="Point in Time" />
</Tabs>
<div className="space-y-4">
<p className="text-scale-1100 text-sm">
Restore your project from a specific date and time.
</p>
<PITRBackupSelection />
</div>
</div>
)
}
DatabaseScheduledBackups.getLayout = (page) => (
<DatabaseLayout title="Database">{page}</DatabaseLayout>
)
export default observer(DatabaseScheduledBackups)
@@ -0,0 +1,45 @@
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { Tabs } from '@supabase/ui'
import { useStore } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import { BackupsList } from 'components/interfaces/Database'
import { NextPageWithLayout } from 'types'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const { ui } = useStore()
const router = useRouter()
const ref = ui.selectedProject?.ref
return (
<div className="mx-auto max-w-4xl space-y-6 px-5 pt-12 pb-20">
<h3 className="text-scale-1200 text-xl">Backups</h3>
<Tabs
type="underlined"
activeId="scheduled"
onChange={(id: any) => {
if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`)
}}
>
<Tabs.Panel id="scheduled" label="Scheduled" />
<Tabs.Panel id="pitr" label="Point in Time" />
</Tabs>
<div className="space-y-4">
<p className="text-scale-1100 text-sm">
Projects are backed up daily around midnight of your project's region and can be restored
at any time.
</p>
<BackupsList />
</div>
</div>
)
}
DatabaseScheduledBackups.getLayout = (page) => (
<DatabaseLayout title="Database">{page}</DatabaseLayout>
)
export default observer(DatabaseScheduledBackups)
+16 -10
View File
@@ -3,8 +3,9 @@ import { Project } from 'types'
import AppStore, { IAppStore } from './app/AppStore'
import MetaStore, { IMetaStore } from './pgmeta/MetaStore'
import UiStore, { IUiStore } from './UiStore'
import ProjectContentStore, { IProjectContentStore } from './content/ProjectContentStore'
import ProjectFunctionsStore, { IProjectFunctionsStore } from './functions/ProjectFunctionsStore'
import ProjectContentStore, { IProjectContentStore } from './project/ProjectContentStore'
import ProjectFunctionsStore, { IProjectFunctionsStore } from './project/ProjectFunctionsStore'
import ProjectBackupsStore, { IProjectBackupsStore } from './project/ProjectBackupsStore'
// Temporary disable mobx warnings
// TODO: need to remove this after refactoring old stores.
@@ -13,31 +14,35 @@ configure({
})
export interface IRootStore {
app: IAppStore
ui: IUiStore
meta: IMetaStore
content: IProjectContentStore
functions: IProjectFunctionsStore
meta: IMetaStore
app: IAppStore
backups: IProjectBackupsStore
setProjectRef: (value?: string) => void
setOrganizationSlug: (value?: string) => void
}
export class RootStore implements IRootStore {
app: IAppStore
ui: IUiStore
meta: IMetaStore
content: IProjectContentStore
functions: IProjectFunctionsStore
meta: IMetaStore
app: IAppStore
backups: IProjectBackupsStore
constructor() {
this.app = new AppStore(this)
this.ui = new UiStore(this)
// @ts-ignore
this.content = new ProjectContentStore(this, { projectRef: '' })
this.functions = new ProjectFunctionsStore(this, { projectRef: '' })
this.meta = new MetaStore(this, {
projectRef: '',
connectionString: '',
})
this.app = new AppStore(this)
// @ts-ignore
this.content = new ProjectContentStore(this, { projectRef: '' })
this.functions = new ProjectFunctionsStore(this, { projectRef: '' })
this.backups = new ProjectBackupsStore(this, { projectRef: '' })
/**
* TODO: meta and content are not observable
@@ -87,6 +92,7 @@ export class RootStore implements IRootStore {
this.ui.setProjectRef(value)
this.functions.setProjectRef(value)
this.content.setProjectRef(value)
this.backups.setProjectRef(value)
}
setOrganizationSlug(value?: string) {
-15
View File
@@ -6,9 +6,6 @@ import { constructHeaders } from 'lib/api/apiHelpers'
import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface'
export interface IDatabaseStore extends IPostgresMetaInterface<any> {
getBackups: (
projectRef: string
) => Promise<{ tierId: any; backups: any[] } | { error: ResponseError }>
getPoolingConfiguration: (projectRef: string) => Promise<any | { error: ResponseError }>
}
@@ -24,18 +21,6 @@ export default class DatabaseStore extends PostgresMetaInterface<any> {
super(rootStore, dataUrl, headers, options)
}
async getBackups(projectRef: string) {
try {
const url = `${this.url}/${projectRef}/backups`
const headers = constructHeaders(this.headers)
const response = await get(url, { headers })
if (response.error) throw response.error
return response
} catch (error: any) {
return { error }
}
}
async getPoolingConfiguration(projectRef: string) {
try {
// Need to use project ref -> shouldn;t need to pass it in via argument
@@ -0,0 +1,107 @@
import { makeAutoObservable } from 'mobx'
import { keyBy } from 'lodash'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { IRootStore } from '../RootStore'
export interface IProjectBackupsStore {
isLoading: boolean
isInitialized: boolean
isLoaded: boolean
error: any
baseUrl: string
projectRef: string
configuration: any
load: () => void
list: () => any[]
setProjectRef: (ref?: string) => void
}
export default class ProjectBackupsStore implements IProjectBackupsStore {
rootStore: IRootStore
STATES = {
INITIAL: 'initial',
LOADING: 'loading',
ERROR: 'error',
LOADED: 'loaded',
}
baseUrl: string
projectRef: string
data: any = []
configuration: any = {}
state = this.STATES.INITIAL
error = null
constructor(rootStore: IRootStore, options: { projectRef: string }) {
const { projectRef } = options
this.projectRef = projectRef
this.rootStore = rootStore
this.baseUrl = ``
makeAutoObservable(this)
}
get isLoading() {
return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING
}
get isInitialized() {
return this.state !== this.STATES.INITIAL
}
get isLoaded() {
return this.state === this.STATES.LOADED
}
async fetchData() {
const headers = {
'Content-Type': 'application/json',
}
const response = await get(this.baseUrl, { headers })
if (response.error) {
throw response.error
}
const { backups, ...configuration } = response
this.data = keyBy(backups, 'id')
this.configuration = configuration
return backups
}
async load() {
let { LOADING, ERROR, LOADED } = this.STATES
try {
this.error = null
this.state = LOADING
await this.fetchData()
this.state = LOADED
return this.data
} catch (e: any) {
console.error('Failed to load project backups', e.message)
this.error = e
this.state = ERROR
}
}
list(filter?: any) {
const arr = Object.values(this.data)
if (!!filter) {
return arr.filter(filter).sort((a: any, b: any) => b.id - a.id)
} else {
return arr.sort((a: any, b: any) => b.id - a.id)
}
}
setProjectRef(ref?: string) {
if (ref) {
this.projectRef = ref
this.baseUrl = `${API_URL}/database/${ref}/backups`
}
}
}
@@ -5,9 +5,6 @@ import { get, post, patch, delete_ } from 'lib/common/fetch'
import { UserContent, UserContentMap } from 'types'
import { IRootStore } from '../RootStore'
// [Joshen] This will be the new ProjectContentStore
// but use the one under the stores folder projectContentStore first while we transition
export interface IProjectFunctionsStore {
isLoading: boolean
isInitialized: boolean