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, },