From 2d92563b577da4c19517641afc944326e47f6bab Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:30:54 +1000 Subject: [PATCH] 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 | | --- | --- | | CleanShot 2026-04-24 at 18 05
25@2x | CleanShot 2026-04-24 at 18 06
12@2x | | CleanShot 2026-04-24 at 18 08
53@2x | CleanShot 2026-04-24 at 18 08
30@2x | | CleanShot 2026-04-24 at 17 41
40@2x | CleanShot 2026-04-24 at 18 10
34@2x | ## 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. --------- Co-authored-by: Joshen Lim --- .../Project/ResumeProjectButton.tsx | 263 ++++++++++++ .../Infrastructure/PauseProjectButton.tsx | 43 +- .../Infrastructure/RestartServerButton.tsx | 22 +- .../Settings/General/Project.test.tsx | 172 ++++++++ .../interfaces/Settings/General/Project.tsx | 86 ++-- .../layouts/AuthLayout/Auth.Commands.tsx | 2 +- .../PausedState/ProjectPausedState.tsx | 395 +++++------------- .../ProjectSettings.Commands.tsx | 3 +- 8 files changed, 639 insertions(+), 347 deletions(-) create mode 100644 apps/studio/components/interfaces/Project/ResumeProjectButton.tsx create mode 100644 apps/studio/components/interfaces/Settings/General/Project.test.tsx diff --git a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx new file mode 100644 index 00000000000..5f316278dd6 --- /dev/null +++ b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx @@ -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, + '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>({ + 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) => { + 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 ( + <> + + {label} + + + setShowConfirmRestore(false)} + onConfirm={() => form.handleSubmit(onConfirmRestore)()} + loading={isRestoring} + confirmLabel="Resume" + confirmLabelLoading="Resuming" + cancelLabel="Cancel" + > +
+

+ {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.'} +

+
+ + {showPostgresVersionSelector && ( +
+ ( + + )} + /> +
+ )} +
+ +
+
+ + setShowFreeProjectLimitWarning(false)} + > + + + + Your organization has members who have exceeded their free project limits + + + +

+ 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: +

+
    + {(membersExceededLimit ?? []).map((member, idx: number) => ( +
  • + {member.username || member.primary_email} (Limit: {member.free_project_limit} free + projects) +
  • + ))} +
+

+ These members will need to either delete, pause, or upgrade one or more of these + projects before you're able to resume this project. +

+
+ + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 76b2c30b283..6379f64b1f7 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -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 - setIsModalOpen(false)} - onConfirm={requestPauseProject} - > -

- Are you sure you want to pause this project? It will not be accessible until you unpause - it. -

