diff --git a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx index ab704a4729e..c5993d85022 100644 --- a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx @@ -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
-
- - Skip the steps - -

Prompt your agent

-
-

- Copy a prompt with everything your agent needs to connect your app for you. -

+

Give your agent everything it needs

diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx new file mode 100644 index 00000000000..e777c92ac8e --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx @@ -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 ( +
+ + + + + {!active ? ( +
+ +
+ ) : ( +
+ + + +
+ )} +
+ ) +} + +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 ( +
+ {method === 'session' ? ( +
+
+ +
+
+ Only use on a IPv4 network +
+

Session pooler connections are IPv4 proxied for free.

+

Use Direct Connection if connecting via an IPv6 network.

+
+
+
+ ) : ( + <> +
+
+ +
+
+ {ipv4Status.title} + {ipv4Status.description && + (typeof ipv4Status.description === 'string' ? ( + {ipv4Status.description} + ) : ( + ipv4Status.description + ))} + {links.length > 0 && ( +
+ {links.map((link) => ( + + ))} +
+ )} +
+
+ + {ipv4Status.type === 'error' && ( + + + + + +
+

+ A few major platforms are IPv4-only and may not work with a Direct Connection: +

+
+
Vercel
+
GitHub Actions
+
Render
+
Retool
+
+

+ If you wish to use a Direct Connection with these, please purchase{' '} + + IPv4 support + + . +

+

+ You may also use the{' '} + Session Pooler or{' '} + Transaction Pooler if you are on + a IPv4 network. +

+
+
+
+ )} + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index d2683ba6bcf..af67c0bbfe7 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -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 (
+
{poolerBadge && {poolerBadge}}
{connectionString} + {projectRef && ( +
+ +
+ )} ({ 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' }) diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index 5e55f1e1ad7..9bccdbd626d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -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): UseConnectStateReturn { const { ref: projectRef } = useParams() const { data: databases = [] } = useReadReplicasQuery({ projectRef }) + const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') const [state, setState] = useState(() => { const defaults = getDefaultState({ schema: connectSchema }) @@ -318,7 +320,13 @@ export function useConnectState(initialState?: Partial): 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])