Files
Danny White 34d6c2fbc5 feat(studio): move read replica creation to a dialog (#49516)
## What kind of change does this PR introduce?

Studio interface improvement.

## What is the current behavior?

Read replica creation uses an oversized sheet, with region selection,
eligibility guidance, and pricing all awkwardly competing for space. The
cost estimate can briefly show a compute-only subtotal while disk
pricing is still loading.

## What is the new behavior?

Read replica creation uses a focused, centred dialog with a vertical
region field, contextual eligibility guidance, and a separate cost
breakdown. Disabled forms omit redundant deployment-location text. The
additional monthly cost appears only after both compute and disk pricing
inputs are available.

| Before | After |
| --- | --- |
| <img width="1024" height="759" alt="Infrastructure Settings Chives
Pantry Supabase"
src="https://github.com/user-attachments/assets/afb0a9a6-3575-4d65-98a3-f21b23a032ac"
/> | <img width="1024" height="759" alt="Infrastructure Settings Chives
Pantry Supabase"
src="https://github.com/user-attachments/assets/89fd96f8-8f95-4b88-b1fd-505a261ff9a0"
/> |
| <img width="1024" height="759" alt="Infrastructure Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/dd110b3a-6aca-4074-9a69-2dc711f4d1c7"
/> | <img width="1024" height="759" alt="Infrastructure Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/894bbd81-01d5-411e-8279-1bd2e5839cff"
/> |
| <img width="1024" height="759" alt="Infrastructure Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/09cbd315-c13d-4987-b274-daf4f91ea10d"
/> | <img width="1024" height="759" alt="Infrastructure Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/a1effb33-1bf2-450a-8cd8-0de8675231c9"
/> |

## To test

- Open `/project/<ref>/settings/infrastructure` and select **Add read
replica** from the section header or empty state. Confirm the dialog
opens and closes using Close, Escape, backdrop, and Cancel.
- On an eligible project, confirm the pricing note initially reads
**Estimated additional cost**, then adds **of $X/month** once pricing
loads. **View breakdown** should remain disabled until then.
- Change the region and open **View breakdown**. Confirm the monthly
cost table has standard row borders and an estimated total.
- On a project below Small compute, confirm the region field is
disabled, its deployment-location description is hidden, and **Change
compute** returns to the compute controls.


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

* **New Features**
* Replaced the add read replica sheet with a dialog-based setup
experience.
* Added region details, eligibility guidance, compute recommendations,
and estimated pricing.
  * Added retry options when pricing information fails to load.

* **UI Improvements**
  * Updated warning messages, documentation links, and action labels.
  * Improved dialog behavior and deferred data loading until opened.

* **Tests**
* Expanded coverage for dialog behavior, eligibility warnings, pricing
errors, retries, and recommendations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-26 15:17:37 +08:00

165 lines
5.6 KiB
TypeScript

import { useParams } from 'common'
import { Database } from 'icons'
import { Plus } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useEffect, useState } from 'react'
import { Button, Card, CardContent, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { AddReadReplicaDialog } from './AddReadReplicaDialog'
import { ReadReplicaRow } from './ReadReplicaRow'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import type { RecommendedComputeForReadReplicas } from './recommendCompute'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { DOCS_URL } from '@/lib/constants'
interface ReadReplicasSectionProps {
onRecommendCompute: (size: RecommendedComputeForReadReplicas) => void
}
export const ReadReplicasSection = ({ onRecommendCompute }: ReadReplicasSectionProps) => {
const { ref: projectRef } = useParams()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
const [isAddReplicaOpen, setAddReplica] = useQueryState(
'addReplica',
parseAsBoolean.withDefault(false).withOptions({
history: 'push',
clearOnDefault: true,
scroll: false,
})
)
const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
const {
data: databases = [],
error: databasesError,
isPending: isDatabasesLoading,
isError: isDatabasesError,
isSuccess: isDatabasesSuccess,
} = useReadReplicasQuery(
{ projectRef },
{
enabled: infrastructureReadReplicas,
refetchInterval: infrastructureReadReplicas ? statusRefetchInterval : false,
}
)
const readReplicas = databases.filter((database) => database.identifier !== projectRef)
const hasReplicas = isDatabasesSuccess && readReplicas.length > 0
useEffect(() => {
if (!isDatabasesSuccess) return
const fixedStatuses = [
REPLICA_STATUS.ACTIVE_HEALTHY,
REPLICA_STATUS.ACTIVE_UNHEALTHY,
REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
]
const replicasInTransition = databases.filter(
(database) => database.identifier !== projectRef && !fixedStatuses.includes(database.status)
)
if (replicasInTransition.length === 0) setStatusRefetchInterval(false)
}, [isDatabasesSuccess, databases, projectRef])
if (!infrastructureReadReplicas) return null
return (
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Read replicas</PageSectionTitle>
<PageSectionDescription>
Scale reads or serve queries closer to users.
</PageSectionDescription>
</PageSectionSummary>
<PageSectionAside>
<DocsButton href={`${DOCS_URL}/guides/platform/read-replicas`} />
<Button
type="button"
variant="primary"
icon={<Plus />}
onClick={() => setAddReplica(true)}
>
Add read replica
</Button>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent className="flex flex-col gap-y-4">
{isDatabasesError && (
<AlertError error={databasesError} subject="Failed to retrieve read replicas" />
)}
{isDatabasesLoading && <GenericSkeletonLoader />}
{isDatabasesSuccess && hasReplicas && (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[20px]" />
<TableHead className="w-[250px]">Name</TableHead>
<TableHead className="w-[150px]">Status</TableHead>
<TableHead className="w-[150px]">Lag</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{readReplicas.map((replica) => (
<ReadReplicaRow
key={replica.identifier}
replica={replica}
onUpdateReplica={() => setStatusRefetchInterval(5000)}
/>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{isDatabasesSuccess && !hasReplicas && (
<EmptyStatePresentational
icon={Database}
title="No read replicas"
description="All reads and writes currently go to the primary."
>
<Button
type="button"
variant="default"
icon={<Plus />}
onClick={() => setAddReplica(true)}
>
Add read replica
</Button>
</EmptyStatePresentational>
)}
</PageSectionContent>
</PageSection>
<AddReadReplicaDialog
open={isAddReplicaOpen}
onOpenChange={setAddReplica}
onSuccess={() => setStatusRefetchInterval(5000)}
onRecommendCompute={onRecommendCompute}
/>
</>
)
}