mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 01:40:13 -04:00
2d92563b57
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. Resolves DEPR-511. ## What is the current behavior? Paused projects in `Project Settings > General > Project availability` still present restart/pause maintenance controls, but no resume affordance. That makes the resume path hard to discover from Settings and pushes users back to the project dashboard to find the correct action. The paused state also keeps showing a redundant disabled `Pause project` row, and the pause confirmation uses a more flexible modal than this flow needs. DEPR-519 already covered the unhealthy-project restart guard, but not this paused-project discoverability path. ## What is the new behavior? Project Settings is now paused-project aware. It shows a shared `Resume project` action when the project can still be restored, falls back to the project dashboard when the restore window has expired or pause status cannot be confirmed, and reuses the same resume flow, permission checks, and free-tier guardrails as the paused dashboard. While a project is already paused, the redundant `Pause project` row is hidden so the section stays focused on the real next action. For active projects, the pause row remains in place, including the useful disabled tooltip states for plans that cannot pause. The pause confirmation now uses `AlertDialog` with shorter, more accurate copy about the restore window, and the restart controls now behave more consistently on smaller breakpoints. The Project Settings command-menu entry is also searchable via `resume project`. | Before | After | | --- | --- | | <img width="1602" height="566" alt="CleanShot 2026-04-24 at 18 05 25@2x" src="https://github.com/user-attachments/assets/bd8f4095-0360-443c-a179-185da69eb9e8" /> | <img width="1538" height="408" alt="CleanShot 2026-04-24 at 18 06 12@2x" src="https://github.com/user-attachments/assets/7ac26529-4b54-460e-89c3-927891d873d8" /> | | <img width="1524" height="524" alt="CleanShot 2026-04-24 at 18 08 53@2x" src="https://github.com/user-attachments/assets/f3c49c46-b389-4324-b982-f557b159623e" /> | <img width="1528" height="550" alt="CleanShot 2026-04-24 at 18 08 30@2x" src="https://github.com/user-attachments/assets/4021e2bb-f22f-40db-be43-de6d0fb571b3" /> | | <img width="896" height="558" alt="CleanShot 2026-04-24 at 17 41 40@2x" src="https://github.com/user-attachments/assets/31569aec-89a6-4984-8011-39d8b102c90f" /> | <img width="912" height="502" alt="CleanShot 2026-04-24 at 18 10 34@2x" src="https://github.com/user-attachments/assets/f19dcd27-12e6-4a2f-8eed-ca709e77dfa1" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a tooltip-enabled "Resume project" button that handles permissions, free-plan member gating, optional Postgres version selection, and navigates to the project after restore. * **UX** * Pause confirmation migrated to an alert-style dialog with updated copy and disabled controls during pausing. * Restart controls updated for improved responsive layout and refreshed button visuals. * Project settings now show appropriate resume/dashboard actions based on pause/restore eligibility. * **Tests** * Added tests for active, resumable-paused, and non-resumable-paused states. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||
import { useFlag, useParams } from 'common'
|
||
import { useRouter } from 'next/router'
|
||
import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { AWS_REGIONS, CloudProvider } from 'shared-data'
|
||
import { toast } from 'sonner'
|
||
import {
|
||
Button,
|
||
cn,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogSection,
|
||
DialogTitle,
|
||
Form,
|
||
FormField,
|
||
} from 'ui'
|
||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||
import { z } from 'zod'
|
||
|
||
import {
|
||
extractPostgresVersionDetails,
|
||
PostgresVersionSelector,
|
||
} from '@/components/interfaces/ProjectCreation/PostgresVersionSelector'
|
||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
|
||
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
|
||
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
|
||
import { useProjectRestoreMutation } from '@/data/projects/project-restore-mutation'
|
||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||
import { PROJECT_STATUS } from '@/lib/constants'
|
||
|
||
const FormSchema = z.object({
|
||
postgresVersionSelection: z.string(),
|
||
})
|
||
|
||
type ResumeProjectButtonProps = Pick<
|
||
ComponentPropsWithoutRef<typeof ButtonTooltip>,
|
||
'className' | 'size' | 'type'
|
||
> & {
|
||
label?: string
|
||
}
|
||
|
||
export const ResumeProjectButton = ({
|
||
className,
|
||
label = 'Resume project',
|
||
size,
|
||
type = 'default',
|
||
}: ResumeProjectButtonProps) => {
|
||
const router = useRouter()
|
||
const { ref } = useParams()
|
||
const { data: project } = useSelectedProjectQuery()
|
||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||
const { setProjectStatus } = useSetProjectStatus()
|
||
|
||
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
|
||
const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region)
|
||
const orgSlug = selectedOrganization?.slug
|
||
const isFreePlan = selectedOrganization?.plan?.id === 'free'
|
||
|
||
const {
|
||
data: pauseStatus,
|
||
isPending: isPauseStatusPending,
|
||
isSuccess: isPauseStatusSuccess,
|
||
} = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE })
|
||
|
||
const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore
|
||
|
||
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
|
||
{ slug: orgSlug },
|
||
{ enabled: isFreePlan }
|
||
)
|
||
|
||
const hasMembersExceedingFreeTierLimit = (membersExceededLimit ?? []).length > 0
|
||
|
||
const [showConfirmRestore, setShowConfirmRestore] = useState(false)
|
||
const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false)
|
||
|
||
const { can: canResumeProject } = useAsyncCheckPermissions(
|
||
PermissionAction.INFRA_EXECUTE,
|
||
'queue_jobs.projects.initialize_or_resume'
|
||
)
|
||
|
||
const { mutate: restoreProject, isPending: isRestoring } = useProjectRestoreMutation({
|
||
onSuccess: async (_, variables) => {
|
||
setProjectStatus({ ref: variables.ref, status: PROJECT_STATUS.RESTORING })
|
||
toast.success('Restoring project, project will be ready in a few minutes')
|
||
await router.push(`/project/${variables.ref}`)
|
||
},
|
||
})
|
||
|
||
const form = useForm<z.infer<typeof FormSchema>>({
|
||
resolver: zodResolver(FormSchema),
|
||
mode: 'onChange',
|
||
defaultValues: { postgresVersionSelection: '' },
|
||
})
|
||
|
||
const onSelectRestore = () => {
|
||
if (project?.status !== PROJECT_STATUS.INACTIVE) {
|
||
return toast.error('Unable to resume: project is not paused')
|
||
}
|
||
|
||
if (isRestoreDisabled) {
|
||
return toast.error('This project can no longer be resumed from the dashboard')
|
||
}
|
||
|
||
if (!canResumeProject) {
|
||
return toast.error('You do not have the required permissions to restore this project')
|
||
}
|
||
|
||
if (hasMembersExceedingFreeTierLimit) {
|
||
return setShowFreeProjectLimitWarning(true)
|
||
}
|
||
|
||
setShowConfirmRestore(true)
|
||
}
|
||
|
||
const onConfirmRestore = async (values: z.infer<typeof FormSchema>) => {
|
||
if (!project) {
|
||
return toast.error('Unable to restore: project is required')
|
||
}
|
||
|
||
if (!showPostgresVersionSelector) {
|
||
return restoreProject({ ref: project.ref })
|
||
}
|
||
|
||
const postgresVersionDetails = extractPostgresVersionDetails(values.postgresVersionSelection)
|
||
|
||
restoreProject({
|
||
ref: project.ref,
|
||
releaseChannel: postgresVersionDetails.releaseChannel,
|
||
postgresEngine: postgresVersionDetails.postgresEngine,
|
||
})
|
||
}
|
||
|
||
const buttonDisabled =
|
||
project?.status !== PROJECT_STATUS.INACTIVE ||
|
||
project === undefined ||
|
||
isPauseStatusPending ||
|
||
isRestoring ||
|
||
isRestoreDisabled ||
|
||
!canResumeProject
|
||
|
||
const tooltipText = useMemo(() => {
|
||
if (isPauseStatusPending) return 'Checking whether this project can be resumed'
|
||
if (project?.status !== PROJECT_STATUS.INACTIVE) {
|
||
return 'Project must be paused before it can be resumed'
|
||
}
|
||
if (isRestoreDisabled) return 'This project can no longer be resumed from the dashboard'
|
||
if (!canResumeProject) return 'You need additional permissions to resume this project'
|
||
return undefined
|
||
}, [canResumeProject, isPauseStatusPending, isRestoreDisabled, project?.status])
|
||
|
||
return (
|
||
<>
|
||
<ButtonTooltip
|
||
className={className}
|
||
size={size}
|
||
type={type}
|
||
disabled={buttonDisabled}
|
||
loading={isRestoring}
|
||
onClick={onSelectRestore}
|
||
tooltip={{
|
||
content: {
|
||
side: 'bottom',
|
||
text: tooltipText,
|
||
},
|
||
}}
|
||
>
|
||
{label}
|
||
</ButtonTooltip>
|
||
|
||
<ConfirmationModal
|
||
visible={showConfirmRestore}
|
||
size="small"
|
||
title="Resume this project"
|
||
onCancel={() => setShowConfirmRestore(false)}
|
||
onConfirm={() => form.handleSubmit(onConfirmRestore)()}
|
||
loading={isRestoring}
|
||
confirmLabel="Resume"
|
||
confirmLabelLoading="Resuming"
|
||
cancelLabel="Cancel"
|
||
>
|
||
<div className={cn(showPostgresVersionSelector && 'flex flex-col gap-y-4')}>
|
||
<p className="text-sm">
|
||
{isFreePlan
|
||
? 'Your project’s data will be restored to when it was initially paused.'
|
||
: 'Your project’s data will be restored and billing will resume based on compute size and hours active.'}
|
||
</p>
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onConfirmRestore)}>
|
||
{showPostgresVersionSelector && (
|
||
<div className="space-y-2">
|
||
<FormField
|
||
control={form.control}
|
||
name="postgresVersionSelection"
|
||
render={({ field }) => (
|
||
<PostgresVersionSelector
|
||
field={field}
|
||
form={form}
|
||
type="unpause"
|
||
label="Postgres version"
|
||
layout="vertical"
|
||
dbRegion={region?.displayName ?? ''}
|
||
cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider}
|
||
organizationSlug={selectedOrganization?.slug}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</form>
|
||
</Form>
|
||
</div>
|
||
</ConfirmationModal>
|
||
|
||
<Dialog
|
||
open={showFreeProjectLimitWarning}
|
||
onOpenChange={() => setShowFreeProjectLimitWarning(false)}
|
||
>
|
||
<DialogContent size="medium" className="gap-0 pb-0">
|
||
<DialogHeader className="border-b">
|
||
<DialogTitle className="leading-normal">
|
||
Your organization has members who have exceeded their free project limits
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<DialogSection className="text-sm">
|
||
<p className="text-foreground-light">
|
||
The following members have reached their maximum limits for the number of active free
|
||
plan projects within organizations where they are an administrator or owner:
|
||
</p>
|
||
<ul className="my-4 list-disc list-inside">
|
||
{(membersExceededLimit ?? []).map((member, idx: number) => (
|
||
<li key={`member-${idx}`}>
|
||
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
|
||
projects)
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="text-foreground-light">
|
||
These members will need to either delete, pause, or upgrade one or more of these
|
||
projects before you're able to resume this project.
|
||
</p>
|
||
</DialogSection>
|
||
<DialogFooter>
|
||
<Button
|
||
htmlType="button"
|
||
type="default"
|
||
onClick={() => setShowFreeProjectLimitWarning(false)}
|
||
>
|
||
Understood
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|