mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
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>
283 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|