fix(studio): add resume project flow to project settings (#45078)

## 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>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-04-28 09:30:54 +00:00
1 parent dab1512fe9
commit 2d92563b57
8 files changed
+639 -347

No files matched your search

@@ -0,0 +1,263 @@
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>
</>
)
}
@@ -3,7 +3,16 @@ import { Pause } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from 'ui'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
@@ -91,21 +100,23 @@ const PauseProjectButton = () => {
Pause project
</ButtonTooltip>
<ConfirmationModal
variant={'destructive'}
visible={isModalOpen}
loading={isPausing}
title="Pause this project?"
confirmLabel="Pause project"
confirmLabelLoading="Pausing project"
onCancel={() => setIsModalOpen(false)}
onConfirm={requestPauseProject}
>
<p className="text-foreground-light text-sm">
Are you sure you want to pause this project? It will not be accessible until you unpause
it.
</p>
</ConfirmationModal>
<AlertDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Pause project?</AlertDialogTitle>
<AlertDialogDescription>
This project will be unavailable while paused. Your data stays safe, and you can
resume it later from the dashboard while it remains within the restore window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPausing}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={isPausing} onClick={requestPauseProject} variant="danger">
{isPausing ? 'Pausing project...' : 'Pause project'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag } from 'common'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, RefreshCw } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
@@ -26,6 +26,7 @@ import {
useSelectedProjectQuery,
} from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
import { type ResponseError } from '@/types'
const RestartServerButton = () => {
const router = useRouter()
@@ -69,6 +70,7 @@ const RestartServerButton = () => {
})
const isLoading = isRestartingProject || isRestartingServices
const hasRestartDropdown = canRestartProject && canRestart && !projectRestartDisabled
const requestProjectRestart = () => {
if (!canRestartProject) {
@@ -84,7 +86,7 @@ const RestartServerButton = () => {
restartProjectServices({ ref: projectRef, region: projectRegion, services: ['postgresql'] })
}
const onRestartFailed = (error: any, type: string) => {
const onRestartFailed = (error: ResponseError, type: string) => {
toast.error(`Unable to restart ${type}: ${error.message}`)
setServiceToRestart(undefined)
}
@@ -99,11 +101,11 @@ const RestartServerButton = () => {
return (
<>
{projectSettingsRestartProject ? (
<div className="flex">
<div className="flex w-full @lg:w-auto">
<ButtonTooltip
type="default"
className={cn(
'px-3 hover:z-10',
'flex-1 px-3 hover:z-10 @lg:flex-none',
canRestartProject && canRestart ? 'rounded-r-none' : ''
)}
disabled={
@@ -131,12 +133,12 @@ const RestartServerButton = () => {
>
Restart project
</ButtonTooltip>
{canRestartProject && canRestart && !projectRestartDisabled && (
{hasRestartDropdown && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
className="rounded-l-none px-[4px] py-[5px] -ml-[1px]"
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-[1px]"
icon={<ChevronDown />}
disabled={!canRestartProject}
/>
@@ -149,11 +151,11 @@ const RestartServerButton = () => {
setServiceToRestart('database')
}}
>
<div className="space-y-1">
<div className="space-y-0.5">
<p className="block text-foreground">Fast database reboot</p>
<p className="block text-foreground-light">
Restarts only the database - faster but may not be able to recover from all
failure modes
Restarts only the database. Faster, but may not be able to recover from all
failure modes.
</p>
</div>
</DropdownMenuItem>
@@ -164,6 +166,8 @@ const RestartServerButton = () => {
) : (
<Button
type="default"
icon={<RefreshCw />}
className="w-full @lg:w-auto"
disabled={isLoading}
onClick={() => {
setServiceToRestart('database')
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Project } from './Project'
const {
mockUseIsFeatureEnabled,
mockUseProjectPauseStatusQuery,
mockUseSelectedOrganizationQuery,
mockUseSelectedProjectQuery,
} = vi.hoisted(() => ({
mockUseIsFeatureEnabled: vi.fn(),
mockUseProjectPauseStatusQuery: vi.fn(),
mockUseSelectedOrganizationQuery: vi.fn(),
mockUseSelectedProjectQuery: vi.fn(),
}))
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}))
vi.mock('ui', () => ({
Button: ({
children,
asChild,
type: _type,
...props
}: {
children: ReactNode
asChild?: boolean
type?: string
}) => (asChild ? <>{children}</> : <button {...props}>{children}</button>),
Card: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('ui-patterns/PageSection', () => ({
PageSection: ({ children, id }: { children: ReactNode; id?: string }) => (
<section id={id}>{children}</section>
),
PageSectionContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PageSectionDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
PageSectionMeta: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PageSectionSummary: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PageSectionTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/interfaces/Project/ResumeProjectButton', () => ({
ResumeProjectButton: () => <div>ResumeProjectButton</div>,
}))
vi.mock('./Infrastructure/PauseProjectButton', () => ({
default: () => <div>PauseProjectButton</div>,
}))
vi.mock('./Infrastructure/RestartServerButton', () => ({
default: () => <div>RestartServerButton</div>,
}))
vi.mock('@/data/projects/project-pause-status-query', () => ({
useProjectPauseStatusQuery: mockUseProjectPauseStatusQuery,
}))
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockUseIsFeatureEnabled,
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery,
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: mockUseSelectedProjectQuery,
}))
describe('Project settings availability', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsFeatureEnabled.mockReturnValue({
projectSettingsRestartProject: true,
})
mockUseSelectedOrganizationQuery.mockReturnValue({
data: { slug: 'supabase' },
})
mockUseProjectPauseStatusQuery.mockReturnValue({
data: undefined,
isError: false,
isSuccess: false,
})
})
it('shows the restart action for active projects', () => {
mockUseSelectedProjectQuery.mockReturnValue({
data: {
parent_project_ref: null,
ref: 'active-project',
status: 'ACTIVE_HEALTHY',
},
})
render(<Project />)
expect(screen.getByText('Restart project')).toBeInTheDocument()
expect(screen.getByText('RestartServerButton')).toBeInTheDocument()
expect(screen.getByText('Pause project')).toBeInTheDocument()
expect(screen.getByText('PauseProjectButton')).toBeInTheDocument()
expect(screen.queryByText('ResumeProjectButton')).not.toBeInTheDocument()
})
it('shows the shared resume action for paused projects that can still be restored', () => {
mockUseSelectedProjectQuery.mockReturnValue({
data: {
parent_project_ref: null,
ref: 'paused-project',
status: 'INACTIVE',
},
})
mockUseProjectPauseStatusQuery.mockReturnValue({
data: { can_restore: true },
isError: false,
isSuccess: true,
})
render(<Project />)
expect(screen.getByText('Resume project')).toBeInTheDocument()
expect(screen.getByText('Bring your paused project back online.')).toBeInTheDocument()
expect(screen.getByText('ResumeProjectButton')).toBeInTheDocument()
expect(screen.queryByText('Pause project')).not.toBeInTheDocument()
expect(screen.queryByText('PauseProjectButton')).not.toBeInTheDocument()
expect(screen.queryByText('RestartServerButton')).not.toBeInTheDocument()
})
it('links back to the dashboard when the paused project can no longer be restored', () => {
mockUseSelectedProjectQuery.mockReturnValue({
data: {
parent_project_ref: null,
ref: 'paused-project',
status: 'INACTIVE',
},
})
mockUseProjectPauseStatusQuery.mockReturnValue({
data: { can_restore: false },
isError: false,
isSuccess: true,
})
render(<Project />)
expect(screen.getAllByText('View project dashboard')).toHaveLength(2)
expect(
screen.getByText(
'This project can no longer be resumed here. Open the dashboard to download backups and view recovery options.'
)
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View project dashboard' })).toHaveAttribute(
'href',
'/project/paused-project'
)
expect(screen.queryByText('Pause project')).not.toBeInTheDocument()
expect(screen.queryByText('PauseProjectButton')).not.toBeInTheDocument()
expect(screen.queryByText('ResumeProjectButton')).not.toBeInTheDocument()
})
})
@@ -12,17 +12,45 @@ import {
import PauseProjectButton from './Infrastructure/PauseProjectButton'
import RestartServerButton from './Infrastructure/RestartServerButton'
import { ResumeProjectButton } from '@/components/interfaces/Project/ResumeProjectButton'
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
export const Project = () => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const isBranch = Boolean(project?.parent_project_ref)
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
const { projectSettingsRestartProject } = useIsFeatureEnabled([
'project_settings:restart_project',
])
const {
data: pauseStatus,
isError: isPauseStatusError,
isSuccess: isPauseStatusSuccess,
} = useProjectPauseStatusQuery({ ref: project?.ref }, { enabled: isPaused })
const shouldShowDashboardLink =
isPaused && (isPauseStatusError || (isPauseStatusSuccess && !pauseStatus.can_restore))
const primaryActionLabel = isPaused
? shouldShowDashboardLink
? 'View project dashboard'
: 'Resume project'
: projectSettingsRestartProject
? 'Restart project'
: 'Restart database'
const primaryActionDescription = isPaused
? isPauseStatusSuccess && !pauseStatus.can_restore
? 'This project can no longer be resumed here. Open the dashboard to download backups and view recovery options.'
: isPauseStatusError
? 'Open the dashboard to manage this paused project.'
: 'Bring your paused project back online.'
: 'Your project will not be available for a few minutes.'
return (
<>
@@ -31,7 +59,9 @@ export const Project = () => {
<PageSectionSummary>
<PageSectionTitle>Project availability</PageSectionTitle>
<PageSectionDescription>
Restart or pause your project when performing maintenance
{isPaused
? 'Resume your paused project or review recovery options'
: 'Restart or pause your project when performing maintenance'}
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
@@ -40,34 +70,42 @@ export const Project = () => {
<CardContent>
<div className="flex flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4">
<div>
<p className="text-sm">
{projectSettingsRestartProject ? 'Restart project' : 'Restart database'}
</p>
<p className="text-sm">{primaryActionLabel}</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be available for a few minutes.
</p>
<p className="text-sm text-foreground-light">{primaryActionDescription}</p>
</div>
</div>
<RestartServerButton />
{isPaused ? (
shouldShowDashboardLink ? (
<Button asChild type="default">
<Link href={`/project/${project?.ref}`}>View project dashboard</Link>
</Button>
) : (
<ResumeProjectButton />
)
) : (
<RestartServerButton />
)}
</div>
</CardContent>
<CardContent>
<div
className="flex w-full flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4"
id="pause-project"
>
<div>
<p className="text-sm">Pause project</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be accessible while it is paused.
</p>
{!isPaused && (
<CardContent>
<div
className="flex w-full flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4"
id="pause-project"
>
<div>
<p className="text-sm">Pause project</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be accessible while it is paused.
</p>
</div>
</div>
<PauseProjectButton />
</div>
<PauseProjectButton />
</div>
</CardContent>
</CardContent>
)}
</Card>
</PageSectionContent>
</PageSection>
@@ -86,9 +124,9 @@ export const Project = () => {
<div className="flex space-x-4">
<BarChart2 strokeWidth={2} />
<div>
<p className="text-sm">Project usage statistics have been moved</p>
<p className="text-sm">Project usage statistics have moved</p>
<p className="text-foreground-light text-sm">
You may view your project's usage under your organization's settings
Project usage is now viewable within organization settings.
</p>
</div>
</div>
@@ -59,7 +59,7 @@ export function useAuthGotoCommands(options?: CommandOptions) {
...(authenticationThirdPartyAuth
? [
{
id: 'nav-auth-providers',
id: 'nav-auth-providers-third-party',
name: 'Providers (Third Party)',
value: 'Auth: Providers (Third Party)',
route: `/project/${ref}/auth/third-party`,
@@ -1,49 +1,26 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { PauseCircle } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { AWS_REGIONS, CloudProvider } from 'shared-data'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
cn,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form,
FormField,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { z } from 'zod'
import { PauseDisabledState } from './PauseDisabledState'
import {
extractPostgresVersionDetails,
PostgresVersionSelector,
} from '@/components/interfaces/ProjectCreation/PostgresVersionSelector'
import AlertError from '@/components/ui/AlertError'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { ResumeProjectButton } from '@/components/interfaces/Project/ResumeProjectButton'
import { AlertError } from '@/components/ui/AlertError'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
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 { usePHFlag } from '@/hooks/ui/useFlag'
@@ -57,14 +34,9 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { setProjectStatus } = useSetProjectStatus()
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const enableProBenefitWording = usePHFlag('proBenefitWording')
const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region)
const orgSlug = selectedOrganization?.slug
const {
data: pauseStatus,
error: pauseStatusError,
@@ -81,280 +53,111 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
const isFreePlan = selectedOrganization?.plan?.id === 'free'
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 { mutate: restoreProject, isPending: isRestoring } = useProjectRestoreMutation({
onSuccess: (_, variables) => {
setProjectStatus({ ref: variables.ref, status: PROJECT_STATUS.RESTORING })
toast.success('Restoring project, project will be ready in a few minutes')
},
})
const { can: canResumeProject } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_jobs.projects.initialize_or_resume'
)
const onSelectRestore = () => {
if (!canResumeProject) {
toast.error('You do not have the required permissions to restore this project')
} else if (hasMembersExceedingFreeTierLimit) setShowFreeProjectLimitWarning(true)
else setShowConfirmRestore(true)
}
const onConfirmRestore = async (values: z.infer<typeof FormSchema>) => {
if (!project) {
return toast.error('Unable to restore: project is required')
}
if (!showPostgresVersionSelector) {
restoreProject({ ref: project.ref })
} else {
const { postgresVersionSelection } = values
const postgresVersionDetails = extractPostgresVersionDetails(postgresVersionSelection)
restoreProject({
ref: project.ref,
releaseChannel: postgresVersionDetails.releaseChannel,
postgresEngine: postgresVersionDetails.postgresEngine,
})
}
}
const FormSchema = z.object({
postgresVersionSelection: z.string(),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
mode: 'onChange',
defaultValues: { postgresVersionSelection: '' },
})
return (
<>
<Card className="w-full max-w-[40rem] mx-auto">
<CardContent>
<PauseCircle
size={48}
strokeWidth={1}
className="text-foreground-lighter shrink-0 mb-4"
/>
<div className="flex-1">
<div>
<h2 className="mb-4">The project "{project?.name}" is currently paused</h2>
<div className="text-foreground-light max-w-4xl">
{isLoading && <GenericSkeletonLoader className="mt-3" />}
<Card className="w-full max-w-[40rem] mx-auto">
<CardContent>
<PauseCircle size={48} strokeWidth={1} className="text-foreground-lighter shrink-0 mb-4" />
<div className="flex-1">
<div>
<h2 className="mb-4">The project "{project?.name}" is currently paused</h2>
<div className="text-foreground-light max-w-4xl">
{isLoading && <GenericSkeletonLoader className="mt-3" />}
{isPauseStatusSuccess && !isRestoreDisabled ? (
isFreePlan ? (
<>
<p className="text-sm">
All data, including backups and storage objects, remains safe. You can
resume this project from the dashboard within{' '}
<Tooltip>
<TooltipTrigger>
<span className={cn(InlineLinkClassName, 'text-foreground')}>
{finalDaysRemainingBeforeRestoreDisabled} day
{finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''}
</span>{' '}
</TooltipTrigger>
<TooltipContent side="bottom" className="w-80 text-center">
Free projects cannot be restored through the dashboard if they are
paused for more than {pauseStatus.max_days_till_restore_disabled} days
</TooltipContent>
</Tooltip>{' '}
(until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={dayjs()
.utc()
.add(pauseStatus.remaining_days_till_restore_disabled ?? 0, 'day')
.toISOString()}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
). After that, this project will not be resumable, but data will still be
available for download.
</p>
<p className="text-sm mt-4">
{enableProBenefitWording === 'variant-a'
? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.'
: 'To prevent future pauses, consider upgrading to Pro.'}
</p>
{!!pauseStatus.last_paused_on && (
<p className="text-foreground-lighter text-sm">
Project last paused on{' '}
<TimestampInfo
className="text-sm"
labelFormat="DD MMM YYYY"
utcTimestamp={pauseStatus.last_paused_on}
/>
</p>
)}
</>
) : (
{isPauseStatusSuccess && !isRestoreDisabled ? (
isFreePlan ? (
<>
<p className="text-sm">
Your project data is safe but inaccessible while paused. Once resumed, usage
will be billed by compute size and hours active.
All data, including backups and storage objects, remains safe. You can resume
this project from the dashboard within{' '}
<Tooltip>
<TooltipTrigger>
<span className={cn(InlineLinkClassName, 'text-foreground')}>
{finalDaysRemainingBeforeRestoreDisabled} day
{finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''}
</span>{' '}
</TooltipTrigger>
<TooltipContent side="bottom" className="w-80 text-center">
Free projects cannot be restored through the dashboard if they are paused
for more than {pauseStatus.max_days_till_restore_disabled} days
</TooltipContent>
</Tooltip>{' '}
(until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={dayjs()
.utc()
.add(pauseStatus.remaining_days_till_restore_disabled ?? 0, 'day')
.toISOString()}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
). After that, this project will not be resumable, but data will still be
available for download.
</p>
)
) : !isLoading ? (
<p className="text-sm mt-4">
{enableProBenefitWording === 'variant-a'
? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.'
: 'To prevent future pauses, consider upgrading to Pro.'}
</p>
{!!pauseStatus.last_paused_on && (
<p className="text-foreground-lighter text-sm">
Project last paused on{' '}
<TimestampInfo
className="text-sm"
labelFormat="DD MMM YYYY"
utcTimestamp={pauseStatus.last_paused_on}
/>
</p>
)}
</>
) : (
<p className="text-sm">
All of your project's data is still intact, but your project is inaccessible
while paused.{' '}
{product !== undefined ? (
<>
Resume this project to access the{' '}
<span className="text-brand">{product}</span> page.
</>
) : !isRestoreDisabled ? (
'Resume this project and get back to building!'
) : null}
Your project data is safe but inaccessible while paused. Once resumed, usage
will be billed by compute size and hours active.
</p>
) : null}
</div>
)
) : !isLoading ? (
<p className="text-sm">
All of your project's data is still intact, but your project is inaccessible while
paused.{' '}
{product !== undefined ? (
<>
Resume this project to access the{' '}
<span className="text-brand">{product}</span> page.
</>
) : !isRestoreDisabled ? (
'Resume this project and get back to building!'
) : null}
</p>
) : null}
</div>
</div>
</CardContent>
{isError && (
<AlertError
className="rounded-none border-0"
error={pauseStatusError}
subject="Failed to retrieve pause status"
/>
)}
{isPauseStatusSuccess && !isRestoreDisabled && (
<CardFooter className="flex flex-wrap justify-end items-center gap-2">
<ButtonTooltip
size="tiny"
type="default"
disabled={!canResumeProject}
onClick={onSelectRestore}
tooltip={{
content: {
side: 'bottom',
text: !canResumeProject
? 'You need additional permissions to resume this project'
: undefined,
},
}}
>
Resume project
</ButtonTooltip>
{isFreePlan ? (
<Button asChild type="primary">
<Link
href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=projectPausedStateRestore`}
>
Upgrade to Pro
</Link>
</Button>
) : (
<Button asChild type="default">
<Link href={`/project/${ref}/settings/general`}>View project settings</Link>
</Button>
)}
</CardFooter>
)}
{isPauseStatusSuccess && isRestoreDisabled && <PauseDisabledState />}
</Card>
<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>
</CardContent>
<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
{isError && (
<AlertError
className="rounded-none border-0"
error={pauseStatusError}
subject="Failed to retrieve pause status"
/>
)}
{isPauseStatusSuccess && !isRestoreDisabled && (
<CardFooter className="flex flex-wrap justify-end items-center gap-2">
<ResumeProjectButton size="tiny" type="default" />
{isFreePlan ? (
<UpgradePlanButton source="projectPausedStateRestore" />
) : (
<Button asChild type="default">
<Link href={`/project/${ref}/settings/general`}>View project settings</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</CardFooter>
)}
{isPauseStatusSuccess && isRestoreDisabled && <PauseDisabledState />}
</Card>
)
}
@@ -97,7 +97,8 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) {
: []),
{
id: 'nav-project-settings-restart-project',
name: 'Restart project',
name: 'Project availability',
value: 'project availability restart project pause project resume project',
route: `/project/${ref}/settings/general#restart-project`,
defaultHidden: true,
},