mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## Context Original intention was to fix the PITR UI for smaller viewports, but realised that the layout of the UI could be improved as well. (Tbh this UI could do with a bit of revisiting, but just making patches for now to improve what's existing) Fixes also apply to the restore to new project page since they share the same component ## Changes involved - Am opting to change the layout of the UI a little such that date selection is on the left, and time selection is up top - Fits the user flow a bit better - you select the parameters you want, and the final message at the bottom is the summary <img width="1033" height="525" alt="image" src="https://github.com/user-attachments/assets/614c0327-f5db-4e1f-a2a1-ae8a9ea89978" /> - In the confirmation dialog, we were originally showing the top label as "Local time" which I feel is inaccurate especially if the user has selected a different timezone from where they're located at. - Opting to display the full name of the selected timezone instead <img width="546" height="261" alt="image" src="https://github.com/user-attachments/assets/cd8838a1-8476-4492-bc86-cac229685e5a" /> - RE mobile layout - am currently just opting to have them in a column fashion although I feel like this isn't ideal either (requires revisiting of the UI as a whole to adjust the layout on desktop too) - e.g the Date picker here could be a popover like Unified Logs to streamline what is essentially a form <img width="507" height="831" alt="image" src="https://github.com/user-attachments/assets/c8e055bd-49c3-4d0a-9d8c-e54f53e9fcb4" /> - Also fixed the CTA URL for read replicas, was still pointing to /settings/infrastructure, should point to database/replication <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Point-in-time recovery now includes the selected timezone throughout the restore workflow. * Restore confirmations display the chosen timezone and a clear summary of the target recovery date and time. * Recovery details now show the earliest and latest available backups for the selected date, including the two-minute matching window. * **Bug Fixes** * Updated the read-replica management link to direct users to database replication settings. * **Style** * Improved restore form layout, calendar presentation, and timezone selector alignment. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
246 lines
9.1 KiB
TypeScript
246 lines
9.1 KiB
TypeScript
import dayjs from 'dayjs'
|
|
import { HelpCircle } from 'lucide-react'
|
|
import { useMemo, useState } from 'react'
|
|
import { Calendar, cn } from 'ui'
|
|
|
|
import { type Timezone } from './PITR.types'
|
|
import {
|
|
constrainDateToRange,
|
|
formatNumberToTwoDigits,
|
|
getClientTimezone,
|
|
getDatesBetweenRange,
|
|
} from './PITR.utils'
|
|
import TimeInput from './TimeInput'
|
|
import { TimezoneSelection } from './TimezoneSelection'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { FormPanel } from '@/components/ui/Forms/FormPanel'
|
|
import InformationBox from '@/components/ui/InformationBox'
|
|
|
|
type Props = {
|
|
onSubmit: (data: {
|
|
selectedTimezone: Timezone
|
|
recoveryTimeTargetUnix: number
|
|
recoveryTimeString: string
|
|
recoveryTimeStringUtc: string
|
|
}) => void
|
|
earliestAvailableBackupUnix: number
|
|
latestAvailableBackupUnix: number
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function PITRForm({
|
|
onSubmit,
|
|
earliestAvailableBackupUnix,
|
|
latestAvailableBackupUnix,
|
|
disabled = false,
|
|
}: Props) {
|
|
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
|
const earliestAvailableBackup = dayjs
|
|
.unix(earliestAvailableBackupUnix ?? 0)
|
|
.tz(selectedTimezone.utc[0])
|
|
const latestAvailableBackup = dayjs
|
|
.unix(latestAvailableBackupUnix ?? 0)
|
|
.tz(selectedTimezone.utc[0])
|
|
const earliestAvailableBackupAsDate = earliestAvailableBackup.toDate()
|
|
const latestAvailableBackupAsDate = latestAvailableBackup.toDate()
|
|
|
|
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
|
|
|
|
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true)
|
|
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
|
|
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
|
|
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
|
|
|
|
const selectedTime = {
|
|
h: selectedDate.hour(),
|
|
m: selectedDate.minute(),
|
|
s: selectedDate.second(),
|
|
}
|
|
|
|
const earliestAvailableBackupTime = {
|
|
h: earliestAvailableBackup.hour(),
|
|
m: earliestAvailableBackup.minute(),
|
|
s: earliestAvailableBackup.second(),
|
|
}
|
|
|
|
const latestAvailableBackupTime = {
|
|
h: latestAvailableBackup.hour(),
|
|
m: latestAvailableBackup.minute(),
|
|
s: latestAvailableBackup.second(),
|
|
}
|
|
|
|
const recoveryTimeTargetUnix = selectedDate.unix()
|
|
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
|
|
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
|
|
|
|
const isWithinRange = useMemo(
|
|
() =>
|
|
(!!selectedDate && selectedDate.isSame(latestAvailableBackup)) ||
|
|
selectedDate.isSame(earliestAvailableBackup) ||
|
|
(selectedDate.isBefore(latestAvailableBackup) &&
|
|
selectedDate.isAfter(earliestAvailableBackup)),
|
|
[selectedDate, latestAvailableBackup, earliestAvailableBackup]
|
|
)
|
|
|
|
const onUpdateDate = (date: Date) => {
|
|
setSelectedDateRaw(
|
|
constrainDateToRange(
|
|
dayjs(date).tz(selectedTimezone.utc[0], true),
|
|
earliestAvailableBackup,
|
|
latestAvailableBackup
|
|
).toDate()
|
|
)
|
|
}
|
|
|
|
const handleSubmit = () => {
|
|
onSubmit({
|
|
selectedTimezone,
|
|
recoveryTimeTargetUnix,
|
|
recoveryTimeString,
|
|
recoveryTimeStringUtc,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<FormPanel
|
|
disabled={true}
|
|
footer={
|
|
<div className="flex items-center justify-end gap-3 p-6">
|
|
<ButtonTooltip
|
|
variant="default"
|
|
disabled={disabled || !selectedDate || !isWithinRange}
|
|
onClick={handleSubmit}
|
|
tooltip={{
|
|
content: {
|
|
hidden: !isWithinRange,
|
|
side: 'bottom',
|
|
text: !isWithinRange
|
|
? 'Selected date is out of range where backups are available'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
Continue
|
|
</ButtonTooltip>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-y-6 lg:flex-row lg:gap-y-0 justify-between px-4 md:px-10 py-6 lg:space-x-10">
|
|
<div className="w-full lg:w-1/3 space-y-2 py-2">
|
|
<p className="text-sm text-foreground">Select a date to restore to</p>
|
|
<Calendar
|
|
mode="single"
|
|
required={true}
|
|
selected={selectedDateRaw}
|
|
onSelect={onUpdateDate}
|
|
defaultMonth={latestAvailableBackupAsDate}
|
|
startMonth={earliestAvailableBackupAsDate}
|
|
endMonth={latestAvailableBackupAsDate}
|
|
disabled={[
|
|
{ before: earliestAvailableBackupAsDate },
|
|
{ after: latestAvailableBackupAsDate },
|
|
]}
|
|
classNames={{
|
|
root: 'w-min px-0',
|
|
day: cn(
|
|
'[&:not(:has(:disabled))]:border [&:not(:has(:disabled))]:border-stronger not-last:border-r-0 [&:not(:has(:disabled))]:bg-overlay-hover',
|
|
'rounded-none'
|
|
),
|
|
day_button: 'w-full rounded-none',
|
|
selected: 'bg-brand-500!',
|
|
}}
|
|
/>
|
|
{availableDates.length > 1 && (
|
|
<div className="flex items-center space-x-2">
|
|
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
|
|
<p className="text-xs text-foreground-light">Point in time back up available</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-full lg:w-2/3">
|
|
{!selectedDate ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<div className="mx-2">
|
|
<InformationBox
|
|
defaultVisibility
|
|
hideCollapse
|
|
icon={<HelpCircle size={14} strokeWidth={2} />}
|
|
title="Select a date which you'd like to restore your database to"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-8 py-2">
|
|
<div className="flex flex-col gap-y-4">
|
|
<p className="text-sm text-foreground">Enter a time to restore to</p>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-foreground-light">Time zone</p>
|
|
<div className="w-[350px]">
|
|
<TimezoneSelection
|
|
selectedTimezone={selectedTimezone}
|
|
onSelectTimezone={setSelectedTimezone}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-foreground-light">Recovery time</p>
|
|
{isSelectedOnEarliestDay && (
|
|
<p className="text-sm text-foreground-lighter">
|
|
<strong>Earliest backup available for this date</strong>:{' '}
|
|
{earliestAvailableBackup.format('HH:mm:ss')}
|
|
</p>
|
|
)}
|
|
{isSelectedOnLatestDay && (
|
|
<p className="text-sm text-foreground-lighter">
|
|
<strong>Latest backup available for this date</strong>:{' '}
|
|
{latestAvailableBackup.format('HH:mm:ss')}
|
|
</p>
|
|
)}
|
|
<TimeInput
|
|
defaultTime={selectedTime}
|
|
minimumTime={
|
|
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
|
|
}
|
|
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
|
|
onChange={({ h, m, s }) => {
|
|
const newDate = dayjs(selectedDateRaw)
|
|
.set('hour', h)
|
|
.set('minute', m)
|
|
.set('second', s)
|
|
|
|
setSelectedDateRaw(newDate.toDate())
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-foreground-light">Database will be restored to:</p>
|
|
<p className="text-3xl">
|
|
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
|
|
<span>
|
|
, {formatNumberToTwoDigits(selectedTime.h)}:
|
|
{formatNumberToTwoDigits(selectedTime.m)}:
|
|
{formatNumberToTwoDigits(selectedTime.s)}
|
|
</span>
|
|
</p>
|
|
|
|
<p className="text-sm text-foreground-lighter mt-4 text-balance">
|
|
Backups are captured every 2 minutes, allowing you to enter a time and restore
|
|
your database to the closest backup point. We'll match the time you enter to the
|
|
closest backup within the 2-minute window
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</FormPanel>
|
|
</div>
|
|
)
|
|
}
|