-
+ + + + Pause project? + + 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. + + + + Cancel + + {isPausing ? 'Pausing project...' : 'Pause project'} + + + + ) } diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index f6b72a1f691..1b2522dfd76 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -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 ? ( -
+
{ > Restart project - {canRestartProject && canRestart && !projectRestartDisabled && ( + {hasRestartDropdown && ( ), + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('ui-patterns/PageSection', () => ({ + PageSection: ({ children, id }: { children: ReactNode; id?: string }) => ( +
{children}
+ ), + PageSectionContent: ({ children }: { children: ReactNode }) =>
{children}
, + PageSectionDescription: ({ children }: { children: ReactNode }) =>

{children}

, + PageSectionMeta: ({ children }: { children: ReactNode }) =>
{children}
, + PageSectionSummary: ({ children }: { children: ReactNode }) =>
{children}
, + PageSectionTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})) + +vi.mock('@/components/interfaces/Project/ResumeProjectButton', () => ({ + ResumeProjectButton: () =>
ResumeProjectButton
, +})) + +vi.mock('./Infrastructure/PauseProjectButton', () => ({ + default: () =>
PauseProjectButton
, +})) + +vi.mock('./Infrastructure/RestartServerButton', () => ({ + default: () =>
RestartServerButton
, +})) + +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() + + 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() + + 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() + + 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() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/General/Project.tsx b/apps/studio/components/interfaces/Settings/General/Project.tsx index 26d40787504..80d94d6352f 100644 --- a/apps/studio/components/interfaces/Settings/General/Project.tsx +++ b/apps/studio/components/interfaces/Settings/General/Project.tsx @@ -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 = () => { Project availability - 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'} @@ -40,34 +70,42 @@ export const Project = () => {
-

- {projectSettingsRestartProject ? 'Restart project' : 'Restart database'} -

+

{primaryActionLabel}

-

- Your project will not be available for a few minutes. -

+

{primaryActionDescription}

- + {isPaused ? ( + shouldShowDashboardLink ? ( + + ) : ( + + ) + ) : ( + + )}
- -
-
-

Pause project

-
-

- Your project will not be accessible while it is paused. -

+ {!isPaused && ( + +
+
+

Pause project

+
+

+ Your project will not be accessible while it is paused. +

+
+
- -
- + + )} @@ -86,9 +124,9 @@ export const Project = () => {
-

Project usage statistics have been moved

+

Project usage statistics have moved

- You may view your project's usage under your organization's settings + Project usage is now viewable within organization settings.

diff --git a/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx b/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx index aee72616b57..1d22aaf6203 100644 --- a/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx +++ b/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx @@ -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`, diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 81819d355f1..ace6fffec13 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -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) => { - 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>({ - resolver: zodResolver(FormSchema), - mode: 'onChange', - defaultValues: { postgresVersionSelection: '' }, - }) - return ( - <> - - - -
-
-

The project "{project?.name}" is currently paused

-
- {isLoading && } + + + +
+
+

The project "{project?.name}" is currently paused

+
+ {isLoading && } - {isPauseStatusSuccess && !isRestoreDisabled ? ( - isFreePlan ? ( - <> -

- All data, including backups and storage objects, remains safe. You can - resume this project from the dashboard within{' '} - - - - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - {' '} - - - Free projects cannot be restored through the dashboard if they are - paused for more than {pauseStatus.max_days_till_restore_disabled} days - - {' '} - (until{' '} - - ). After that, this project will not be resumable, but data will still be - available for download. -

-

- {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.'} -

- {!!pauseStatus.last_paused_on && ( -

- Project last paused on{' '} - -

- )} - - ) : ( + {isPauseStatusSuccess && !isRestoreDisabled ? ( + isFreePlan ? ( + <>

- 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{' '} + + + + {finalDaysRemainingBeforeRestoreDisabled} day + {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} + {' '} + + + Free projects cannot be restored through the dashboard if they are paused + for more than {pauseStatus.max_days_till_restore_disabled} days + + {' '} + (until{' '} + + ). After that, this project will not be resumable, but data will still be + available for download.

- ) - ) : !isLoading ? ( +

+ {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.'} +

+ {!!pauseStatus.last_paused_on && ( +

+ Project last paused on{' '} + +

+ )} + + ) : (

- All of your project's data is still intact, but your project is inaccessible - while paused.{' '} - {product !== undefined ? ( - <> - Resume this project to access the{' '} - {product} 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.

- ) : null} -
+ ) + ) : !isLoading ? ( +

+ All of your project's data is still intact, but your project is inaccessible while + paused.{' '} + {product !== undefined ? ( + <> + Resume this project to access the{' '} + {product} page. + + ) : !isRestoreDisabled ? ( + 'Resume this project and get back to building!' + ) : null} +

+ ) : null}
-
- - {isError && ( - - )} - - {isPauseStatusSuccess && !isRestoreDisabled && ( - - - Resume project - - - {isFreePlan ? ( - - ) : ( - - )} - - )} - - {isPauseStatusSuccess && isRestoreDisabled && } -
- - setShowConfirmRestore(false)} - onConfirm={() => form.handleSubmit(onConfirmRestore)()} - loading={isRestoring} - confirmLabel="Resume" - confirmLabelLoading="Resuming" - cancelLabel="Cancel" - > -
-

- {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.'} -

-
- - {showPostgresVersionSelector && ( -
- ( - - )} - /> -
- )} -
-
-
+ - setShowFreeProjectLimitWarning(false)} - > - - - - Your organization has members who have exceeded their free project limits - - - -

- 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: -

-
    - {(membersExceededLimit || []).map((member, idx: number) => ( -
  • - {member.username || member.primary_email} (Limit: {member.free_project_limit} free - projects) -
  • - ))} -
-

- These members will need to either delete, pause, or upgrade one or more of these - projects before you're able to resume this project. -

-
- - - -
-
- + )} + + )} + + {isPauseStatusSuccess && isRestoreDisabled && } + ) } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx index 0bb24b87742..7a638bfbe79 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx @@ -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, },