mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Update connect sheet direct connection logic (#44471)
<img width="996" height="424" alt="image" src="https://github.com/user-attachments/assets/f0a0620b-c7e1-4391-a065-51c95fee1186" /> Updates the new ConnectSheet direct connection logic to better handle plan and add-on status. To test view the connect sheet with: - A free org - A paid org - A paid org with the ip4 add-on enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * IPv4 connection status panel with icons, badges, contextual links and collapsible details about IPv4 limitations and pooler alternatives * Pooler-type badges reflecting entitlement-based availability and clearer connection compatibility messaging with direct links to relevant settings * **Tests** * Added coverage for entitlement-dependent pooler behavior * **Style** * Simplified admonition header and improved formatting of copied step text <!-- 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
273916171b
commit
042248fd6d
5 files changed
+294
-11
No files matched your search
@@ -26,6 +26,10 @@ export function CopyPromptAdmonition({ stepsContainerRef }: CopyPromptAdmonition
|
||||
element.remove()
|
||||
})
|
||||
|
||||
clone.querySelectorAll('p, div').forEach((el) => {
|
||||
el.appendChild(document.createTextNode('\n'))
|
||||
})
|
||||
|
||||
const text = clone.textContent ?? ''
|
||||
return normalizeTextLines(text)
|
||||
}
|
||||
@@ -141,15 +145,7 @@ export function CopyPromptAdmonition({ stepsContainerRef }: CopyPromptAdmonition
|
||||
|
||||
<div className="relative flex flex-col md:flex-row md:items-center gap-y-2 md:gap-x-8 justify-between">
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<Badge variant="success" className="-ml-0.5">
|
||||
Skip the steps
|
||||
</Badge>
|
||||
<p className="heading-default">Prompt your agent</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-lighter text-balance max-w-72">
|
||||
Copy a prompt with everything your agent needs to connect your app for you.
|
||||
</p>
|
||||
<p className="heading-default">Give your agent everything it needs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { ChevronRight, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
|
||||
const IPv4StatusIcon = ({ className, active }: { className?: string; active: boolean }) => {
|
||||
return (
|
||||
<div className={cn('relative inline-flex', className)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1"
|
||||
stroke="currentColor"
|
||||
className="size-6 stroke-foreground-lighter"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{!active ? (
|
||||
<div className="absolute -right-1.5 -top-1.5 bg-destructive rounded w-4 h-4 flex items-center justify-center">
|
||||
<X size={10} strokeWidth={4} className="text-white rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute -right-1.5 -top-1.5 bg-brand-500 rounded w-4 h-4 flex items-center justify-center">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.33325 2.5L3.74992 7.08333L1.66659 5"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface IPv4Status {
|
||||
type: 'error' | 'success'
|
||||
title: string
|
||||
description?: string | ReactNode
|
||||
links?: { text: string; url: string }[]
|
||||
}
|
||||
|
||||
interface IPv4StatusPanelProps {
|
||||
method: 'direct' | 'transaction' | 'session'
|
||||
ipv4Status: IPv4Status
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
export function IPv4StatusPanel({ method, ipv4Status, projectRef }: IPv4StatusPanelProps) {
|
||||
if (!IS_PLATFORM) return null
|
||||
|
||||
const links = ipv4Status.links ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -space-y-px w-full">
|
||||
{method === 'session' ? (
|
||||
<div className="border border-muted px-5 flex gap-7 items-center py-3 rounded bg-alternative/50">
|
||||
<div className="flex w-6 h-6 rounded items-center justify-center gap-2 flex-shrink-0 bg-surface-100">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-foreground">Only use on a IPv4 network</span>
|
||||
<div className="flex flex-col text-xs text-foreground-lighter">
|
||||
<p>Session pooler connections are IPv4 proxied for free.</p>
|
||||
<p>Use Direct Connection if connecting via an IPv6 network.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'border border-muted px-5 flex gap-7 items-center py-3 first:rounded-t',
|
||||
ipv4Status.type === 'error' ? 'rounded-b-none' : 'last:rounded-b'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IPv4StatusIcon active={ipv4Status.type === 'success'} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-foreground">{ipv4Status.title}</span>
|
||||
{ipv4Status.description &&
|
||||
(typeof ipv4Status.description === 'string' ? (
|
||||
<span className="text-xs text-foreground-lighter">{ipv4Status.description}</span>
|
||||
) : (
|
||||
ipv4Status.description
|
||||
))}
|
||||
{links.length > 0 && (
|
||||
<div className="flex items-center gap-x-2 mt-2">
|
||||
{links.map((link) => (
|
||||
<Button key={link.text} asChild type="default" size="tiny">
|
||||
<Link href={link.url} className="text-xs text-light hover:text-foreground">
|
||||
{link.text}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ipv4Status.type === 'error' && (
|
||||
<Collapsible_Shadcn_ className="group -space-y-px">
|
||||
<CollapsibleTrigger_Shadcn_
|
||||
asChild
|
||||
className="group/collapse w-full justify-start rounded-t-none !last:rounded-b group-data-[state=open]:rounded-b-none border-muted"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
className="text-foreground-lighter !bg-dash-sidebar"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'group-data-[state=open]/collapse:rotate-90 text-foreground-muted transition-transform'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Some platforms are IPv4-only:
|
||||
</Button>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="bg-dash-sidebar rounded-b border px-3 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-foreground-light max-w-xs">
|
||||
A few major platforms are IPv4-only and may not work with a Direct Connection:
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="text-foreground text-xs">Vercel</div>
|
||||
<div className="text-foreground text-xs">GitHub Actions</div>
|
||||
<div className="text-foreground text-xs">Render</div>
|
||||
<div className="text-foreground text-xs">Retool</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter max-w-xs">
|
||||
If you wish to use a Direct Connection with these, please purchase{' '}
|
||||
<Link
|
||||
href={`/project/${projectRef}/settings/addons?panel=ipv4`}
|
||||
className="text-xs text-light hover:text-foreground"
|
||||
>
|
||||
IPv4 support
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p className="text-xs text-foreground-lighter max-w-xs">
|
||||
You may also use the{' '}
|
||||
<span className="text-foreground-light">Session Pooler</span> or{' '}
|
||||
<span className="text-foreground-light">Transaction Pooler</span> if you are on
|
||||
a IPv4 network.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+78
@@ -1,12 +1,16 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { Badge } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { getConnectionStrings } from '../../../DatabaseSettings.utils'
|
||||
import { IPv4StatusPanel, type IPv4Status } from './IPv4StatusPanel'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import {
|
||||
DATABASE_CONNECTION_TYPES,
|
||||
IPV4_ADDON_TEXT,
|
||||
PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT,
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
@@ -121,6 +125,9 @@ const CONNECTION_METHOD_TO_TELEMETRY: Record<
|
||||
*/
|
||||
function DirectConnectionContent({ state }: StepContentProps) {
|
||||
const track = useTrack()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
|
||||
|
||||
const connectionSource = state.connectionSource
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
@@ -129,6 +136,7 @@ function DirectConnectionContent({ state }: StepContentProps) {
|
||||
const connectionStrings = useConnectionStringDatabases()
|
||||
const connectionStringPooler =
|
||||
connectionStrings[connectionSource as keyof typeof connectionStrings]
|
||||
const hasIPv4Addon = connectionStringPooler.ipv4SupportedForDedicatedPooler
|
||||
|
||||
// Determine which connection string to use
|
||||
const resolvedConnectionString = useMemo(
|
||||
@@ -184,8 +192,69 @@ function DirectConnectionContent({ state }: StepContentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const sharedPoolerPreferred = !hasDedicatedPooler
|
||||
const ipv4AddOnUrl = {
|
||||
text: 'IPv4 add-on',
|
||||
url: `/project/${projectRef}/settings/addons?panel=ipv4`,
|
||||
}
|
||||
const ipv4SettingsUrl = {
|
||||
text: 'IPv4 settings',
|
||||
url: `/project/${projectRef}/settings/addons?panel=ipv4`,
|
||||
}
|
||||
const poolerSettingsUrl = {
|
||||
text: 'Pooler settings',
|
||||
url: `/project/${projectRef}/database/settings#connection-pooling`,
|
||||
}
|
||||
const buttonLinks = !hasIPv4Addon
|
||||
? [ipv4AddOnUrl, ...(sharedPoolerPreferred ? [poolerSettingsUrl] : [])]
|
||||
: [ipv4SettingsUrl, ...(sharedPoolerPreferred ? [poolerSettingsUrl] : [])]
|
||||
|
||||
let ipv4Status: IPv4Status
|
||||
if (connectionMethod === 'direct') {
|
||||
ipv4Status = {
|
||||
type: !hasIPv4Addon ? 'error' : 'success',
|
||||
title: !hasIPv4Addon ? 'Not IPv4 compatible' : 'IPv4 compatible',
|
||||
description:
|
||||
!sharedPoolerPreferred && !hasIPv4Addon
|
||||
? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT
|
||||
: sharedPoolerPreferred
|
||||
? 'Use Session Pooler if on a IPv4 network or purchase IPv4 add-on'
|
||||
: IPV4_ADDON_TEXT,
|
||||
links: buttonLinks,
|
||||
}
|
||||
} else if (connectionMethod === 'transaction') {
|
||||
const isUsingSharedPooler = useSharedPooler || !hasDedicatedPooler
|
||||
ipv4Status = {
|
||||
type: !isUsingSharedPooler && !hasIPv4Addon ? 'error' : 'success',
|
||||
title: !isUsingSharedPooler && !hasIPv4Addon ? 'Not IPv4 compatible' : 'IPv4 compatible',
|
||||
description:
|
||||
!isUsingSharedPooler && !hasIPv4Addon
|
||||
? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT
|
||||
: isUsingSharedPooler
|
||||
? 'Transaction pooler connections are IPv4 proxied for free.'
|
||||
: IPV4_ADDON_TEXT,
|
||||
links: !isUsingSharedPooler ? buttonLinks : undefined,
|
||||
}
|
||||
} else {
|
||||
ipv4Status = {
|
||||
type: 'success',
|
||||
title: 'IPv4 compatible',
|
||||
description: 'Session pooler connections are IPv4 proxied for free',
|
||||
}
|
||||
}
|
||||
|
||||
const poolerBadge =
|
||||
connectionMethod === 'transaction'
|
||||
? useSharedPooler || !hasDedicatedPooler
|
||||
? 'Shared Pooler'
|
||||
: 'Dedicated Pooler'
|
||||
: connectionMethod === 'session'
|
||||
? 'Shared Pooler'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-x-2">{poolerBadge && <Badge>{poolerBadge}</Badge>}</div>
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
wrapperClassName="lg:col-span-2"
|
||||
@@ -196,6 +265,15 @@ function DirectConnectionContent({ state }: StepContentProps) {
|
||||
>
|
||||
{connectionString}
|
||||
</CodeBlock>
|
||||
{projectRef && (
|
||||
<div className="mt-2">
|
||||
<IPv4StatusPanel
|
||||
method={connectionMethod}
|
||||
ipv4Status={ipv4Status}
|
||||
projectRef={projectRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionParameters
|
||||
parameters={buildConnectionParameters(connectionParams)}
|
||||
onCopy={trackCopy}
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock('@/data/read-replicas/replicas-query', () => ({
|
||||
useReadReplicasQuery: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
|
||||
useCheckEntitlements: vi.fn().mockImplementation(() => ({ hasAccess: true })),
|
||||
}))
|
||||
|
||||
describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
// Initial State Tests
|
||||
@@ -276,7 +280,10 @@ describe('useConnectState', () => {
|
||||
expect(fieldIds).toContain('connectionType')
|
||||
})
|
||||
|
||||
test('should show useSharedPooler only for transaction connection method', () => {
|
||||
test('should show useSharedPooler only for transaction connection method when user has dedicated_pooler entitlement', async () => {
|
||||
const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
|
||||
vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: true } as any)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
|
||||
)
|
||||
@@ -285,6 +292,18 @@ describe('useConnectState', () => {
|
||||
expect(fieldIds).toContain('useSharedPooler')
|
||||
})
|
||||
|
||||
test('should hide useSharedPooler even if using transaction method when user lacks dedicated_pooler entitlement', async () => {
|
||||
const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
|
||||
vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: false } as any)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
|
||||
)
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).not.toContain('useSharedPooler')
|
||||
})
|
||||
|
||||
test('should hide useSharedPooler for direct connection method', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'direct' })
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { resolveFrameworkLibraryKey } from './Connect.utils'
|
||||
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
|
||||
// ============================================================================
|
||||
// Data Source Helpers
|
||||
@@ -194,6 +195,7 @@ export interface UseConnectStateReturn {
|
||||
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
|
||||
|
||||
const [state, setState] = useState<ConnectState>(() => {
|
||||
const defaults = getDefaultState({ schema: connectSchema })
|
||||
@@ -318,7 +320,13 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
[projectRef]
|
||||
)
|
||||
|
||||
const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state])
|
||||
const activeFields = useMemo(() => {
|
||||
const fields = getActiveFields(connectSchema, state)
|
||||
if (!hasDedicatedPooler) {
|
||||
return fields.filter((f) => f.id !== 'useSharedPooler')
|
||||
}
|
||||
return fields
|
||||
}, [state, hasDedicatedPooler])
|
||||
|
||||
const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state])
|
||||
|
||||
|
||||
Reference in new issue
Block a user