Files
supabase/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx
Alaister YoungandAlaister Young 29493e02d0 [FE-4010] feat(studio): add read-only replica connection option for HA projects (#49485)
For Multigres (HA) projects you can't connect to read replicas directly
— reads go through a read-only load balancer on the primary's host at
port 5433. Since #44695 stripped the pooler UI, HA projects showed no
source option at all in the Connect dialog and still prompted for the
IPv4 add-on. This surfaces it as a first-class, clearly-labeled
read-only source. In the UI it's labeled `Replica (read-only)` rather
than "load balancer" — the primary goes through the same gateway, so
"load balancer" would be confusing from a product perspective
(internally the `load-balancer` source identifier and
`HIGH_AVAILABILITY_LOAD_BALANCER_PORT` constant keep their names).

<img width="883" height="342" alt="Screenshot 2026-08-24 at 11 32 26 PM"
src="https://github.com/user-attachments/assets/3716f6dd-0325-4b9d-adbc-9ece9244de62"
/>

**Added:**
- Source select for HA projects in the Direct tab: `Primary database` +
`Replica (read-only)` (individual replica rows are filtered out —
they're only reachable via the load balancer)
- Replica (load balancer) connection strings on all 9 connection types:
primary host, port `5433`, with the Multigres-required
`sslmode=require&sslnegotiation=direct` params (JDBC gets the
`sslNegotiation` spelling, .NET gets `SSL Negotiation=Direct`)
- `Read-only` badge on the connection code block + note pointing writes
at the primary
- Programmatic labels for the ConnectSheet select/switch/multi-select
fields (the Source combobox previously had no accessible name)

**Changed:**
- The generated-file step (Node.js/Golang/.NET/Python/SQLAlchemy) is now
source-aware — it previously ignored the Source selection entirely (also
affected read replicas on normal projects) and silently rendered the
primary's connection info
- .NET template now emits `Port=` (Npgsql defaults to 5432 when omitted)
and the install step actually installs Npgsql (pinned 9.0.5 — `SSL
Negotiation` requires 9+)
- SQLAlchemy `DATABASE_URL` merges `sslmode=require` into the string's
existing query params instead of a hardcoded suffix that could drop TLS
- Source option labels normalized to sentence case (`Primary database`,
`Read replica (…)`)
- `MultipleCodeBlock` (ui-patterns) accepts an optional `className`
- HA coercion in `useConnectState` extended: a stale replica
`connectionSource` restored from URL/localStorage falls back to the
primary

**Removed:**
- IPv4 add-on admonition for HA projects (the forced-direct method was
tripping it; the add-on doesn't apply to Multigres)

Out of scope (needs platform work): SQL editor / Data API / other
`DatabaseSelector` surfaces — executing against the load balancer
requires a platform-issued connection string, and the load-balancers API
only returns a REST endpoint today. The `5433` port is a client-side
constant (`HIGH_AVAILABILITY_LOAD_BALANCER_PORT`) until the API exposes
it.

## To test

On an HA (Multigres) project:
- Open Connect → Direct: Source shows exactly `Primary database` and
`Replica (read-only)`; selecting the replica shows
`…@<primary-host>:5433/postgres?sslmode=require&sslnegotiation=direct`,
a `Read-only` badge, and the read-only note
- Cycle all 9 connection types with the replica selected — every snippet
carries port 5433 (`.NET` includes `Port=5433;…;SSL
Negotiation=Direct`), badge/note persist
- No "Enable IPv4 add-on" admonition anywhere in the Direct tab
- Switch tabs / hard-reload: source resets to primary with no stale
badge/string combos

On a normal project:
- Direct tab unchanged: no `Replica (read-only)` option, pooler badges
and IPv4 admonitions behave as before, `.NET` now shows `Port=5432` and
no `SSL Negotiation`

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

- **New Features**
- Added read-only load-balancer connection options for high-availability
projects.
- Added .NET and SQLAlchemy connection examples with required SSL
settings.
- Added clear read-only labels and notices explaining write
restrictions.
- **Bug Fixes**
  - Suppressed IPv4 add-on notices for high-availability connections.
  - Improved connection-source selection and restored-setting handling.
  - Improved connection form identification and accessibility.
- **Style**
  - Added customizable styling support for multi-code-block displays.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-28 10:43:55 +01:00

283 lines
9.7 KiB
TypeScript

import {
cn,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
import { ConnectionIcon } from './ConnectionIcon'
import {
ConnectModeButton,
getConnectModeButtonCornerVariants,
getConnectModeEmptySlotClasses,
} from './ConnectModeButton'
interface ConnectConfigSectionProps {
activeFields: ResolvedField[]
state: Record<string, string | boolean | string[]>
onFieldChange: (fieldId: string, value: string | boolean | string[]) => void
getFieldOptions: (fieldId: string) => FieldOption[]
}
export function ConnectConfigSection({
activeFields,
state,
onFieldChange,
getFieldOptions,
}: ConnectConfigSectionProps) {
if (activeFields.length === 0) return null
return (
<div className="flex flex-col gap-y-4">
{activeFields.map((field) => {
const options = getFieldOptions(field.id)
const value = state[field.id]
// Skip fields with no options (or single option that's auto-selected)
// Exception: switch and multi-select fields don't require options
if (field.type !== 'switch' && field.type !== 'multi-select') {
if (options.length === 0) return null
if (options.length === 1) return null
}
switch (field.type) {
case 'radio-grid':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="flex-row gap-3 space-y-0"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
label=""
className="flex-1 rounded-lg text-left"
>
<div className="flex items-center gap-2">
{option.icon && <ConnectionIcon supportsDarkMode icon={option.icon} />}
<span className="text-sm">{option.label}</span>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'radio-list':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="min-w-0 w-full"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
className="min-w-0 w-full text-left"
label={
<span className="flex min-w-0 items-center gap-2">
{option.icon && <ConnectionIcon icon={option.icon} />}
<span className="truncate">{option.label}</span>
</span>
}
description={option.description}
/>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={`connect-${field.id}`}
>
<Select
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
>
<SelectTrigger
id={`connect-${field.id}`}
size="small"
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="[&>span:last-child]:flex [&>span:last-child]:items-center [&>span:last-child]:gap-x-2"
>
{/*
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
*/}
{field.id === 'framework' && option.icon && (
<ConnectionIcon icon={option.icon} />
)}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)
case 'switch':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={field.id}
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
>
<Switch
id={field.id}
checked={Boolean(value)}
onCheckedChange={(v) => onFieldChange(field.id, v)}
/>
</FormItemLayout>
)
case 'multi-select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={`connect-${field.id}`}
>
<MultiSelector
values={Array.isArray(value) ? value : []}
onValuesChange={(v) => onFieldChange(field.id, v)}
>
<MultiSelectorTrigger
id={`connect-${field.id}`}
className="w-full"
label="Select features"
badgeLimit="wrap"
showIcon={true}
/>
<MultiSelectorContent>
<MultiSelectorList>
{options.map((option) => (
<MultiSelectorItem
key={option.value}
value={option.value}
className="items-start"
>
<div className="flex flex-col ml-2 gap-y-0.5">
<span className="font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-foreground-light">
{option.description}
</span>
)}
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)
default:
return null
}
})}
</div>
)
}
interface ModeSelectorProps {
modes: Array<{ id: ConnectMode; label: string; description: string }>
selected: ConnectMode
onChange: (mode: ConnectMode) => void
}
export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
const count = modes.length
// 2-col layout leaves an empty cell when count is odd; hide it once we switch to a single row
const emptySlots = count % 2 === 1 ? 1 : 0
return (
// Container query: 2-col when the sheet is narrow; one equal row when there's room
<div className="@container">
<div
className={cn(
'grid',
'grid-cols-2',
count === 3 && '@[28rem]:grid-cols-3',
count === 4 && '@[30rem]:grid-cols-4',
count === 5 && '@[32rem]:grid-cols-5',
count >= 6 && '@[36rem]:grid-cols-6'
)}
>
{modes.map((mode, index) => (
<ConnectModeButton
key={mode.id}
modeId={mode.id}
label={mode.label}
description={mode.description}
selected={selected === mode.id}
onClick={() => onChange(mode.id)}
{...getConnectModeButtonCornerVariants({ index, count, emptySlots })}
/>
))}
{Array.from({ length: emptySlots }, (_, index) => (
<div
key={`empty-${index}`}
aria-hidden
className={getConnectModeEmptySlotClasses(count)}
/>
))}
</div>
</div>
)
}