[FE-4337] feat(studio): block pause, restore, and add-ons on High Availability projects (#49990)

Studio-side guard for Multigres (`high_availability`) projects,
mirroring the platform API guard from supabase/platform#37527. Pause,
restore/PITR, and add-on affordances now show a clear "unavailable on
High Availability projects" state instead of failing with a 400 after
the click.

<img width="1195" height="632" alt="Screenshot 2026-09-04 at 2 03 11 PM"
src="https://github.com/user-attachments/assets/718c09f2-d92b-49dc-90ed-5d9ff810b03d"
/>

**Added:**
- Pause project button is disabled on HA projects with a tooltip
- Scheduled backups tab short-circuits to an HA empty state (matches the
existing PITR tab). Per-row Restore buttons are also disabled with a
tooltip as defense in depth, since BackupItem is reusable
- Restore to new project shows an HA admonition ahead of the permission
/ PG15 / physical-backup checks
- Add-ons page shows a page-level HA notice, all three rows are locked
with a tooltip, and the side panels are not mounted on HA so
`?panel=pitr|ipv4|customDomain` deep links are inert
- Component tests for `PauseProjectButton` and `BackupItem`, plus unit
tests for the new `isHighAvailability` branch in `Addons.utils.ts`

**Changed:**
- Add-ons rows are now consistent: the IPv4 row uses the same padlock
tooltip as PITR and custom domain instead of a tooltip on the badge.
Same disabled-reason strings as before, just surfaced via the padlock on
non-HA projects too
- `BackupItem` tooltip text extracted into a `getTooltipText()` function
(mirrors `PauseProjectButton`)
- `HighAvailabilityDisabledSectionNotice` accepts a `className`

Detection reuses the existing `useIsHighAvailability()` hook, which the
rest of Studio already treats as the Multigres signal.

## To test

Use an HA project (`project.high_availability === true`) and a normal
project.

HA project:
- Settings > General: "Pause project" is disabled, tooltip reads
"Pausing is unavailable on High Availability projects"
- Database > Backups > Scheduled backups: HA empty state, no "No backups
yet" / daily backup copy
- Database > Backups > Restore to new project: HA admonition, no restore
controls
- Settings > Add-ons: notice at the top, padlock on all three rows with
per-row tooltip, clicking rows does nothing, and `?panel=pitr` /
`?panel=ipv4` / `?panel=customDomain` open nothing

Normal project (regression):
- No "High Availability" strings on any of the above pages
- Pause button enabled (or disabled only for its usual reasons, e.g.
paid plan)
- Add-on rows open their side panels on click and via `?panel=pitr`
- Scheduled backups tab shows its normal list / empty state


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

## Summary by CodeRabbit

- **Features**
- High Availability projects now clearly indicate when scheduled
backups, backup restoration, project pausing, IPv4, PITR, and custom
domains are unavailable.
- Added explanatory notices, disabled controls, and tooltips throughout
affected settings and backup screens.
- Restore-to-new-project workflows now provide guidance to contact
support when unavailable.

- **Bug Fixes**
- Improved consistency of availability messaging across High
Availability project settings and database backup actions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-09-04 17:08:01 +08:00
1 parent cafa1eb7e6
commit 0ddf2006d3
11 files changed
+396 -105

No files matched your search

@@ -0,0 +1,69 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BackupItem } from './BackupItem'
import type { DatabaseBackup } from '@/data/database/backups-query'
import { customRender } from '@/tests/lib/custom-render'
const { mockUseAsyncCheckPermissions } = vi.hoisted(() => ({
mockUseAsyncCheckPermissions: vi.fn(),
}))
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
}))
const backup: DatabaseBackup = {
id: 1,
inserted_at: '2024-01-01T00:00:00Z',
isPhysicalBackup: false,
project_id: 1,
status: 'COMPLETED',
}
describe('BackupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAsyncCheckPermissions.mockReturnValue({ can: true })
})
it('enables restoring for a healthy project that is not High Availability', () => {
customRender(
<BackupItem
index={0}
isHealthy={true}
isHighAvailability={false}
backup={backup}
onSelectBackup={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: 'Restore' })).toBeEnabled()
})
it('disables restoring with a tooltip on High Availability projects', async () => {
customRender(
<BackupItem
index={0}
isHealthy={true}
isHighAvailability={true}
backup={backup}
onSelectBackup={vi.fn()}
/>
)
const button = screen.getByRole('button', { name: 'Restore' })
expect(button).toBeDisabled()
// Radix opens the tooltip on pointermove; userEvent does not synthesize
// pointer events on disabled buttons
fireEvent.pointerMove(button)
expect(
await screen.findAllByText(
'Restoring from a backup is unavailable on High Availability projects',
{},
{ timeout: 2000 }
)
).not.toHaveLength(0)
})
})
@@ -13,11 +13,18 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
interface BackupItemProps {
index: number
isHealthy: boolean
isHighAvailability: boolean
backup: DatabaseBackup
onSelectBackup: () => void
}
export const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProps) => {
export const BackupItem = ({
index,
isHealthy,
isHighAvailability,
backup,
onSelectBackup,
}: BackupItemProps) => {
const { ref: projectRef } = useParams()
const { can: canTriggerScheduledBackups } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
@@ -37,22 +44,30 @@ export const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupI
},
})
function getTooltipText() {
if (isHighAvailability) {
return 'Restoring from a backup is unavailable on High Availability projects'
} else if (!isHealthy) {
return 'Cannot be restored as project is not active'
} else if (!canTriggerScheduledBackups) {
return 'You need additional permissions to trigger a restore'
} else {
return undefined
}
}
const generateSideButtons = (backup: DatabaseBackup) => {
if (backup.status === 'COMPLETED')
return (
<div className="flex space-x-4">
<ButtonTooltip
variant="default"
disabled={!isHealthy || !canTriggerScheduledBackups}
disabled={!isHealthy || !canTriggerScheduledBackups || isHighAvailability}
onClick={onSelectBackup}
tooltip={{
content: {
side: 'bottom',
text: !isHealthy
? 'Cannot be restored as project is not active'
: !canTriggerScheduledBackups
? 'You need additional permissions to trigger a restore'
: undefined,
text: getTooltipText(),
},
}}
>
@@ -17,7 +17,7 @@ import { useBackupRestoreMutation } from '@/data/database/backup-restore-mutatio
import { DatabaseBackup, useBackupsQuery } from '@/data/database/backups-query'
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsHighAvailability, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
export const BackupsList = () => {
@@ -29,6 +29,7 @@ export const BackupsList = () => {
const { setProjectStatus } = useSetProjectStatus()
const { data: selectedProject } = useSelectedProjectQuery()
const isHealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isHighAvailability = useIsHighAvailability()
const { data: backups } = useBackupsQuery({ projectRef })
const {
@@ -88,6 +89,7 @@ export const BackupsList = () => {
backup={x}
index={i}
isHealthy={isHealthy}
isHighAvailability={isHighAvailability}
onSelectBackup={() => setSelectedBackup(x)}
/>
)
@@ -28,6 +28,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import {
useIsAwsK8sCloudProvider,
useIsHighAvailability,
useIsOrioleDb,
useSelectedProjectQuery,
} from '@/hooks/misc/useSelectedProject'
@@ -41,6 +42,7 @@ export const RestoreToNewProject = () => {
useCheckEntitlements('backup.restore_to_new_project')
const isOrioleDb = useIsOrioleDb()
const isAwsK8s = useIsAwsK8sCloudProvider()
const isHighAvailability = useIsHighAvailability()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
@@ -133,6 +135,16 @@ export const RestoreToNewProject = () => {
)
}
if (isHighAvailability) {
return (
<Admonition
type="default"
title="Restoring to a new project is unavailable on High Availability projects"
description="We're working to bring restores to High Availability projects. Contact support if this is blocking your work."
/>
)
}
if (isAwsK8s) {
return (
<Admonition
@@ -30,9 +30,9 @@ import {
getAddons,
subscriptionHasHipaaAddon,
} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { ProjectUpdateDisabledTooltip } from '@/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
import { AlertError } from '@/components/ui/AlertError'
import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice'
import { InlineLink } from '@/components/ui/InlineLink'
import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
import { ResourceList } from '@/components/ui/Resource/ResourceList'
@@ -44,6 +44,7 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import {
useIsAwsCloudProvider,
useIsHighAvailability,
useIsOrioleDbInAws,
useIsProjectActive,
useSelectedProjectQuery,
@@ -59,6 +60,7 @@ export const Addons = () => {
const isAws = useIsAwsCloudProvider()
const isProjectActive = useIsProjectActive()
const isOrioleDbInAws = useIsOrioleDbInAws() === true
const isHighAvailability = useIsHighAvailability()
const { projectSettingsCustomDomains, projectAddonsDedicatedIpv4Address } = useIsFeatureEnabled([
'project_settings:custom_domains',
@@ -100,14 +102,19 @@ export const Addons = () => {
const customDomainEnabled = customDomain !== undefined
const canOpenIPv4 =
isAws && isProjectActive && !projectUpdateDisabled && (canUpdateIPv4 || ipv4Enabled)
isAws &&
isProjectActive &&
!projectUpdateDisabled &&
(canUpdateIPv4 || ipv4Enabled) &&
!isHighAvailability
const canOpenPITR =
isProjectActive &&
!projectUpdateDisabled &&
sufficientPgVersion &&
!hasHipaaAddon &&
!isOrioleDbInAws
const canOpenCustomDomain = isProjectActive && !projectUpdateDisabled
!isOrioleDbInAws &&
!isHighAvailability
const canOpenCustomDomain = isProjectActive && !projectUpdateDisabled && !isHighAvailability
const ipv4DisabledReason = getIPv4DisabledReason({
isAws,
@@ -115,6 +122,7 @@ export const Addons = () => {
projectUpdateDisabled,
canUpdateIPv4,
ipv4Enabled,
isHighAvailability,
})
const pitrDisabledReason = getPitrDisabledReason({
@@ -123,11 +131,13 @@ export const Addons = () => {
hasHipaaAddon,
sufficientPgVersion,
isOrioleDbInAws,
isHighAvailability,
})
const customDomainDisabledReason = getCustomDomainDisabledReason({
isProjectActive,
projectUpdateDisabled,
isHighAvailability,
})
const pitrAlertState = getPitrAlertState({
hasHipaaAddon,
@@ -190,6 +200,11 @@ export const Addons = () => {
return (
<PageContainer size="default">
<PageSection className="last:pb-0 gap-0">
<HighAvailabilityDisabledSectionNotice
className="mb-4"
title="Add-ons unavailable on High Availability projects"
description="We're working to bring add-ons to High Availability projects. Contact support if this is blocking your work."
/>
{isBranch && (
<Admonition
type="default"
@@ -246,17 +261,19 @@ export const Addons = () => {
}
meta={
<div className="flex items-center gap-4">
<ProjectUpdateDisabledTooltip
projectUpdateDisabled={projectUpdateDisabled}
projectNotActive={!isProjectActive}
tooltip={ipv4DisabledReason}
>
{ipv4Enabled ? (
<Badge variant="success">Enabled</Badge>
) : (
<Badge variant="default">Disabled</Badge>
)}
</ProjectUpdateDisabledTooltip>
{ipv4Enabled ? (
<Badge variant="success">Enabled</Badge>
) : (
<Badge variant="default">Disabled</Badge>
)}
{!canOpenIPv4 && ipv4DisabledReason && (
<Tooltip>
<TooltipTrigger>
<Lock strokeWidth={1.5} className="text-foreground-light" size={16} />
</TooltipTrigger>
<TooltipContent>{ipv4DisabledReason}</TooltipContent>
</Tooltip>
)}
</div>
}
>
@@ -384,9 +401,13 @@ export const Addons = () => {
</ResourceList>
)}
<PITRSidePanel />
<CustomDomainSidePanel />
<IPv4SidePanel />
{!isHighAvailability && (
<>
<PITRSidePanel />
<CustomDomainSidePanel />
<IPv4SidePanel />
</>
)}
</PageSection>
</PageContainer>
)
@@ -8,6 +8,19 @@ import {
} from './Addons.utils'
describe('getIPv4DisabledReason', () => {
it('returns the High Availability message before other checks', () => {
expect(
getIPv4DisabledReason({
isAws: false,
isProjectActive: false,
projectUpdateDisabled: true,
canUpdateIPv4: false,
ipv4Enabled: false,
isHighAvailability: true,
})
).toBe('Dedicated IPv4 address is unavailable on High Availability projects')
})
it('returns the AWS-only message for non-AWS projects', () => {
expect(
getIPv4DisabledReason({
@@ -16,6 +29,7 @@ describe('getIPv4DisabledReason', () => {
projectUpdateDisabled: false,
canUpdateIPv4: true,
ipv4Enabled: false,
isHighAvailability: false,
})
).toBe('Dedicated IPv4 address is only available for AWS projects')
})
@@ -28,6 +42,7 @@ describe('getIPv4DisabledReason', () => {
projectUpdateDisabled: true,
canUpdateIPv4: false,
ipv4Enabled: false,
isHighAvailability: false,
})
).toBe('Project must be active to update IPv4')
})
@@ -40,6 +55,7 @@ describe('getIPv4DisabledReason', () => {
projectUpdateDisabled: true,
canUpdateIPv4: true,
ipv4Enabled: false,
isHighAvailability: false,
})
).toBe('Project updates are currently disabled')
})
@@ -52,6 +68,7 @@ describe('getIPv4DisabledReason', () => {
projectUpdateDisabled: false,
canUpdateIPv4: false,
ipv4Enabled: false,
isHighAvailability: false,
})
).toBe('You can only add IPv4 when your project network configuration is set to IPv6')
})
@@ -64,12 +81,26 @@ describe('getIPv4DisabledReason', () => {
projectUpdateDisabled: false,
canUpdateIPv4: false,
ipv4Enabled: true,
isHighAvailability: false,
})
).toBeUndefined()
})
})
describe('getPitrDisabledReason', () => {
it('returns the High Availability message before other checks', () => {
expect(
getPitrDisabledReason({
isProjectActive: false,
projectUpdateDisabled: true,
hasHipaaAddon: true,
sufficientPgVersion: false,
isOrioleDbInAws: true,
isHighAvailability: true,
})
).toBe('Point in time recovery is unavailable on High Availability projects')
})
it('returns the inactive-project message first', () => {
expect(
getPitrDisabledReason({
@@ -78,6 +109,7 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: true,
sufficientPgVersion: false,
isOrioleDbInAws: true,
isHighAvailability: false,
})
).toBe('Project must be active to update PITR')
})
@@ -90,6 +122,7 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: true,
sufficientPgVersion: false,
isOrioleDbInAws: true,
isHighAvailability: false,
})
).toBe('Project updates are currently disabled')
})
@@ -102,6 +135,7 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: true,
sufficientPgVersion: true,
isOrioleDbInAws: false,
isHighAvailability: false,
})
).toBe('PITR cannot be changed with HIPAA enabled')
})
@@ -114,6 +148,7 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: false,
sufficientPgVersion: false,
isOrioleDbInAws: false,
isHighAvailability: false,
})
).toBe('Your project is too old to enable PITR')
})
@@ -126,6 +161,7 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: false,
sufficientPgVersion: true,
isOrioleDbInAws: true,
isHighAvailability: false,
})
).toBe('Point in time recovery is not supported with OrioleDB')
})
@@ -138,17 +174,29 @@ describe('getPitrDisabledReason', () => {
hasHipaaAddon: false,
sufficientPgVersion: true,
isOrioleDbInAws: false,
isHighAvailability: false,
})
).toBeUndefined()
})
})
describe('getCustomDomainDisabledReason', () => {
it('returns the High Availability message before other checks', () => {
expect(
getCustomDomainDisabledReason({
isProjectActive: false,
projectUpdateDisabled: true,
isHighAvailability: true,
})
).toBe('Custom domain is unavailable on High Availability projects')
})
it('returns the inactive-project message', () => {
expect(
getCustomDomainDisabledReason({
isProjectActive: false,
projectUpdateDisabled: true,
isHighAvailability: false,
})
).toBe('Project must be active to update custom domain')
})
@@ -158,6 +206,7 @@ describe('getCustomDomainDisabledReason', () => {
getCustomDomainDisabledReason({
isProjectActive: true,
projectUpdateDisabled: true,
isHighAvailability: false,
})
).toBe('Project updates are currently disabled')
})
@@ -167,6 +216,7 @@ describe('getCustomDomainDisabledReason', () => {
getCustomDomainDisabledReason({
isProjectActive: true,
projectUpdateDisabled: false,
isHighAvailability: false,
})
).toBeUndefined()
})
@@ -4,6 +4,7 @@ export interface IPv4DisabledReasonOptions {
projectUpdateDisabled: boolean
canUpdateIPv4: boolean
ipv4Enabled: boolean
isHighAvailability: boolean
}
export const getIPv4DisabledReason = ({
@@ -12,7 +13,12 @@ export const getIPv4DisabledReason = ({
projectUpdateDisabled,
canUpdateIPv4,
ipv4Enabled,
isHighAvailability,
}: IPv4DisabledReasonOptions) => {
if (isHighAvailability) {
return 'Dedicated IPv4 address is unavailable on High Availability projects'
}
if (!isAws) {
return 'Dedicated IPv4 address is only available for AWS projects'
}
@@ -38,6 +44,7 @@ export interface PitrDisabledReasonOptions {
hasHipaaAddon: boolean
sufficientPgVersion: boolean
isOrioleDbInAws: boolean
isHighAvailability: boolean
}
export const getPitrDisabledReason = ({
@@ -46,7 +53,12 @@ export const getPitrDisabledReason = ({
hasHipaaAddon,
sufficientPgVersion,
isOrioleDbInAws,
isHighAvailability,
}: PitrDisabledReasonOptions) => {
if (isHighAvailability) {
return 'Point in time recovery is unavailable on High Availability projects'
}
if (!isProjectActive) {
return 'Project must be active to update PITR'
}
@@ -73,12 +85,18 @@ export const getPitrDisabledReason = ({
export interface CustomDomainDisabledReasonOptions {
isProjectActive: boolean
projectUpdateDisabled: boolean
isHighAvailability: boolean
}
export const getCustomDomainDisabledReason = ({
isProjectActive,
projectUpdateDisabled,
isHighAvailability,
}: CustomDomainDisabledReasonOptions) => {
if (isHighAvailability) {
return 'Custom domain is unavailable on High Availability projects'
}
if (!isProjectActive) {
return 'Project must be active to update custom domain'
}
@@ -0,0 +1,75 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PauseProjectButton } from './PauseProjectButton'
import { customRender } from '@/tests/lib/custom-render'
const {
mockUseAsyncCheckPermissions,
mockUseCheckEntitlements,
mockUseIsHighAvailability,
mockUseSelectedOrganizationQuery,
mockUseSelectedProjectQuery,
} = vi.hoisted(() => ({
mockUseAsyncCheckPermissions: vi.fn(),
mockUseCheckEntitlements: vi.fn(),
mockUseIsHighAvailability: vi.fn(),
mockUseSelectedOrganizationQuery: vi.fn(),
mockUseSelectedProjectQuery: vi.fn(),
}))
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
useCheckEntitlements: mockUseCheckEntitlements,
}))
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery,
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useIsHighAvailability: mockUseIsHighAvailability,
useIsProjectActive: () => true,
useSelectedProjectQuery: mockUseSelectedProjectQuery,
}))
describe('PauseProjectButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAsyncCheckPermissions.mockReturnValue({ can: true })
mockUseCheckEntitlements.mockReturnValue({ hasAccess: true })
mockUseSelectedOrganizationQuery.mockReturnValue({ data: { plan: { id: 'free' } } })
mockUseSelectedProjectQuery.mockReturnValue({
data: { ref: 'default', status: 'ACTIVE_HEALTHY' },
})
mockUseIsHighAvailability.mockReturnValue(false)
})
it('enables pausing for an active project that is not High Availability', () => {
customRender(<PauseProjectButton />)
expect(screen.getByRole('button', { name: 'Pause project' })).toBeEnabled()
})
it('disables pausing with a tooltip on High Availability projects', async () => {
mockUseIsHighAvailability.mockReturnValue(true)
customRender(<PauseProjectButton />)
const button = screen.getByRole('button', { name: 'Pause project' })
expect(button).toBeDisabled()
// Radix opens the tooltip on pointermove; userEvent does not synthesize
// pointer events on disabled buttons
fireEvent.pointerMove(button)
expect(
await screen.findAllByText(
'Pausing is unavailable on High Availability projects',
{},
{ timeout: 2000 }
)
).not.toHaveLength(0)
})
})
@@ -20,7 +20,11 @@ import { useProjectPauseMutation } from '@/data/projects/project-pause-mutation'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useIsProjectActive, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import {
useIsHighAvailability,
useIsProjectActive,
useSelectedProjectQuery,
} from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
export const PauseProjectButton = () => {
@@ -30,6 +34,7 @@ export const PauseProjectButton = () => {
const { setProjectStatus } = useSetProjectStatus()
const isProjectActive = useIsProjectActive()
const isHighAvailability = useIsHighAvailability()
const isProjectUnhealthy = project?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -68,11 +73,14 @@ export const PauseProjectButton = () => {
project === undefined ||
isPaused ||
!canPauseProject ||
!isProjectActive
!isProjectActive ||
isHighAvailability
function getTooltipText() {
if (isPaused) {
return `Your ${entityLabel} is already paused`
} else if (isHighAvailability) {
return 'Pausing is unavailable on High Availability projects'
} else if (!canPauseProject) {
return `You need additional permissions to pause this ${entityLabel}`
} else if (isProjectUnhealthy) {
@@ -10,18 +10,20 @@ const DEFAULT_DESCRIPTION =
interface HighAvailabilityDisabledSectionNoticeProps {
title?: string
description?: ReactNode
className?: string
}
export function HighAvailabilityDisabledSectionNotice({
title = DEFAULT_TITLE,
description = DEFAULT_DESCRIPTION,
className,
}: HighAvailabilityDisabledSectionNoticeProps) {
const isHighAvailability = useIsHighAvailability()
if (!isHighAvailability) return null
return (
<Admonition type="default" title={title}>
<Admonition type="default" title={title} className={className}>
<p className="text-sm text-foreground-light">{description}</p>
</Admonition>
)
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { Info } from 'lucide-react'
import { DatabaseBackup, Info } from 'lucide-react'
import { Admonition } from 'ui-patterns/Admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -19,33 +19,16 @@ import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
import InformationBox from '@/components/ui/InformationBox'
import { NoPermission } from '@/components/ui/NoPermission'
import { useBackupsQuery } from '@/data/database/backups-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useIsOrioleDbInAws } from '@/hooks/misc/useSelectedProject'
import { useIsHighAvailability, useIsOrioleDbInAws } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
import type { NextPageWithLayout } from '@/types'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
const {
data: backups,
error,
isPending: isLoading,
isError,
isSuccess,
} = useBackupsQuery({ projectRef })
const isOrioleDbInAws = useIsOrioleDbInAws()
const isPitrEnabled = backups?.pitr_enabled
const { can: canReadScheduledBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.READ,
'back_ups'
)
return (
<>
<PageHeader>
@@ -61,62 +44,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
<PageContainer>
<PageSection>
<PageSectionContent>
{isOrioleDbInAws ? (
<Admonition
type="default"
title="Database backups are not available for OrioleDB"
description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups"
>
<DocsButton abbrev={false} className="mt-2" href={`${DOCS_URL}`} />
</Admonition>
) : (
<div className="flex flex-col gap-y-4">
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError error={error} subject="Failed to retrieve scheduled backups" />
)}
{isSuccess && (
<>
{!isPitrEnabled && (
<p className="text-sm text-foreground-light">
Projects are backed up daily around midnight of your project’s region and
can be restored at any time.
</p>
)}
{isPitrEnabled && (
<InformationBox
hideCollapse
defaultVisibility
icon={<Info strokeWidth={2} />}
title="Point-In-Time-Recovery (PITR) enabled"
description={
<div>
Your project uses PITR and full daily backups are no longer taken. PITR
lets you restore to a specific time (down to the second) within your
selected PITR retention period.{' '}
<a
className="text-brand transition-colors hover:text-brand-600"
href={`${DOCS_URL}/guides/platform/backups`}
>
Learn more
</a>
</div>
}
/>
)}
{isPermissionsLoaded && !canReadScheduledBackups ? (
<NoPermission resourceText="view scheduled backups" />
) : (
<BackupsList />
)}
</>
)}
</div>
)}
<ScheduledBackups />
</PageSectionContent>
</PageSection>
</PageContainer>
@@ -124,6 +52,97 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
)
}
const ScheduledBackups = () => {
const { ref: projectRef } = useParams()
const {
data: backups,
error,
isPending: isLoading,
isError,
isSuccess,
} = useBackupsQuery({ projectRef })
const isOrioleDbInAws = useIsOrioleDbInAws()
const isHighAvailability = useIsHighAvailability()
const isPitrEnabled = backups?.pitr_enabled
const { can: canReadScheduledBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.READ,
'back_ups'
)
if (isOrioleDbInAws) {
return (
<Admonition
type="default"
title="Database backups are not available for OrioleDB"
description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups"
>
<DocsButton abbrev={false} className="mt-2" href={`${DOCS_URL}`} />
</Admonition>
)
}
if (isHighAvailability) {
return (
<HighAvailabilityDisabledEmptyState
icon={DatabaseBackup}
title="Scheduled backups unavailable on High Availability projects"
description="We're working to bring scheduled backups to High Availability projects. Contact support if this is blocking your work."
className="max-w-none mx-0"
/>
)
}
return (
<div className="flex flex-col gap-y-4">
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve scheduled backups" />}
{isSuccess && (
<>
{!isPitrEnabled && (
<p className="text-sm text-foreground-light">
Projects are backed up daily around midnight of your project’s region and can be
restored at any time.
</p>
)}
{isPitrEnabled && (
<InformationBox
hideCollapse
defaultVisibility
icon={<Info strokeWidth={2} />}
title="Point-In-Time-Recovery (PITR) enabled"
description={
<div>
Your project uses PITR and full daily backups are no longer taken. PITR lets you
restore to a specific time (down to the second) within your selected PITR
retention period.{' '}
<a
className="text-brand transition-colors hover:text-brand-600"
href={`${DOCS_URL}/guides/platform/backups`}
>
Learn more
</a>
</div>
}
/>
)}
{isPermissionsLoaded && !canReadScheduledBackups ? (
<NoPermission resourceText="view scheduled backups" />
) : (
<BackupsList />
)}
</>
)}
</div>
)
}
DatabaseScheduledBackups.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Backups">{page}</DatabaseLayout>