mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Add new ui for restore to new project (#30325)
* add new ui for restore to new project * rm false * fix typeerr badly * add form with name and new project pass * fix flash in loading > results state change * restore api types * update api types, add pitr picker * add additional monthly spend section * fix loading state jump * update copy * update api types * update to new clone status api res * fix mutation missing vars * dont render logical backups in list * fix state issue on submit * better pitr empty state * Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com> * Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx Co-authored-by: Joshen Lim <joshenlimek@gmail.com> * Apply suggestions from code review Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com> * update api types to accept recoveryTimeTarget for PITR restores * simplify pitr code a bit cleanup state bugs * cleanup * add PITR submit * dont show restore btn unless status is complete * update api types * fix backups nav * markdown descs and fix type err in nav * check if project is cloned * fix: make source project data optional * fix datepicker highlighted day style * fix tz picker * check if its same or before/after in isWithinRange * update api types fix issue with clones * unsus * fix markdown rendering issues * add badge * Update database roles and permissions description * Update restore message text in PITR form component * Update key attribute to item.id in DatabaseBackupsNav component * Add conditional rendering for multiple available dates * Refactor dialog section styles in restore-to-new-project.tsx * Update null to undefined in conditional text assignment * reuse component in pitr backups * Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com> * Update link to original project in RestoreToNewProject * fix prettier --------- Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Crispy1975 <12525875+Crispy1975@users.noreply.github.com>
This commit is contained in:
19 files changed
+1836
-410
No files matched your search
@@ -1,36 +1,56 @@
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { NavMenu, NavMenuItem } from 'ui'
|
||||
import { Badge, NavMenu, NavMenuItem } from 'ui'
|
||||
|
||||
type Props = {
|
||||
active: 'scheduled' | 'pitr'
|
||||
active: 'pitr' | 'scheduled' | 'rtnp'
|
||||
}
|
||||
|
||||
function DatabaseBackupsNav({ active }: Props) {
|
||||
const { ref } = useParams()
|
||||
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
|
||||
const ref = useProjectContext()?.project?.ref
|
||||
|
||||
const navMenuItems = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 'scheduled',
|
||||
label: 'Scheduled backups',
|
||||
href: `/project/${ref}/database/backups/scheduled`,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: 'pitr',
|
||||
label: 'Point in time',
|
||||
href: `/project/${ref}/database/backups/pitr`,
|
||||
},
|
||||
]
|
||||
{
|
||||
enabled: isCloneToNewProjectEnabled,
|
||||
id: 'rtnp',
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
Restore to new project{' '}
|
||||
<Badge size="small" className="!text-[10px] px-1 py-0">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
href: `/project/${ref}/database/backups/restore-to-new-project`,
|
||||
},
|
||||
] as const
|
||||
|
||||
return (
|
||||
<NavMenu>
|
||||
{navMenuItems.map((item) => (
|
||||
<NavMenuItem key={item.label} active={item.id === active}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
))}
|
||||
{navMenuItems.map(
|
||||
(item) =>
|
||||
item.enabled && (
|
||||
<NavMenuItem key={item.id} active={item.id === active}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
)
|
||||
)}
|
||||
</NavMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import DatePicker from 'react-datepicker'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { useBackupsQuery } from 'data/database/backups-query'
|
||||
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import {
|
||||
Alert,
|
||||
@@ -31,15 +22,9 @@ import {
|
||||
import BackupsEmpty from '../BackupsEmpty'
|
||||
import BackupsStorageAlert from '../BackupsStorageAlert'
|
||||
import type { Timezone } from './PITR.types'
|
||||
import {
|
||||
constrainDateToRange,
|
||||
formatNumberToTwoDigits,
|
||||
getClientTimezone,
|
||||
getDatesBetweenRange,
|
||||
} from './PITR.utils'
|
||||
import { getClientTimezone } from './PITR.utils'
|
||||
import PITRStatus from './PITRStatus'
|
||||
import TimeInput from './TimeInput'
|
||||
import { TimezoneSelection } from './TimezoneSelection'
|
||||
import { PITRForm } from './pitr-form'
|
||||
|
||||
const PITRSelection = () => {
|
||||
const router = useRouter()
|
||||
@@ -51,11 +36,11 @@ const PITRSelection = () => {
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
||||
|
||||
const canTriggerPhysicalBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
const [selectedRecoveryPoint, setSelectedRecoveryPoint] = useState<{
|
||||
recoveryTimeTargetUnix: number
|
||||
recoveryTimeString: string
|
||||
recoveryTimeStringUtc: string
|
||||
}>()
|
||||
|
||||
const hasReadReplicas = (databases ?? []).length > 1
|
||||
|
||||
@@ -76,67 +61,16 @@ const PITRSelection = () => {
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
backups?.physicalBackupData ?? {}
|
||||
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
|
||||
const earliestAvailableBackup = dayjs
|
||||
.unix(earliestPhysicalBackupDateUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
const latestAvailableBackup = dayjs
|
||||
.unix(latestPhysicalBackupDateUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
|
||||
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
|
||||
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
// This will be the actual unix timestamp for the backup
|
||||
const recoveryTimeTargetUnix = selectedDate.unix()
|
||||
// Formatting from the unix again just to double check correctness
|
||||
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
|
||||
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const isSelectedOutOfRange =
|
||||
selectedDate &&
|
||||
(selectedDate.isBefore(earliestAvailableBackup) || selectedDate.isAfter(latestAvailableBackup))
|
||||
|
||||
const onUpdateDate = (date: Date) => {
|
||||
setSelectedDateRaw(
|
||||
constrainDateToRange(
|
||||
dayjs(date).tz(selectedTimezone.utc[0], true),
|
||||
earliestAvailableBackup,
|
||||
latestAvailableBackup
|
||||
).toDate()
|
||||
)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setShowConfiguration(false)
|
||||
setSelectedTimezone(getClientTimezone())
|
||||
}
|
||||
|
||||
const onConfirmRestore = async () => {
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
|
||||
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
|
||||
if (!selectedRecoveryPoint?.recoveryTimeTargetUnix)
|
||||
return console.error('Recovery time target unix is required')
|
||||
|
||||
restoreFromPitr({
|
||||
ref,
|
||||
recovery_time_target_unix: selectedRecoveryPoint.recoveryTimeTargetUnix,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -177,173 +111,14 @@ const PITRSelection = () => {
|
||||
onSetConfiguration={() => setShowConfiguration(true)}
|
||||
/>
|
||||
) : (
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<Button type="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<ButtonTooltip
|
||||
type="warning"
|
||||
disabled={isSelectedOutOfRange || !selectedDate || !canTriggerPhysicalBackups}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: isSelectedOutOfRange
|
||||
? 'Selected date is out of range where backups are available'
|
||||
: !canTriggerPhysicalBackups
|
||||
? 'You need additional permissions to trigger a restore'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Review restore details
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex justify-between px-10 py-6 space-x-10">
|
||||
<div className="w-1/3 space-y-2">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDateRaw}
|
||||
onChange={onUpdateDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
minDate={earliestAvailableBackup.toDate()}
|
||||
maxDate={latestAvailableBackup.toDate()}
|
||||
highlightDates={availableDates.map((date) => date.toDate())}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-foreground-light text-sm">
|
||||
{format(date, 'MMMM yyyy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<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-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="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Restore database 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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<p className="text-sm text-foreground-light mt-8">
|
||||
Enter a time within the available range to restore from. <br /> 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 className="!mt-4 space-y-1">
|
||||
<h3 className="text-sm text-foreground-light"></h3>
|
||||
{isSelectedOnEarliestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Earliest backup available for this date</strong>:{' '}
|
||||
{earliestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
{isSelectedOnLatestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Latest backup available for this date</strong>:{' '}
|
||||
{latestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
<PITRForm
|
||||
earliestAvailableBackupUnix={earliestPhysicalBackupDateUnix}
|
||||
latestAvailableBackupUnix={latestPhysicalBackupDateUnix}
|
||||
onSubmit={(recoveryPoint) => {
|
||||
setSelectedRecoveryPoint(recoveryPoint)
|
||||
setShowConfirmation(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -379,12 +154,12 @@ const PITRSelection = () => {
|
||||
</div>
|
||||
<div className="py-2 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-mono text-foreground-lighter">{selectedTimezone?.text}</p>
|
||||
<p className="text-2xl">{recoveryTimeString}</p>
|
||||
<p className="text-sm font-mono text-foreground-lighter">Local Time</p>
|
||||
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeString}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono text-foreground-lighter">(UTC+00:00)</p>
|
||||
<p className="text-2xl">{recoveryTimeStringUtc}</p>
|
||||
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeStringUtc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const TimezoneSelection = ({
|
||||
value={option}
|
||||
onSelect={(text) => {
|
||||
const selectedTimezone = ALL_TIMEZONES.find(
|
||||
(option) => option.text.toLocaleLowerCase() === text
|
||||
(option) => option.text === text
|
||||
)
|
||||
if (selectedTimezone) {
|
||||
onSelectTimezone(selectedTimezone)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import DatePicker from 'react-datepicker'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { format } from 'date-fns'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
|
||||
import {
|
||||
constrainDateToRange,
|
||||
formatNumberToTwoDigits,
|
||||
getClientTimezone,
|
||||
getDatesBetweenRange,
|
||||
} from './PITR.utils'
|
||||
import { TimezoneSelection } from './TimezoneSelection'
|
||||
import TimeInput from './TimeInput'
|
||||
import { Timezone } from './PITR.types'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: {
|
||||
recoveryTimeTargetUnix: number
|
||||
recoveryTimeString: string
|
||||
recoveryTimeStringUtc: string
|
||||
}) => void
|
||||
earliestAvailableBackupUnix: number
|
||||
latestAvailableBackupUnix: number
|
||||
}
|
||||
|
||||
export function PITRForm({
|
||||
onSubmit,
|
||||
earliestAvailableBackupUnix,
|
||||
latestAvailableBackupUnix,
|
||||
}: 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 [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({
|
||||
recoveryTimeTargetUnix,
|
||||
recoveryTimeString,
|
||||
recoveryTimeStringUtc,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
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 justify-between px-10 py-6 space-x-10">
|
||||
<div className="w-1/3 space-y-2">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDateRaw}
|
||||
onChange={onUpdateDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
minDate={earliestAvailableBackup.toDate()}
|
||||
maxDate={latestAvailableBackup.toDate()}
|
||||
highlightDates={availableDates.map((date) => date.toDate())}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-foreground-light text-sm">
|
||||
{format(date, 'MMMM yyyy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{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-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="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Date to restore 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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<p className="text-sm text-foreground-light mt-8">
|
||||
Enter a time within the available range to restore from. <br /> 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 className="!mt-4 space-y-1">
|
||||
<h3 className="text-sm text-foreground-light"></h3>
|
||||
{isSelectedOnEarliestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Earliest backup available for this date</strong>:{' '}
|
||||
{earliestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
{isSelectedOnLatestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Latest backup available for this date</strong>:{' '}
|
||||
{latestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -363,6 +363,7 @@ export enum LogsTableName {
|
||||
POSTGREST = 'postgrest_logs',
|
||||
SUPAVISOR = 'supavisor_logs',
|
||||
WAREHOUSE = 'warehouse_logs',
|
||||
CRON_JOBS = 'cron_job_run_details',
|
||||
}
|
||||
|
||||
export const LOGS_TABLES = {
|
||||
@@ -376,6 +377,7 @@ export const LOGS_TABLES = {
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
supavisor: LogsTableName.SUPAVISOR,
|
||||
warehouse: LogsTableName.WAREHOUSE,
|
||||
cron: LogsTableName.CRON_JOBS,
|
||||
}
|
||||
|
||||
export const LOGS_SOURCE_DESCRIPTION = {
|
||||
@@ -389,6 +391,7 @@ export const LOGS_SOURCE_DESCRIPTION = {
|
||||
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
|
||||
[LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs',
|
||||
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
|
||||
[LogsTableName.CRON_JOBS]: 'Logs obtained from cron job runs',
|
||||
}
|
||||
|
||||
export const genQueryParams = (params: { [k: string]: string }) => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export type QueryType =
|
||||
| 'supavisor'
|
||||
| 'postgrest'
|
||||
| 'warehouse'
|
||||
| 'cron'
|
||||
|
||||
export type Mode = 'simple' | 'custom'
|
||||
|
||||
|
||||
@@ -211,6 +211,9 @@ limit ${limit}
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`
|
||||
|
||||
case 'cron_job_run_details':
|
||||
return `select status, start_time, end_time, jobid from ${table} ${where} ${orderBy} limit ${limit}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ export function LogsSidebarMenuV2() {
|
||||
url: `/project/${ref}/logs/edge-functions-logs`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Cron Jobs',
|
||||
key: 'cron-logs',
|
||||
url: `/project/${ref}/logs/cron-logs`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
const filteredLogs = BASE_COLLECTIONS.filter((collection) => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { projectKeys } from './keys'
|
||||
|
||||
export type ProjectCloneVariables = {
|
||||
projectRef: string
|
||||
newProjectName: string
|
||||
newDbPass: string
|
||||
cloneBackupId?: number
|
||||
recoveryTimeTarget?: number
|
||||
}
|
||||
|
||||
export async function triggerClone({
|
||||
projectRef,
|
||||
newProjectName,
|
||||
cloneBackupId,
|
||||
newDbPass,
|
||||
recoveryTimeTarget,
|
||||
}: ProjectCloneVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
const { data, error } = await post('/platform/database/{ref}/clone', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: { cloneBackupId, newProjectName, newDbPass, recoveryTimeTarget },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type ProjectCloneData = Awaited<ReturnType<typeof triggerClone>>
|
||||
|
||||
export const useProjectCloneMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<ProjectCloneData, ResponseError, ProjectCloneVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<ProjectCloneData, ResponseError, ProjectCloneVariables>(
|
||||
(vars) => triggerClone(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.listCloneBackups(variables.projectRef),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
console.error(data)
|
||||
toast.error(`Failed to trigger clone: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { projectKeys } from './keys'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export async function getCloneBackups(projectRef?: string) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { data, error } = await get(`/platform/database/{ref}/clone`, {
|
||||
params: { path: { ref: projectRef } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type CloneBackupsData = Awaited<ReturnType<typeof getCloneBackups>>
|
||||
export type CloneBackupsError = ResponseError
|
||||
|
||||
export const useCloneBackupsQuery = <TData = CloneBackupsData>(
|
||||
{ projectRef }: { projectRef?: string },
|
||||
options: UseQueryOptions<CloneBackupsData, CloneBackupsError, TData> = {}
|
||||
) => {
|
||||
return useQuery<CloneBackupsData, CloneBackupsError, TData>(
|
||||
projectKeys.listCloneBackups(projectRef),
|
||||
() => getCloneBackups(projectRef),
|
||||
{ enabled: !!projectRef, ...options }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { projectKeys } from './keys'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export async function getCloneStatus(projectRef?: string) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { data, error } = await get(`/platform/database/{ref}/clone/status`, {
|
||||
params: { path: { ref: projectRef } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type CloneStatusData = Awaited<ReturnType<typeof getCloneStatus>>
|
||||
export type CloneStatusError = ResponseError
|
||||
|
||||
export const useCloneStatusQuery = <TData = CloneStatusData>(
|
||||
{ projectRef }: { projectRef?: string },
|
||||
options: UseQueryOptions<CloneStatusData, CloneStatusError, TData> = {}
|
||||
) => {
|
||||
return useQuery<CloneStatusData, CloneStatusError, TData>(
|
||||
projectKeys.listCloneStatus(projectRef),
|
||||
() => getCloneStatus(projectRef),
|
||||
{ enabled: !!projectRef, ...options }
|
||||
)
|
||||
}
|
||||
@@ -18,4 +18,10 @@ export const projectKeys = {
|
||||
['projects', projectRef, 'pause-status'] as const,
|
||||
|
||||
orgProjects: (slug: string | undefined) => ['projects', 'org', slug] as const,
|
||||
|
||||
// Clone to new project
|
||||
listCloneBackups: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'clone-backups'] as const,
|
||||
listCloneStatus: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'clone-status'] as const,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { components } from 'api-types'
|
||||
import { PROVIDERS } from 'lib/constants'
|
||||
|
||||
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
|
||||
|
||||
export const instanceSizeSpecs: Record<
|
||||
DesiredInstanceSize,
|
||||
{
|
||||
label: string
|
||||
ram: string
|
||||
cpu: string
|
||||
priceHourly: number
|
||||
priceMonthly: number
|
||||
cloud_providers: string[]
|
||||
}
|
||||
> = {
|
||||
micro: {
|
||||
label: 'Micro',
|
||||
ram: '1 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.01344,
|
||||
priceMonthly: 10,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
ram: '2 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0206,
|
||||
priceMonthly: 15,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
ram: '4 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0822,
|
||||
priceMonthly: 60,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
ram: '8 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.1517,
|
||||
priceMonthly: 110,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
xlarge: {
|
||||
label: 'XL',
|
||||
ram: '16 GB',
|
||||
cpu: '4-core',
|
||||
priceHourly: 0.2877,
|
||||
priceMonthly: 210,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'2xlarge': {
|
||||
label: '2XL',
|
||||
ram: '32 GB',
|
||||
cpu: '8-core',
|
||||
priceHourly: 0.562,
|
||||
priceMonthly: 410,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'4xlarge': {
|
||||
label: '4XL',
|
||||
ram: '64 GB',
|
||||
cpu: '16-core',
|
||||
priceHourly: 1.32,
|
||||
priceMonthly: 960,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'8xlarge': {
|
||||
label: '8XL',
|
||||
ram: '128 GB',
|
||||
cpu: '32-core',
|
||||
priceHourly: 2.562,
|
||||
priceMonthly: 1870,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'12xlarge': {
|
||||
label: '12XL',
|
||||
ram: '192 GB',
|
||||
cpu: '48-core',
|
||||
priceHourly: 3.836,
|
||||
priceMonthly: 2800,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'16xlarge': {
|
||||
label: '16XL',
|
||||
ram: '256 GB',
|
||||
cpu: '64-core',
|
||||
priceHourly: 5.12,
|
||||
priceMonthly: 3730,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
}
|
||||
@@ -84,6 +84,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
|
||||
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
|
||||
|
||||
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
|
||||
|
||||
@@ -100,99 +101,6 @@ const sizes: DesiredInstanceSize[] = [
|
||||
'16xlarge',
|
||||
]
|
||||
|
||||
const instanceSizeSpecs: Record<
|
||||
DesiredInstanceSize,
|
||||
{
|
||||
label: string
|
||||
ram: string
|
||||
cpu: string
|
||||
priceHourly: number
|
||||
priceMonthly: number
|
||||
cloud_providers: string[]
|
||||
}
|
||||
> = {
|
||||
micro: {
|
||||
label: 'Micro',
|
||||
ram: '1 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.01344,
|
||||
priceMonthly: 10,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
ram: '2 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0206,
|
||||
priceMonthly: 15,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
ram: '4 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0822,
|
||||
priceMonthly: 60,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
ram: '8 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.1517,
|
||||
priceMonthly: 110,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
xlarge: {
|
||||
label: 'XL',
|
||||
ram: '16 GB',
|
||||
cpu: '4-core',
|
||||
priceHourly: 0.2877,
|
||||
priceMonthly: 210,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'2xlarge': {
|
||||
label: '2XL',
|
||||
ram: '32 GB',
|
||||
cpu: '8-core',
|
||||
priceHourly: 0.562,
|
||||
priceMonthly: 410,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'4xlarge': {
|
||||
label: '4XL',
|
||||
ram: '64 GB',
|
||||
cpu: '16-core',
|
||||
priceHourly: 1.32,
|
||||
priceMonthly: 960,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'8xlarge': {
|
||||
label: '8XL',
|
||||
ram: '128 GB',
|
||||
cpu: '32-core',
|
||||
priceHourly: 2.562,
|
||||
priceMonthly: 1870,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'12xlarge': {
|
||||
label: '12XL',
|
||||
ram: '192 GB',
|
||||
cpu: '48-core',
|
||||
priceHourly: 3.836,
|
||||
priceMonthly: 2800,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'16xlarge': {
|
||||
label: '16XL',
|
||||
ram: '256 GB',
|
||||
cpu: '64-core',
|
||||
priceHourly: 5.12,
|
||||
priceMonthly: 3730,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
}
|
||||
|
||||
const Wizard: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { slug, projectName } = useParams()
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty'
|
||||
|
||||
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
|
||||
import { useCloneBackupsQuery } from 'data/projects/clone-query'
|
||||
import { useCloneStatusQuery } from 'data/projects/clone-status-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { getDatabaseMajorVersion, passwordStrength } from 'lib/helpers'
|
||||
import Link from 'next/link'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogSection,
|
||||
Input,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition, TimestampInfo } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
import { debounce } from 'lodash'
|
||||
import generator from 'generate-password-browser'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
|
||||
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
const RestoreToNewProjectPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<div className="space-y-6">
|
||||
<FormHeader className="!mb-0" title="Database Backups" />
|
||||
<DatabaseBackupsNav active="rtnp" />
|
||||
<div className="space-y-8">
|
||||
<RestoreToNewProject />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
RestoreToNewProjectPage.getLayout = (page) => (
|
||||
<DatabaseLayout title="Database">{page}</DatabaseLayout>
|
||||
)
|
||||
|
||||
const RestoreToNewProject = () => {
|
||||
const { project } = useProjectContext()
|
||||
const organization = useSelectedOrganization()
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
|
||||
const isFreePlan = subscription?.plan?.id === 'free'
|
||||
|
||||
const {
|
||||
data: cloneBackups,
|
||||
error,
|
||||
isLoading: cloneBackupsLoading,
|
||||
isError,
|
||||
} = useCloneBackupsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
},
|
||||
{
|
||||
enabled: !isFreePlan,
|
||||
}
|
||||
)
|
||||
|
||||
const plan = subscription?.plan?.id
|
||||
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadPhysicalBackups = useCheckPermissions(PermissionAction.READ, 'physical_backups')
|
||||
const canTriggerPhysicalBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
const hasPITREnabled = cloneBackups?.pitr_enabled
|
||||
|
||||
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
|
||||
const IS_PG15_OR_ABOVE = dbVersion >= 15
|
||||
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
|
||||
|
||||
const {
|
||||
data: cloneStatus,
|
||||
refetch: refetchCloneStatus,
|
||||
isLoading: cloneStatusLoading,
|
||||
} = useCloneStatusQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
const lastClone = cloneStatus?.clones?.[cloneStatus?.clones.length - 1]
|
||||
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
|
||||
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
|
||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
|
||||
const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null)
|
||||
const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
|
||||
|
||||
const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
|
||||
|
||||
/**
|
||||
* New project will have the same compute size and disk size as the original project
|
||||
*/
|
||||
function getAdditionalMonthlySpend() {
|
||||
const currentProjectComputeSize = project?.infra_compute_size
|
||||
if (!currentProjectComputeSize) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentProjectComputeSize === 'nano') {
|
||||
return null
|
||||
}
|
||||
|
||||
const additionalMonthlySpend = instanceSizeSpecs[currentProjectComputeSize]
|
||||
|
||||
return additionalMonthlySpend
|
||||
}
|
||||
|
||||
const { mutate: triggerClone, isLoading: cloneMutationLoading } = useProjectCloneMutation({
|
||||
onError: (error) => {
|
||||
console.error('error', error)
|
||||
toast.error('Failed to restore to new project')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Restoration process started')
|
||||
refetchCloneStatus()
|
||||
setShowNewProjectDialog(false)
|
||||
},
|
||||
})
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
|
||||
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
|
||||
|
||||
const delayedCheckPasswordStrength = useRef(
|
||||
debounce((value: string) => checkPasswordStrength(value), 300)
|
||||
).current
|
||||
|
||||
async function checkPasswordStrength(value: string) {
|
||||
const { message, strength } = await passwordStrength(value)
|
||||
setPasswordStrengthScore(strength)
|
||||
setPasswordStrengthMessage(message)
|
||||
}
|
||||
|
||||
function generateStrongPassword() {
|
||||
const password = generator.generate({
|
||||
length: 16,
|
||||
numbers: true,
|
||||
uppercase: true,
|
||||
})
|
||||
|
||||
form.setValue('password', password)
|
||||
delayedCheckPasswordStrength(password)
|
||||
}
|
||||
|
||||
function AdditionalMonthlySpend() {
|
||||
const additionalMonthlySpend = getAdditionalMonthlySpend()
|
||||
if (!additionalMonthlySpend) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground-lighter border-t p-5">
|
||||
<p>
|
||||
The new project will have the same compute size and disk size as this project. You will be
|
||||
able to update the compute size and disk size after the new project is created in{' '}
|
||||
<span className="font-mono text-xs tracking-tighter text-foreground-light">
|
||||
Project Settings > Compute and Disk
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-between text-foreground mt-2">
|
||||
<p>Additional Monthly Compute + Disk Cost</p>
|
||||
<p className="font-mono text-right text-brand">${additionalMonthlySpend.priceMonthly}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
if (!canReadPhysicalBackups) {
|
||||
return <NoPermission resourceText="view backups" />
|
||||
}
|
||||
|
||||
if (!canTriggerPhysicalBackups) {
|
||||
return <NoPermission resourceText="restore backups" />
|
||||
}
|
||||
|
||||
if (!IS_PG15_OR_ABOVE) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project is not available for this database version"
|
||||
>
|
||||
<Markdown
|
||||
content={`Restore to new project is only available for Postgres 15 and above.
|
||||
Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure)
|
||||
to upgrade your database version.
|
||||
`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (!PHYSICAL_BACKUPS_ENABLED) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project requires physical backups"
|
||||
description={
|
||||
<>
|
||||
Physical backups must be enabled to restore your database to a new project.
|
||||
<br /> Find out more about how backups work at supabase{' '}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="underline"
|
||||
href="https://supabase.com/docs/guides/platform/backups"
|
||||
>
|
||||
in our docs
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (plan === 'free') {
|
||||
return (
|
||||
<UpgradeToPro
|
||||
buttonText="Upgrade"
|
||||
primaryText="Restore to a new project requires a pro plan or above."
|
||||
secondaryText={
|
||||
'To restore to a new project, you need to upgrade to a Pro plan and have physical backups enabled.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (IS_CLONED_PROJECT) {
|
||||
return (
|
||||
<Admonition type="default" title={`This project cannot be restored to a new project`}>
|
||||
<Markdown
|
||||
content={`This project was originally restored from another project. This is a temporary limitation. Please [contact us](/support/new?ref=${project?.ref}) if you need to restore a project to multiple other projects.
|
||||
[Go to original project](/dashboard/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''})`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <AlertError error={error} subject="Failed to retrieve backups" />
|
||||
}
|
||||
|
||||
if (!isActiveHealthy) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project is not available while project is offline"
|
||||
description="Your project needs to be online to restore your database to a new project"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'FAILED') {
|
||||
return (
|
||||
<Admonition type="destructive" title="Failed to restore to new project">
|
||||
<Markdown
|
||||
content="The new project failed to be created.
|
||||
[Contact support](/support/new?category=dashboard_bug)"
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'COMPLETED') {
|
||||
return (
|
||||
<Admonition type="default" title="Restoration completed">
|
||||
<Markdown
|
||||
content={`The new project has been created. A project can only be restored to another project once.
|
||||
[Go to new project](/project/${lastClone?.target_project.ref})
|
||||
`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'IN_PROGRESS') {
|
||||
return (
|
||||
<Alert_Shadcn_ className="[&>svg]:bg-none! [&>svg]:text-foreground-light">
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle_Shadcn_>Restoration in progress</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The new project is being created.
|
||||
<br />
|
||||
<Link className="underline" href={`/project/${lastClone?.target_project.ref}`}>
|
||||
Go to new project
|
||||
</Link>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
!isLoading &&
|
||||
hasPITREnabled &&
|
||||
!cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
|
||||
) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="No backups found"
|
||||
description={'PITR is enabled, but no backups were found. Check again in a few minutes.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && !hasPITREnabled && cloneBackups?.backups.length === 0) {
|
||||
return <BackupsEmpty />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={showConfirmationDialog} onOpenChange={setShowConfirmationDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Confirm restore to a new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
This process will create a new project and restore your database to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSection className="prose pb-6 space-y-4 text-sm">
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
Project organization will stay the same: <code>{organization?.name}</code>
|
||||
</li>
|
||||
<li>
|
||||
Project region will stay the same: <code>{project?.region || ''}</code>
|
||||
</li>
|
||||
<li>
|
||||
A project can only be restored to another project once. <br />
|
||||
<span className="text-foreground-lighter text-xs">
|
||||
This is a temporary limitation. Please contact us if you need to restore a project
|
||||
to multiple other projects.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>What will be transferred?</li>
|
||||
<ul className="ml-4">
|
||||
<li>Database schema (tables, views, procedures)</li>
|
||||
<li>All data and indexes</li>
|
||||
<li>Database roles, permissions and users</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>What needs manual reconfiguration?</li>
|
||||
<ul className="ml-4">
|
||||
<li>Storage objects & settings</li>
|
||||
<li>Edge Functions</li>
|
||||
<li>Auth settings & API keys</li>
|
||||
<li>Realtime settings</li>
|
||||
<li>Database extensions and settings</li>
|
||||
<li>Read replicas</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</DialogSection>
|
||||
<AdditionalMonthlySpend />
|
||||
<DialogFooter>
|
||||
<Button type="outline" onClick={() => setShowConfirmationDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowConfirmationDialog(false)
|
||||
setShowNewProjectDialog(true)
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Create new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
This process will create a new project and restore your database to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={'create-new-project-form'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (!project?.ref) {
|
||||
toast.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (hasPITREnabled && recoveryTimeTarget) {
|
||||
triggerClone({
|
||||
projectRef: project?.ref,
|
||||
newProjectName: data.name,
|
||||
newDbPass: data.password,
|
||||
recoveryTimeTarget: recoveryTimeTarget,
|
||||
cloneBackupId: undefined,
|
||||
})
|
||||
} else if (selectedBackupId) {
|
||||
triggerClone({
|
||||
projectRef: project?.ref,
|
||||
cloneBackupId: selectedBackupId,
|
||||
newProjectName: data.name,
|
||||
newDbPass: data.password,
|
||||
recoveryTimeTarget: undefined,
|
||||
})
|
||||
} else {
|
||||
toast.error('No backup or point in time selected')
|
||||
return
|
||||
}
|
||||
})}
|
||||
>
|
||||
<DialogSection className="pb-6 space-y-4 text-sm">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="New Project Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ placeholder="Enter a name" type="text" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
id="db-password"
|
||||
label="Database Password"
|
||||
type="password"
|
||||
placeholder="Type in a strong password"
|
||||
value={field.value}
|
||||
copy={field.value?.length > 0}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
field.onChange(value)
|
||||
if (value == '') {
|
||||
setPasswordStrengthScore(-1)
|
||||
setPasswordStrengthMessage('')
|
||||
} else delayedCheckPasswordStrength(value)
|
||||
}}
|
||||
descriptionText={
|
||||
<PasswordStrengthBar
|
||||
passwordStrengthScore={passwordStrengthScore}
|
||||
password={field.value}
|
||||
passwordStrengthMessage={passwordStrengthMessage}
|
||||
generateStrongPassword={generateStrongPassword}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</DialogSection>
|
||||
<AdditionalMonthlySpend />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
htmlType="reset"
|
||||
type="outline"
|
||||
onClick={() => setShowNewProjectDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={cloneMutationLoading}>
|
||||
Restore to new project
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{hasPITREnabled ? (
|
||||
<>
|
||||
<PITRForm
|
||||
onSubmit={(v) => {
|
||||
setShowConfirmationDialog(true)
|
||||
setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
|
||||
}}
|
||||
earliestAvailableBackupUnix={
|
||||
cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
|
||||
}
|
||||
latestAvailableBackupUnix={
|
||||
cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Panel>
|
||||
{cloneBackups?.backups.length === 0 ? (
|
||||
<>
|
||||
<BackupsEmpty />
|
||||
</>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{/* <pre>{JSON.stringify({ cloneStatus }, null, 2)}</pre> */}
|
||||
{cloneBackups?.backups.map((backup) => {
|
||||
if (!backup.isPhysicalBackup) return null
|
||||
return (
|
||||
<div className="flex p-4 gap-4" key={backup.id}>
|
||||
<div>
|
||||
<TimestampInfo value={backup.inserted_at} />
|
||||
</div>
|
||||
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
|
||||
{(backup.status as any) === 'COMPLETED' && (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setSelectedBackupId(backup.id)
|
||||
setShowConfirmationDialog(true)
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreToNewProjectPage
|
||||
@@ -0,0 +1,26 @@
|
||||
// select * from cron.job_run_details where jobid = '1' order by start_time desc limit 10
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
|
||||
export const LogPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
return (
|
||||
<LogsPreviewer
|
||||
projectRef={ref as string}
|
||||
condensedLayout={true}
|
||||
tableName={LogsTableName.CRON_JOBS}
|
||||
queryType={'cron'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => <LogsLayout title="Cron Logs">{page}</LogsLayout>
|
||||
|
||||
export default LogPage
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.react-datepicker__day--highlighted {
|
||||
@apply bg-brand-600 text-foreground-light;
|
||||
@apply bg-overlay-hover text-foreground-light;
|
||||
}
|
||||
|
||||
.react-datepicker__aria-live {
|
||||
|
||||
Vendored
+647
-51
File diff suppressed because it is too large.
Load diff
@@ -84,12 +84,22 @@ export const TimestampInfo = ({
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
}}
|
||||
className={cn('grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1', {
|
||||
'bg-surface-100': copied,
|
||||
})}
|
||||
className={cn(
|
||||
'relative cursor-default grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1',
|
||||
{
|
||||
'bg-surface-100': copied,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="text-right truncate">{label}:</span>
|
||||
<span className={copied ? 'text-brand-600' : ''}>{copied ? 'Copied!' : value}</span>
|
||||
<div className="relative">
|
||||
{copied && (
|
||||
<span className="absolute inset-0 flex items-center text-brand-600 bg-surface-100">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user