mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
[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:
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)}
|
||||
/>
|
||||
)
|
||||
|
||||
+12
@@ -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'
|
||||
}
|
||||
|
||||
+75
@@ -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)
|
||||
})
|
||||
})
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user