Files
supabase/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx
Saxon Fletcher d2a3162bf1 Add high availability project creation controls (#48375)
## Summary

- Move High Availability into the standard project creation settings
above Compute, gated by the `instances.high_availability` entitlement.
- Mark the option as Alpha and explain that it is free during Alpha for
up to two projects.
- Enforce the supported HA configuration: `AWS_K8S`, Postgres 17 on the
`ga` release channel (no custom version is sent — the API resolves the
image), and the environment-specific local/staging region restrictions.
- Show eligible locations in a dedicated **High Availability Regions**
group.
- Preserve the existing Advanced Configuration availability rules and
additionally hide the section while HA is enabled.
- Restore the previous provider and Postgres settings when HA is
switched off.

## How to test
1. Go to create a new project
2. Ensure you have access to high availability (e.g. on local)
3. Toggle high availability on and note how the project form restricts
settings listed above

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

* **New Features**
* Added High Availability to project creation with Alpha warning
labeling and improved switch accessibility.
* Constrains region selection to compatible High Availability regions
and enforces HA-specific engine/release settings.
* Disables/hides custom PostgreSQL version selection when High
Availability is enabled (and omits HA custom request payloads).

* **Bug Fixes**
* Improved persistence of selected PostgreSQL version and region across
data reloads and configuration panel reopen/toggle.
* Restores region when form state temporarily drops values during
remounts.

* **Tests**
* Expanded end-to-end coverage for HA UI, region grouping, and
submit/payload restoration behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-07-30 16:36:54 +08:00

266 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useRef, 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' | 'variant'
> & {
label?: string
}
export const ResumeProjectButton = ({
className,
label = 'Resume project',
size,
variant = 'default',
}: ResumeProjectButtonProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { setProjectStatus } = useSetProjectStatus()
const newProjectInternalOnlyConfiguration = useFlag('newProjectInternalOnlyConfiguration')
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 lastValidPostgresVersionSelection = useRef('')
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 (!newProjectInternalOnlyConfiguration) {
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}
variant={variant}
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(newProjectInternalOnlyConfiguration && 'flex flex-col gap-y-4')}>
<p className="text-sm">
{isFreePlan
? 'Your projects data will be restored to when it was initially paused.'
: 'Your projects data will be restored and billing will resume based on compute size and hours active.'}
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onConfirmRestore)}>
{newProjectInternalOnlyConfiguration && (
<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}
lastValidSelectionRef={lastValidPostgresVersionSelection}
/>
)}
/>
</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
type="button"
variant="default"
onClick={() => setShowFreeProjectLimitWarning(false)}
>
Understood
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}