Files
Ali WaseemandAlaister Young b6abae6abe fix(studio): only show restore completion once the restore has run (#48948)
Resolves
[FE-4144](https://linear.app/supabase/issue/FE-4144/restore-flow-shows-completion-before-restore-is-actually-done)

## Problem

`RestoringState` treated any `ACTIVE_HEALTHY` reading from the project
status endpoint as "restore finished". Right after a restore is
triggered the backend still reports the pre-restore status, so the first
poll could land on `ACTIVE_HEALTHY` and flip the UI to "Restoration
complete!" seconds into a restore that had barely started. `isCompleted`
was local state nothing reset and polling stopped on that first reading,
so the screen never self-corrected — "Return to project" then hung until
a manual refresh.

## Changes

- Gate completion on having observed the project leave the healthy
state, so a stale pre-restore reading is no longer mistaken for a
finished restore.
- Keep polling through an unconfirmed healthy reading instead of
stopping on it.
- `onConfirm` clears its loading flag rather than relying on the layout
to unmount the component.
- Component tests covering both the premature completion and the stuck
button.

## Needs validation

Not yet verified against a real restore — please confirm on staging
before merging. Worth checking in particular that a restore which
completes normally still reaches the completion screen.

There is one residual edge case left in place deliberately: if the
details endpoint reports `RESTORING` while the status endpoint reports
`ACTIVE_HEALTHY`, the UI now stays on "Restoration in progress" until
the details query catches up. Fixing that properly needs an
authoritative "restore initiated at" timestamp from the API, which does
not exist today.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved project restoration tracking to prevent completion from being
reported prematurely.
- Restoration now correctly detects failures and stops polling when
appropriate.
- Restore status and saved transition information are cleared after
successful completion or failure.
- Confirmation actions now remain reliable while project details
refresh.
  - Restoring controls become usable again after the process finishes.
- Improved the restore menu trigger behavior for more consistent
interaction.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-19 07:51:56 -06:00

207 lines
8.0 KiB
TypeScript

import { SupportCategories } from '@supabase/shared-types/out/constants'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { CheckCircle, Download, Loader } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useBackupDownloadMutation } from '@/data/database/backup-download-mutation'
import { useDownloadableBackupQuery } from '@/data/database/backup-query'
import { useInvalidateProjectDetailsQuery } from '@/data/projects/project-detail-query'
import { useProjectStatusQuery } from '@/data/projects/project-status-query'
import { useLongRunningTransitionState } from '@/hooks/misc/useLongRunningTransitionState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
import {
clearPersistedTransitionStartTime,
minutesToMilliseconds,
} from '@/lib/project-transition-state'
import { getRestoreLongRunningThresholdMinutes } from '@/lib/restore-estimate'
export const POLL_INTERVAL_MS = 4000
export const RestoringState = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isConfirming, setIsConfirming] = useState(false)
const [hasLeftHealthyState, setHasLeftHealthyState] = useState(false)
const restoreStateStartStorageKey = ref
? LOCAL_STORAGE_KEYS.PROJECT_RESTORING_STARTED_AT(ref)
: null
const { data } = useDownloadableBackupQuery({ projectRef: ref })
const backups = data?.backups ?? []
const logicalBackups = backups.filter((b) => !b.isPhysicalBackup)
const longRunningThresholdMinutes = getRestoreLongRunningThresholdMinutes(project?.volumeSizeGb)
const longRunningThresholdMs = minutesToMilliseconds(longRunningThresholdMinutes)
const isTakingLongerThanExpected = useLongRunningTransitionState({
storageKey: restoreStateStartStorageKey,
thresholdMs: longRunningThresholdMs,
})
const { invalidateProjectDetailsQuery } = useInvalidateProjectDetailsQuery()
const { data: projectStatusData } = useProjectStatusQuery(
{ projectRef: ref },
{
enabled: project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY,
refetchInterval: (query) => {
const status = query.state.data?.status
if (status === PROJECT_STATUS.RESTORE_FAILED) return false
if (status === PROJECT_STATUS.ACTIVE_HEALTHY && hasLeftHealthyState) return false
return POLL_INTERVAL_MS
},
}
)
const projectStatus = projectStatusData?.status
// Right after a restore is triggered the status endpoint can still report the stale
// pre-restore ACTIVE_HEALTHY, so completion is only trusted once the status has been
// observed leaving the healthy state.
if (
!hasLeftHealthyState &&
projectStatus !== undefined &&
projectStatus !== PROJECT_STATUS.ACTIVE_HEALTHY
) {
setHasLeftHealthyState(true)
}
const hasRestoreFailed = projectStatus === PROJECT_STATUS.RESTORE_FAILED
const isCompleted = hasLeftHealthyState && projectStatus === PROJECT_STATUS.ACTIVE_HEALTHY
const { mutate: downloadBackup, isPending: isDownloading } = useBackupDownloadMutation({
onSuccess: (res) => {
const { fileUrl } = res
// Trigger browser download by create,trigger and remove tempLink
const tempLink = document.createElement('a')
tempLink.href = fileUrl
document.body.appendChild(tempLink)
tempLink.click()
document.body.removeChild(tempLink)
},
})
const onClickDownloadBackup = () => {
if (!ref) return console.error('Project ref is required')
if (logicalBackups.length === 0) return console.error('No available backups to download')
downloadBackup({ ref, backup: logicalBackups[0] })
}
const onConfirm = async () => {
if (!ref) return console.error('Project ref is required')
setIsConfirming(true)
try {
await invalidateProjectDetailsQuery(ref)
} finally {
setIsConfirming(false)
}
}
useEffect(() => {
if (!isCompleted && !hasRestoreFailed) return
if (restoreStateStartStorageKey) {
clearPersistedTransitionStartTime(restoreStateStartStorageKey)
}
if (hasRestoreFailed && ref) void invalidateProjectDetailsQuery(ref)
}, [
isCompleted,
hasRestoreFailed,
restoreStateStartStorageKey,
ref,
invalidateProjectDetailsQuery,
])
return (
<div className="flex items-center justify-center h-full">
<div className="bg-surface-100 border border-overlay rounded-md w-3/4 lg:w-1/2">
{isCompleted ? (
<div className="space-y-6 pt-6">
<div className="flex px-8 space-x-8">
<div className="mt-1">
<CheckCircle className="text-brand" size={18} strokeWidth={2} />
</div>
<div className="space-y-1">
<p>Restoration complete!</p>
<p className="text-sm text-foreground-light">
Your project has been successfully restored and is now back online.
</p>
</div>
</div>
<div className="border-t border-overlay flex items-center justify-end py-4 px-8">
<Button disabled={isConfirming} loading={isConfirming} onClick={onConfirm}>
Return to project
</Button>
</div>
</div>
) : (
<>
<div className="space-y-6 py-6">
<div className="flex px-8 space-x-8">
<div className="mt-1">
<Loader className="animate-spin" size={18} />
</div>
<div className="space-y-1">
<p>Restoration in progress</p>
<p className="text-sm text-foreground-light">
Restoration can take from a few minutes up to several hours depending on the
size of your database. Your project will be offline while the restoration is
running.
</p>
{isTakingLongerThanExpected && (
<Admonition
type="warning"
title="This is taking longer than usual"
layout="responsive"
description="Contact support if this project remains in a restoring state."
actions={
<Button asChild variant="default">
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref ?? ref,
subject: 'Project stuck in restoring state',
message: `Project "${project?.name ?? 'Unknown project'}" (ref: ${project?.ref ?? ref ?? 'unknown'}) has remained in a restoring state for over ${longRunningThresholdMinutes} minutes.`,
}}
>
Contact support
</SupportLink>
</Button>
}
className="mt-5!"
/>
)}
</div>
</div>
</div>
<div className="border-t border-overlay flex items-center justify-end py-4 px-8 gap-x-2">
<ButtonTooltip
variant="default"
icon={<Download />}
loading={isDownloading}
disabled={logicalBackups.length === 0}
tooltip={{
content: {
side: 'bottom',
text:
logicalBackups.length === 0 ? 'No available backups to download' : undefined,
},
}}
onClick={onClickDownloadBackup}
>
Download latest backup
</ButtonTooltip>
</div>
</>
)}
</div>
</div>
)
}