diff --git a/apps/docs/scripts/codemod/mdx-meta.mjs b/apps/docs/scripts/codemod/mdx-meta.mjs deleted file mode 100644 index 84f9fe15adf70..0000000000000 --- a/apps/docs/scripts/codemod/mdx-meta.mjs +++ /dev/null @@ -1,116 +0,0 @@ -// @ts-check - -/** - * Copies MDX files from the `pages` directory to the `content` directory, - * replacing frontmatter in `meta` with YAML frontmatter. - * - * Also deletes import and export statements. - */ - -let SUB_DIR = 'self-hosting' - -import { parse } from 'acorn' -import { fromMarkdown } from 'mdast-util-from-markdown' -import { mdxFromMarkdown } from 'mdast-util-mdx' -import { mdxjs } from 'micromark-extension-mdxjs' -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, extname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT_DIR = join(__dirname, '../..') -let PAGES_DIR = join(ROOT_DIR, 'pages/guides') -let CONTENT_DIR = join(ROOT_DIR, 'content/guides') - -if (SUB_DIR) { - PAGES_DIR = join(PAGES_DIR, SUB_DIR) - CONTENT_DIR = join(CONTENT_DIR, SUB_DIR) -} - -function convertToYaml(properties) { - let result = '---\n' - - for (const property of properties) { - const key = property.key.name - const value = property.value.value - result += `${key}: "${value}"\n` - } - - result += '---\n\n' - return result -} - -async function main() { - try { - const origDirContents = await readdir(PAGES_DIR, { recursive: true }) - const origMdxFiles = origDirContents.filter((filename) => extname(filename) === '.mdx') - - await Promise.all( - origMdxFiles.map(async (filename) => { - const content = await readFile(join(PAGES_DIR, filename), 'utf-8') - - const mdxTree = fromMarkdown(content, { - extensions: [mdxjs()], - mdastExtensions: [mdxFromMarkdown()], - }) - - const meta = mdxTree.children.find( - (node) => node.type === 'mdxjsEsm' && node.value.trim().startsWith('export const meta') - ) - - let yamlString = '' - if (meta) { - // @ts-ignore - const parsedMeta = parse(meta.value, { ecmaVersion: 2020, sourceType: 'module' }) - yamlString = convertToYaml( - // @ts-ignore - parsedMeta.body[0].declaration.declarations[0].init.properties - ) - } - - const importStatements = mdxTree.children.filter( - (node) => node.type === 'mdxjsEsm' && node.value.trim().match(/^import \w+ from/) - ) - - const exportStatements = mdxTree.children.filter( - (node) => - node.type === 'mdxjsEsm' && - (node.value.trim().match(/^export const (?!meta)/) || - node.value.trim().startsWith('export default')) - ) - - const positions = [meta, ...importStatements, ...exportStatements] - // @ts-ignore - .map(({ position }) => [position.start.line, position.end.line]) - // splicing them out in reverse order means we don't have to worry about line numbers shifting - .sort((a, b) => b[0] - a[0]) - - let index = 0 - while (index < positions.length - 1) { - const overlapsNext = positions[index][0] <= positions[index + 1][1] - if (overlapsNext) { - positions[index][0] = positions[index + 1][0] - positions.splice(index + 1, 1) - } else { - index++ - } - } - - const lines = content.split('\n') - for (const position of positions) { - lines.splice(position[0] - 1, position[1] - position[0] + 1) - } - const splicedLines = lines.join('\n') - const splicedWithFrontmatter = yamlString + splicedLines - - const destinationPath = join(CONTENT_DIR, filename) - await mkdir(dirname(destinationPath), { recursive: true }) - writeFile(destinationPath, splicedWithFrontmatter) - }) - ) - } catch (err) { - console.error(err) - } -} - -main() diff --git a/apps/docs/scripts/orphans/detect-orphan-mdx.ts b/apps/docs/scripts/orphans/detect-orphan-mdx.ts deleted file mode 100644 index 89554cd7aa668..0000000000000 --- a/apps/docs/scripts/orphans/detect-orphan-mdx.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readdir } from 'node:fs/promises' -import { extname, join } from 'node:path' -import { - GLOBAL_MENU_ITEMS, - ai, - api, - auth, - cli, - database, - functions, - gettingstarted, - graphql, - platform, - realtime, - resources, - self_hosting, - storage, -} from '../../components/Navigation/NavigationMenu/NavigationMenu.constants.js' - -const DOCS_ROOT_DIR = join(__dirname, '..', '..') -const DEFAULT_DOCS_CONTENT_DIR = join(DOCS_ROOT_DIR, 'content') - -// eslint-disable-next-line turbo/no-undeclared-env-vars -const DOCS_CONTENT_DIR = process.env.DOCS_CONTENT_DIR || DEFAULT_DOCS_CONTENT_DIR - -const IGNORE_LIST = [ - /** - * This is linked from the Edge Functions examples page though not from the - * main nav. - */ - 'guides/functions/examples/github-actions', - /** - * Auth helpers are deprecated and hidden but still available for legacy - * users if they need it. - */ - 'guides/auth/auth-helpers', - 'guides/auth/auth-helpers/nextjs-pages', - 'guides/auth/auth-helpers/nextjs', - 'guides/auth/auth-helpers/remix', - 'guides/auth/auth-helpers/sveltekit', -] - -type RefItem = { - href?: string - url?: string - items?: RefItem[] -} - -const recGetUrl = (items: readonly RefItem[], acc: string[] = []) => - items.reduce((acc, item) => { - const url = item.href || item.url - if (url) acc.push(url) - if (item.items) acc.push(...recGetUrl(item.items, acc)) - return acc - }, acc) - -const main = async () => { - try { - const savedFiles = (await readdir(DOCS_CONTENT_DIR, { recursive: true })) - .filter((file) => extname(file) === '.mdx') - .map((file) => file.replace(/\.mdx$/, '')) - - const flattenedGlobalMenuItems = GLOBAL_MENU_ITEMS.flat() as RefItem[] - const pagesToPublish = [ - flattenedGlobalMenuItems, - gettingstarted.items, - cli.items, - auth.items, - database.items, - api.items, - graphql.items, - functions.items, - realtime.items, - storage.items, - ai.items, - platform.items, - resources.items, - self_hosting.items, - ] - .flatMap((items) => recGetUrl(items)) - // Remove initial slash - .map((path) => path.substring(1)) - - const extraPages = savedFiles.filter( - (file) => !pagesToPublish.includes(file) && !IGNORE_LIST.includes(file) - ) - console.log(extraPages) - } catch (err) { - console.error(err) - process.exit(1) - } -} - -main() diff --git a/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx index 44d5ffc2b2186..843b507f2655f 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx @@ -11,6 +11,9 @@ import { SelectSeparator_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' @@ -62,9 +65,17 @@ export const UsersSearch = ({ Phone number - - All columns - + + + + Unified search + + + + Search by all columns at once, including mid-string search. May impact database + performance if you have many users. + + diff --git a/apps/studio/components/interfaces/Connect/Connect.constants.ts b/apps/studio/components/interfaces/Connect/Connect.constants.ts index 6636fdc76ca12..98466ea6fdc3d 100644 --- a/apps/studio/components/interfaces/Connect/Connect.constants.ts +++ b/apps/studio/components/interfaces/Connect/Connect.constants.ts @@ -356,3 +356,32 @@ export const CONNECTION_TYPES = [ export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT = 'Purchase IPv4 add-on or use Shared Pooler if on a IPv4 network' export const IPV4_ADDON_TEXT = 'Connections are IPv4 proxied with IPv4 add-on' + +export type ConnectionStringMethod = 'direct' | 'transaction' | 'session' + +export const connectionStringMethodOptions: Record< + ConnectionStringMethod, + { value: string; label: string; description: string; badge: string } +> = { + direct: { + value: 'direct', + label: 'Direct connection', + description: + 'Ideal for applications with persistent and long-lived connections, such as those running on virtual machines or long-standing containers.', + badge: 'IPv4 Compatible', + }, + transaction: { + value: 'transaction', + label: 'Transaction pooler', + description: + 'Ideal for stateless applications like serverless functions where each interaction with Postgres is brief and isolated.', + badge: 'IPv4 Compatible', + }, + session: { + value: 'session', + label: 'Session pooler', + description: + 'Only recommended as an alternative to Direct Connection, when connecting via an IPv4 network.', + badge: 'IPv4 Only', + }, +} diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index 5f207ea614ce6..3a8cb3a7c5237 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -87,6 +87,9 @@ export const Connect = () => { const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString) const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString) const [queryWith, setQueryWith] = useQueryState('with', parseAsString) + const [_, setQueryType] = useQueryState('type', parseAsString) + const [__, setQuerySource] = useQueryState('source', parseAsString) + const [___, setQueryMethod] = useQueryState('method', parseAsString) const [connectionObject, setConnectionObject] = useState(FRAMEWORKS) const [selectedParent, setSelectedParent] = useState(connectionObject[0].key) // aka nextjs @@ -242,13 +245,20 @@ export const Connect = () => { selectedGrandchild, }) + const resetQueryStates = () => { + setQueryFramework(null) + setQueryUsing(null) + setQueryWith(null) + setQueryType(null) + setQuerySource(null) + setQueryMethod(null) + } + const handleDialogChange = (open: boolean) => { if (!open) { setShowConnect(null) setTab(null) - setQueryFramework(null) - setQueryUsing(null) - setQueryWith(null) + resetQueryStates() } else { setShowConnect(open) } @@ -311,8 +321,8 @@ export const Connect = () => { Connect - - + + Connect to your project {connectionTypes.length === 1 ? ` via ${connectionTypes[0].label.toLowerCase()}` : null} @@ -322,7 +332,13 @@ export const Connect = () => { - handleConnectionType(value)}> + { + resetQueryStates() + handleConnectionType(value) + }} + > {connectionTypes.length > 1 ? ( {connectionTypes.map((type) => ( diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx index 12f83c33319a4..da75b672c078e 100644 --- a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -19,7 +19,6 @@ import { } from 'ui' import { Admonition } from 'ui-patterns' import { ConnectionParameters } from './ConnectionParameters' -import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons' interface ConnectionPanelProps { type?: 'direct' | 'transaction' | 'session' @@ -125,13 +124,15 @@ export const ConnectionPanel = ({ const links = ipv4Status.links ?? [] return ( - - + + {title} {!!badge && {badge}} {description} + + {fileTitle && } {type === 'transaction' && isSessionMode ? ( @@ -160,7 +161,7 @@ export const ConnectionPanel = ({ )} language={lang} value={connectionString} - className="[&_code]:text-[12px] [&_code]:text-foreground" + className="[&_code]:text-[12px] [&_code]:text-foreground [&_code]:!whitespace-normal" hideLineNumbers onCopyCallback={onCopyCallback} /> @@ -178,35 +179,7 @@ export const ConnectionPanel = ({ )} {children} - - - {type !== 'session' && ( - <> - - - {type === 'transaction' ? : } - - - - {type === 'transaction' - ? 'Suitable for a large number of connected clients' - : 'Suitable for long-lived, persistent connections'} - - - - - - - {type === 'transaction' - ? 'Clients share a connection pool' - : 'Each client has a dedicated connection to Postgres'} - - - - > - )} - {IS_PLATFORM && ( @@ -244,9 +217,10 @@ export const ConnectionPanel = ({ Only use on a IPv4 network - - Use Direct Connection if connecting via an IPv6 network - + + Session pooler connections are IPv4 proxied for free. + Use Direct Connection if connecting via an IPv6 network. + )} diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index e7d36449fdc88..feb575411e460 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -1,5 +1,6 @@ -import { ChevronDown } from 'lucide-react' -import { HTMLAttributes, ReactNode, useMemo, useState } from 'react' +import { ChevronDown, GlobeIcon, InfoIcon } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { HTMLAttributes, ReactNode, useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils' @@ -18,7 +19,6 @@ import { pluckObjectFields } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, - Button, CodeBlock, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, @@ -35,10 +35,12 @@ import { import { Admonition } from 'ui-patterns' import { CONNECTION_PARAMETERS, + type ConnectionStringMethod, DATABASE_CONNECTION_TYPES, DatabaseConnectionType, IPV4_ADDON_TEXT, PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT, + connectionStringMethodOptions, } from './Connect.constants' import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' import { getConnectionStrings } from './DatabaseSettings.utils' @@ -50,7 +52,7 @@ const StepLabel = ({ ...props }: { number: number; children: ReactNode } & HTMLAttributes) => ( - + {number} {children} @@ -66,12 +68,74 @@ export const DatabaseConnectionString = () => { const { data: org } = useSelectedOrganizationQuery() const state = useDatabaseSelectorStateSnapshot() + // URL state management + const [queryType, setQueryType] = useQueryState('type', parseAsString.withDefault('uri')) + const [querySource, setQuerySource] = useQueryState('source', parseAsString) + const [queryMethod, setQueryMethod] = useQueryState('method', parseAsString.withDefault('direct')) + const [selectedTab, setSelectedTab] = useState('uri') + const [selectedMethod, setSelectedMethod] = useState('direct') const sharedPoolerPreferred = useMemo(() => { return org?.plan?.id === 'free' }, [org]) + // Sync URL state with component state on mount and when URL changes + useEffect(() => { + const validTypes = DATABASE_CONNECTION_TYPES.map((t) => t.id) + if (queryType && validTypes.includes(queryType as DatabaseConnectionType)) { + setSelectedTab(queryType as DatabaseConnectionType) + } else if (queryType && !validTypes.includes(queryType as DatabaseConnectionType)) { + setQueryType('uri') + setSelectedTab('uri') + } + + const validMethods: ConnectionStringMethod[] = ['direct', 'transaction', 'session'] + if (queryMethod && validMethods.includes(queryMethod as ConnectionStringMethod)) { + setSelectedMethod(queryMethod as ConnectionStringMethod) + } else if (queryMethod && !validMethods.includes(queryMethod as ConnectionStringMethod)) { + setQueryMethod('direct') + setSelectedMethod('direct') + } + + if (querySource && querySource !== state.selectedDatabaseId) { + state.setSelectedDatabaseId(querySource) + } else if (!querySource && state.selectedDatabaseId !== projectRef) { + state.setSelectedDatabaseId(projectRef) + } + }, [queryType, queryMethod, querySource, state]) + + // Sync component state changes back to URL + const handleTabChange = (connectionType: DatabaseConnectionType) => { + setSelectedTab(connectionType) + setQueryType(connectionType) + } + + const handleMethodChange = (method: ConnectionStringMethod) => { + setSelectedMethod(method) + setQueryMethod(method) + } + + const handleDatabaseChange = (databaseId: string) => { + if (databaseId === projectRef) { + setQuerySource(null) + } else { + setQuerySource(databaseId) + } + } + + // Sync database selector state changes back to URL + useEffect(() => { + if (state.selectedDatabaseId && state.selectedDatabaseId !== querySource) { + // Only set source in URL if it's not the primary database + if (state.selectedDatabaseId === projectRef) { + setQuerySource(null) + } else { + setQuerySource(state.selectedDatabaseId) + } + } + }, [state.selectedDatabaseId, querySource, projectRef]) + const { data: pgbouncerConfig, error: pgbouncerError, @@ -136,14 +200,14 @@ export const DatabaseConnectionString = () => { const handleCopy = ( connectionTypeId: string, - connectionMethod: 'direct' | 'transaction_pooler' | 'session_pooler' + connectionStringMethod: 'direct' | 'transaction_pooler' | 'session_pooler' ) => { const connectionInfo = DATABASE_CONNECTION_TYPES.find((type) => type.id === connectionTypeId) const connectionType = connectionInfo?.label ?? 'Unknown' const lang = connectionInfo?.lang ?? 'Unknown' sendEvent({ action: 'connection_string_copied', - properties: { connectionType, lang, connectionMethod }, + properties: { connectionType, lang, connectionMethod: connectionStringMethod }, groups: { project: projectRef ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) } @@ -221,12 +285,7 @@ export const DatabaseConnectionString = () => { Type - - setSelectedTab(connectionType) - } - > + @@ -239,7 +298,32 @@ export const DatabaseConnectionString = () => { - + + + + Method + + + + + {connectionStringMethodOptions[selectedMethod].label} + + + + {Object.keys(connectionStringMethodOptions).map((method) => ( + + ))} + + + {isLoading && ( @@ -258,15 +342,16 @@ export const DatabaseConnectionString = () => { {/* // handle non terminal examples */} {hasCodeExamples && ( - - - + + + Install the following - {exampleInstallCommands?.map((cmd, i) => ( + {exampleInstallCommands?.map((cmd) => ( { ))} {exampleFiles && exampleFiles?.length > 0 && ( - - + + Add file to project - {exampleFiles?.map((file, i) => ( - + {exampleFiles?.map((file) => ( + { {hasCodeExamples && ( - Choose type of connection + Connect to your database )} - - handleCopy(selectedTab, 'direct')} - /> - - {IS_PLATFORM && ( - <> - handleCopy(selectedTab, 'transaction_pooler')} - > - {!sharedPoolerPreferred && !ipv4Addon && ( - - - - } - className="text-foreground !bg-dash-sidebar justify-between" - > - - Using the Shared Pooler - - IPv4 compatible - - - - - - - Only recommended when your network does not support IPv6. Added latency - compared to dedicated pooler. - - handleCopy(selectedTab, 'transaction_pooler')} - /> - - - )} - + + {selectedMethod === 'direct' && ( + handleCopy(selectedTab, 'direct')} + /> + )} + {selectedMethod === 'transaction' && IS_PLATFORM && ( + handleCopy(selectedTab, 'transaction_pooler')} + /> + )} + + {selectedMethod === 'session' && IS_PLATFORM && ( + <> {sharedPoolerPreferred && ipv4Addon && ( { )} - { {examplePostInstallCommands && ( - - - - Add the configuration package to read the settings - - {examplePostInstallCommands?.map((cmd, i) => ( - - {cmd} - - ))} - + + + Add the configuration package to read the settings + + {examplePostInstallCommands?.map((cmd) => ( + + {cmd} + + ))} )} @@ -536,3 +581,43 @@ export const DatabaseConnectionString = () => { ) } + +const ConnectionStringMethodSelectItem = ({ + method, + poolerBadge, +}: { + method: ConnectionStringMethod + poolerBadge: string +}) => ( + + + {connectionStringMethodOptions[method].label} + + {connectionStringMethodOptions[method].description} + + + {method === 'session' ? ( + + + {connectionStringMethodOptions[method].badge} + + ) : ( + + + {connectionStringMethodOptions[method].badge} + + )} + {method === 'transaction' && ( + + {poolerBadge} + + )} + {method === 'session' && ( + + Shared Pooler + + )} + + + +) diff --git a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts index 0f331df2b8b6e..62ded19df9e7e 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts @@ -38,7 +38,7 @@ export const getConnectionStrings = ({ } => { const isMd5 = poolingInfo?.connectionString.includes('options=reference') const { projectRef } = metadata - const password = '[YOUR-PASSWORD]' + const password = '[YOUR_PASSWORD]' // Direct connection variables const directUser = connectionInfo.db_user diff --git a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx deleted file mode 100644 index 05f557ebe6c1c..0000000000000 --- a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx +++ /dev/null @@ -1,489 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { Fragment, useEffect, useState } from 'react' - -import { Database } from 'icons' -import { cn } from 'ui' - -// Add overall icon dimension controls -const ICON_WIDTH = 48 -const ICON_HEIGHT = 96 - -// Add these to your existing constants section -const LINE_WIDTH = 2 // SVG container width -const LINE_STROKE_WIDTH = 1 // Width of the actual line -const LINE_OFFSET = 1 // For centering the line in container - -const FlowingLine = ({ - x, - y1, - y2, - isActive, -}: { - x: number - y1: number - y2: number - isActive: boolean -}) => { - return ( - - {isActive && ( - - - - - - - - - - - - )} - - ) -} - -const TopRect = ({ isActive }: { isActive: boolean }) => ( - -) - -const BottomRect = ({ isActive }: { isActive: boolean }) => ( - - - - -) - -// Update existing constants to use these dimensions -const TOP_LINE_START = ICON_HEIGHT * 0.18 // 20% from top -const TOP_LINE_END = ICON_HEIGHT * 0.28 // 48% from top -const BOTTOM_LINE_START = ICON_HEIGHT * 0.4 // 65% from top -const BOTTOM_LINE_END = ICON_HEIGHT * 0.59 // 80% from top - -// Update rect positions and dimensions -const TOP_RECT_Y = ICON_HEIGHT * 0.32 // 55% from top -const BOTTOM_RECT_Y = ICON_HEIGHT * 0.64 // 85% from top -const RECT_X = ICON_WIDTH * 0.17 // ~17% from left -const RECT_WIDTH = ICON_WIDTH * 0.67 // ~67% of total width - -// Update circle positions -const CIRCLE_Y = ICON_HEIGHT * 0.13 // 13% from top -const CIRCLE_SPACING = ICON_WIDTH * 0.25 // 25% of width -const CIRCLE_START_X = ICON_WIDTH * 0.25 // 25% from left -const CIRCLE_RADIUS = ICON_WIDTH * 0.055 // ~3.8% of width - -// Static circle for SessionIcon -const ConnectionDot = ({ index, isActive }: { index: number; isActive: boolean }) => ( - -) - -export const TransactionIcon = () => { - const [dots, setDots] = useState([false, false, false]) - const [lines, setLines] = useState([false, false, false]) - const [bottomLineActive, setBottomLineActive] = useState(false) - - useEffect(() => { - // Watch lines state and update bottomLineActive accordingly - setBottomLineActive(lines.some(Boolean)) - }, [lines]) - - useEffect(() => { - const animateDot = (index: number) => { - // Clear previous state - setDots((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - setLines((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - - // Step 1: Animate dot in - setTimeout(() => { - setDots((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - }, 0) - - // Step 2: After dot is in, wait, then show line - setTimeout(() => { - setLines((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - }, 400) // Wait 400ms after dot appears before showing line - - // Step 3: Clear everything - setTimeout(() => { - setDots((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - setLines((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - }, 1000) // Total animation duration - } - - // Initial staggered animation - setTimeout(() => animateDot(0), 0) - setTimeout(() => animateDot(1), 200) - setTimeout(() => animateDot(2), 400) - - // Set up intervals for continuous animation - const intervals = [0, 1, 2].map((index) => - setInterval(() => animateDot(index), 3000 + index * 200) - ) - - return () => intervals.forEach(clearInterval) - }, []) - - return ( - - - {[0, 1, 2].map((index) => ( - - - {dots[index] && ( - - )} - - - ))} - - - - {[0, 1, 2].map((index) => ( - - ))} - - - - ) -} - -export const SessionIcon = () => { - const [topLineStates, setTopLineStates] = useState([false, false, false]) - const [bottomLineStates, setBottomLineStates] = useState([false, false, false]) - - useEffect(() => { - // Function to animate a single dot - const animateDot = (index: number) => { - setTopLineStates((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - - setTimeout(() => { - setBottomLineStates((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - }, 300) - - setTimeout(() => { - setTopLineStates((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - setBottomLineStates((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - }, 5000) - } - - // Start initial animations immediately with slight delays - setTimeout(() => animateDot(0), 100) - setTimeout(() => animateDot(1), 1500) - setTimeout(() => animateDot(2), 3000) - - // Set up intervals for subsequent animations - const intervals = [0, 1, 2].map((index) => - setInterval(() => animateDot(index), Math.random() * 3000 + 8000) - ) - - return () => intervals.forEach(clearInterval) - }, []) - - return ( - - - {[0, 1, 2].map((index) => ( - - - - ))} - state)} /> - state)} /> - - {[0, 1, 2].map((index) => ( - - - - - ))} - - ) -} - -export const DirectConnectionIcon = () => { - const [dots, setDots] = useState([false, false, false]) - const [lines, setLines] = useState([false, false, false]) - - useEffect(() => { - // Function to animate a single dot - const animateDot = (index: number) => { - setDots((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - setLines((prev) => { - const newState = [...prev] - newState[index] = true - return newState - }) - - // Clear after 2.5s - setTimeout(() => { - setDots((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - setLines((prev) => { - const newState = [...prev] - newState[index] = false - return newState - }) - }, 2500) - } - - // Initial staggered animation - // Start initial animations immediately with slight delays - setTimeout(() => animateDot(0), 100) - setTimeout(() => animateDot(1), 1500) - setTimeout(() => animateDot(2), 3000) - - // Set up intervals for continuous animation - const intervals = [0, 1, 2].map((index) => - setInterval(() => animateDot(index), Math.random() * 3000 + 8000) - ) - - return () => intervals.forEach(clearInterval) - }, []) - - return ( - - - {[0, 1, 2].map((index) => ( - - - {dots[index] && ( - - )} - - - ))} - state)} /> - - {[0, 1, 2].map((index) => ( - - ))} - - ) -} diff --git a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx index 65afd2049be2f..12ec20e15df1c 100644 --- a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx +++ b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx @@ -47,16 +47,22 @@ export const ProjectClaimConfirm = ({ const onClaimProject = async () => { try { - await approveRequest({ id: auth_id!, slug: selectedOrganization.slug }) + const response = await approveRequest({ id: auth_id!, slug: selectedOrganization.slug }) await claimProject({ slug: selectedOrganization.slug, token: claimToken!, }) toast.success('Project claimed successfully') - // invalidate the org projects to force them to be refetched - queryClient.invalidateQueries(projectKeys.list()) - router.push(`/org/${selectedOrganization.slug}`) + try { + // check if the redirect url is valid. If not, redirect the user to the org dashboard + const url = new URL(response.url) + window.location.href = url.toString() + } catch { + // invalidate the org projects to force them to be refetched + queryClient.invalidateQueries(projectKeys.list()) + router.push(`/org/${selectedOrganization.slug}`) + } } catch (error: any) { toast.error(`Failed to claim project ${error.message}`) } diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts index 67ad4cc1c1ad1..ebfba8dfca8c4 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts @@ -4,6 +4,7 @@ import { groupBy } from 'lodash' import { DataPoint } from 'data/analytics/constants' import type { OrgDailyUsageResponse, PricingMetric } from 'data/analytics/org-daily-stats-query' import type { OrgSubscription } from 'data/subscriptions/types' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' // [Joshen] This is just for development to generate some test data for chart rendering export const generateUsageData = (attribute: string, days: number): DataPoint[] => { @@ -17,7 +18,16 @@ export const generateUsageData = (attribute: string, days: number): DataPoint[] }) } -export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription, source?: string) => { +export function useGetUpgradeUrl(slug: string, subscription?: OrgSubscription, source?: string) { + const { billingAll } = useIsFeatureEnabled(['billing:all']) + + if (!billingAll) { + const subject = `Enquiry to upgrade plan for organization` + const message = `Organization Slug: ${slug}\nRequested plan: ` + + return `/support/new?orgSlug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}` + } + if (!subscription) { return `/org/${slug}/billing` } diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index 49f2d8e761d40..eadeb6c1f87bc 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -16,7 +16,7 @@ import { CategoryAttribute } from '../Usage.constants' import { ChartTooltipValueFormatter, ChartYFormatterCompactNumber, - getUpgradeUrl, + useGetUpgradeUrl, } from '../Usage.utils' import UsageBarChart from '../UsageBarChart' import { ChartMeta } from './UsageSection' @@ -53,7 +53,7 @@ const AttributeUsage = ({ isSuccess, currentBillingCycleSelected, }: AttributeUsageProps) => { - const upgradeUrl = getUpgradeUrl(slug ?? '', subscription, attribute.key) + const upgradeUrl = useGetUpgradeUrl(slug ?? '', subscription, attribute.key) const usageRatio = (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.pricing_free_units ?? 0) const usageBasedBilling = subscription?.usage_billing_enabled diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 9dbc26c80f05a..9d1b038e27897 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -1,6 +1,7 @@ import { ControllerRenderProps, UseFormReturn } from 'react-hook-form' import { useFlag, useParams } from 'common' +import AlertError from 'components/ui/AlertError' import { useDefaultRegionQuery } from 'data/misc/get-default-region-query' import { useOrganizationAvailableRegionsQuery } from 'data/organizations/organization-available-regions-query' import { BASE_PATH, PROVIDERS } from 'lib/constants' @@ -15,6 +16,10 @@ import { SelectTrigger_Shadcn_, SelectValue_Shadcn_, Select_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, + cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { getAvailableRegions } from './ProjectCreation.utils' @@ -44,8 +49,12 @@ export const RegionSelector = ({ { enabled: !smartRegionEnabled } ) - const { data: availableRegionsData, isLoading: isLoadingAvailableRegions } = - useOrganizationAvailableRegionsQuery({ slug, cloudProvider }, { enabled: smartRegionEnabled }) + const { + data: availableRegionsData, + isLoading: isLoadingAvailableRegions, + isError: isErrorAvailableRegions, + error: errorAvailableRegions, + } = useOrganizationAvailableRegionsQuery({ slug, cloudProvider }, { enabled: smartRegionEnabled }) const smartRegions = availableRegionsData?.all.smartGroup ?? [] const allRegions = availableRegionsData?.all.specific ?? [] @@ -63,6 +72,7 @@ export const RegionSelector = ({ code: value.code, name: value.displayName, provider: cloudProvider, + status: undefined, } }) @@ -73,6 +83,10 @@ export const RegionSelector = ({ process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + if (isErrorAvailableRegions) { + return + } + return ( :nth-child(2)]:w-full', + value.status !== undefined && '!pointer-events-auto' + )} + disabled={value.status !== undefined} > @@ -164,11 +182,21 @@ export const RegionSelector = ({ {recommendedSpecificRegions.has(value.code) && ( - - - Recommended - - + + Recommended + + )} + {value.status !== undefined && value.status === 'capacity' && ( + + + + Unavailable + + + + Temporarily unavailable due to this region being at capacity. + + )} diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx index dcb593f5bc46b..362f9af3d0425 100644 --- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -5,6 +5,7 @@ import type { UseFormReturn } from 'react-hook-form' import { toast } from 'sonner' // End of third-party imports +import { useParams } from 'common' import CopyButton from 'components/ui/CopyButton' import InformationBox from 'components/ui/InformationBox' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' @@ -66,6 +67,8 @@ interface ProjectSelectorProps { } function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) { + const { projectRef: urlProjectRef } = useParams() + return ( { - if (!projectRef || projectRef === NO_PROJECT_MARKER) + if (!urlProjectRef && (!projectRef || projectRef === NO_PROJECT_MARKER)) field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER) }} onSelect={(project) => field.onChange(project.ref)} diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index e33793c126dac..09515b4598780 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -141,7 +141,7 @@ export function createSupportFormUrl(initialParams: SupportFormUrlKeys) { * - URL param (if any) * - Fallback */ -export async function selectInitalOrgAndProject({ +export async function selectInitialOrgAndProject({ projectRef, orgSlug, orgs, diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts index d00b5d63ca8f6..b3aa32e06c3f6 100644 --- a/apps/studio/components/interfaces/Support/useSupportForm.ts +++ b/apps/studio/components/interfaces/Support/useSupportForm.ts @@ -7,11 +7,11 @@ import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { SupportFormSchema, type SupportFormValues } from './SupportForm.schema' import type { SupportFormActions } from './SupportForm.state' import { - loadSupportFormInitialParams, NO_ORG_MARKER, NO_PROJECT_MARKER, type SupportFormUrlKeys, - selectInitalOrgAndProject, + loadSupportFormInitialParams, + selectInitialOrgAndProject, } from './SupportForm.utils' const supportFormDefaultValues: DefaultValues = { @@ -88,7 +88,7 @@ export function useSupportForm(dispatch: Dispatch): UseSuppo ? urlParamsRef.current.projectRef : null - selectInitalOrgAndProject({ + selectInitialOrgAndProject({ projectRef: projectRefFromUrl, orgSlug: orgSlugFromUrl, orgs: organizations ?? [], diff --git a/apps/studio/data/entitlements/entitlements-query.ts b/apps/studio/data/entitlements/entitlements-query.ts new file mode 100644 index 0000000000000..cf922714eaf56 --- /dev/null +++ b/apps/studio/data/entitlements/entitlements-query.ts @@ -0,0 +1,38 @@ +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types/base' +import type { components } from 'api-types' + +export type EntitlementsVariables = { + slug: string +} + +export type EntitlementConfig = + components['schemas']['ListEntitlementsResponse']['entitlements'][0]['config'] +export type Entitlement = components['schemas']['ListEntitlementsResponse']['entitlements'][0] + +export async function getEntitlements({ slug }: EntitlementsVariables, signal?: AbortSignal) { + if (!slug) throw new Error('slug is required') + + const { data, error } = await get('/platform/organizations/{slug}/entitlements', { + params: { path: { slug } }, + signal, + }) + if (error) handleError(error) + + return data +} + +export type EntitlementsData = Awaited> +export type EntitlementsError = ResponseError + +export const useEntitlementsQuery = ( + { slug }: EntitlementsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + return useQuery( + ['entitlements', slug], + ({ signal }) => getEntitlements({ slug }, signal), + { enabled: enabled && typeof slug !== 'undefined', ...options, staleTime: 1 * 60 * 1000 } + ) +} diff --git a/apps/studio/hooks/misc/useCheckEntitlements.ts b/apps/studio/hooks/misc/useCheckEntitlements.ts new file mode 100644 index 0000000000000..bf55549b27791 --- /dev/null +++ b/apps/studio/hooks/misc/useCheckEntitlements.ts @@ -0,0 +1,55 @@ +import { useEntitlementsQuery } from 'data/entitlements/entitlements-query' +import { useMemo } from 'react' +import { useSelectedOrganizationQuery } from './useSelectedOrganization' +import type { EntitlementConfig } from 'data/entitlements/entitlements-query' + +export function useCheckEntitlements( + featureKey: string, + organizationSlug?: string, + options?: { + enabled?: boolean + } +) { + // If no organizationSlug provided, try to get it from the selected organization + const shouldGetSelectedOrg = !organizationSlug && options?.enabled !== false + const { + data: selectedOrg, + isLoading: isLoadingSelectedOrg, + isSuccess: isSuccessSelectedOrg, + } = useSelectedOrganizationQuery({ + enabled: shouldGetSelectedOrg, + }) + + const finalOrgSlug = organizationSlug || selectedOrg?.slug + const enabled = options?.enabled !== false && !!finalOrgSlug + + const { + data: entitlementsData, + isLoading: isLoadingEntitlements, + isSuccess: isSuccessEntitlements, + } = useEntitlementsQuery({ slug: finalOrgSlug! }, { enabled }) + + const { hasAccess, entitlementConfig } = useMemo((): { + hasAccess: boolean + entitlementConfig: EntitlementConfig + } => { + // If no organization slug, no access + if (!finalOrgSlug) return { hasAccess: false, entitlementConfig: { enabled: false } } + + const entitlement = entitlementsData?.entitlements.find( + (entitlement) => entitlement.feature.key === featureKey + ) + const entitlementConfig = entitlement?.config ?? { enabled: false } + + if (!entitlement) return { hasAccess: false, entitlementConfig: { enabled: false } } + + return { hasAccess: entitlement.hasAccess, entitlementConfig } + }, [entitlementsData, featureKey, finalOrgSlug]) + + const isLoading = shouldGetSelectedOrg ? isLoadingSelectedOrg : isLoadingEntitlements + const isSuccess = shouldGetSelectedOrg + ? isSuccessSelectedOrg && isSuccessEntitlements + : isSuccessEntitlements + + return { hasAccess, entitlementConfig, isLoading, isSuccess } +} diff --git a/apps/studio/pages/claim-project.tsx b/apps/studio/pages/claim-project.tsx index a109a561195cf..521f3959f23ae 100644 --- a/apps/studio/pages/claim-project.tsx +++ b/apps/studio/pages/claim-project.tsx @@ -21,7 +21,7 @@ const ClaimProjectPageLayout = ({ children }: PropsWithChildren) => { return ( <> - Claim project | {appTitle || 'Supabase'} + {`Claim project | ${appTitle ?? 'Supabase'}`} {children} > diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index fdfe2f256116b..15dde790e7c2c 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -1251,6 +1251,46 @@ export interface paths { patch?: never trace?: never } + '/platform/organizations/{slug}/entitlements': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Get entitlements for an organization + * @description Returns the entitlements available to the organization based on their plan and any overrides. + */ + get: operations['OrganizationEntitlementsController_getEntitlements'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/platform/organizations/{slug}/entitlements/entitlements': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Get entitlements for an organization + * @description Returns the entitlements available to the organization based on their plan and any overrides. + */ + get: operations['OrganizationEntitlementsController_getEntitlements'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/organizations/{slug}/members': { parameters: { query?: never @@ -5731,6 +5771,11 @@ export interface components { billing_name?: string billing_via_partner: boolean email: string + tax_id: { + country: string + type: string + value: string + } | null } DatabaseDetailResponse: { /** @enum {string} */ @@ -6942,6 +6987,29 @@ export interface components { LinkClazarBuyerBody: { buyer_id: string } + ListEntitlementsResponse: { + entitlements: { + config: + | { + enabled: boolean + } + | { + enabled: boolean + unlimited: boolean + value: number + } + | { + enabled: boolean + set: string[] + } + feature: { + key: string + /** @enum {string} */ + type: 'boolean' | 'numeric' | 'set' + } + hasAccess: boolean + }[] + } ListGitHubConnectionsResponse: { connections: { branch_limit: number @@ -14178,6 +14246,92 @@ export interface operations { } } } + OrganizationEntitlementsController_getEntitlements: { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ListEntitlementsResponse'] + } + } + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + OrganizationEntitlementsController_getEntitlements: { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ListEntitlementsResponse'] + } + } + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } MembersController_getMembers: { parameters: { query?: never
{description}
Session pooler connections are IPv4 proxied for free.
Use Direct Connection if connecting via an IPv6 network.
- Only recommended when your network does not support IPv6. Added latency - compared to dedicated pooler. -