From 0ddf2006d372ab6edf7ad20fef8f83faf5a8aa63 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 4 Sep 2026 17:08:01 +0800 Subject: [PATCH] [FE-4337] feat(studio): block pause, restore, and add-ons on High Availability projects (#49990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2026-09-04 at 2 03 11 PM **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 ## 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. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../Database/Backups/BackupItem.test.tsx | 69 +++++++ .../Database/Backups/BackupItem.tsx | 29 ++- .../Database/Backups/BackupsList.tsx | 4 +- .../RestoreToNewProject.tsx | 12 ++ .../interfaces/Settings/Addons/Addons.tsx | 57 ++++-- .../Settings/Addons/Addons.utils.test.ts | 50 +++++ .../Settings/Addons/Addons.utils.ts | 18 ++ .../PauseProjectButton.test.tsx | 75 ++++++++ .../Infrastructure/PauseProjectButton.tsx | 12 +- .../HighAvailabilityDisabledSectionNotice.tsx | 4 +- .../[ref]/database/backups/scheduled.tsx | 171 ++++++++++-------- 11 files changed, 396 insertions(+), 105 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Backups/BackupItem.test.tsx create mode 100644 apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.test.tsx diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.test.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.test.tsx new file mode 100644 index 00000000000..9c7d2d89c52 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.test.tsx @@ -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( + + ) + + expect(screen.getByRole('button', { name: 'Restore' })).toBeEnabled() + }) + + it('disables restoring with a tooltip on High Availability projects', async () => { + customRender( + + ) + + 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) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 85ca7e62f9f..b5a7c913c16 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -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 (
diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index dadc2ee0820..46075e4e997 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -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)} /> ) diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx index 30319bc6fd3..8b2b38a8b7a 100644 --- a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx +++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx @@ -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(false) const [selectedBackupId, setSelectedBackupId] = useState(null) @@ -133,6 +135,16 @@ export const RestoreToNewProject = () => { ) } + if (isHighAvailability) { + return ( + + ) + } + if (isAwsK8s) { return ( { 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 ( + {isBranch && ( { } meta={
- - {ipv4Enabled ? ( - Enabled - ) : ( - Disabled - )} - + {ipv4Enabled ? ( + Enabled + ) : ( + Disabled + )} + {!canOpenIPv4 && ipv4DisabledReason && ( + + + + + {ipv4DisabledReason} + + )}
} > @@ -384,9 +401,13 @@ export const Addons = () => { )} - - - + {!isHighAvailability && ( + <> + + + + + )}
) diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.utils.test.ts b/apps/studio/components/interfaces/Settings/Addons/Addons.utils.test.ts index 525d3a5f6d4..06bfd537838 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.utils.test.ts @@ -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() }) diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.utils.ts b/apps/studio/components/interfaces/Settings/Addons/Addons.utils.ts index 95729611108..072d32a7494 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.utils.ts +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.utils.ts @@ -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' } diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.test.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.test.tsx new file mode 100644 index 00000000000..0be7102c2fb --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.test.tsx @@ -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() + + expect(screen.getByRole('button', { name: 'Pause project' })).toBeEnabled() + }) + + it('disables pausing with a tooltip on High Availability projects', async () => { + mockUseIsHighAvailability.mockReturnValue(true) + customRender() + + 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) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 923ccf3c7d0..c449a2ae13b 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -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) { diff --git a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx index 28a928bcd9c..09e7ec47e44 100644 --- a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx +++ b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx @@ -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 ( - +

{description}

) diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index 547e0018607..1f3fa15292e 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -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 ( <> @@ -61,62 +44,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { - {isOrioleDbInAws ? ( - - - - ) : ( -
- {isLoading && } - - {isError && ( - - )} - - {isSuccess && ( - <> - {!isPitrEnabled && ( -

- Projects are backed up daily around midnight of your project’s region and - can be restored at any time. -

- )} - - {isPitrEnabled && ( - } - title="Point-In-Time-Recovery (PITR) enabled" - description={ -
- 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.{' '} - - Learn more - -
- } - /> - )} - - {isPermissionsLoaded && !canReadScheduledBackups ? ( - - ) : ( - - )} - - )} -
- )} +
@@ -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 ( + + + + ) + } + + if (isHighAvailability) { + return ( + + ) + } + + return ( +
+ {isLoading && } + + {isError && } + + {isSuccess && ( + <> + {!isPitrEnabled && ( +

+ Projects are backed up daily around midnight of your project’s region and can be + restored at any time. +

+ )} + + {isPitrEnabled && ( + } + title="Point-In-Time-Recovery (PITR) enabled" + description={ +
+ 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.{' '} + + Learn more + +
+ } + /> + )} + + {isPermissionsLoaded && !canReadScheduledBackups ? ( + + ) : ( + + )} + + )} +
+ ) +} + DatabaseScheduledBackups.getLayout = (page) => ( {page}