From 7ca43d891989a22318323452bbcbaa5b94353ff0 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 5 Dec 2024 15:40:23 +0800 Subject: [PATCH 1/3] Chore/fix error handling in generate v3 (#30913) * Fix error handling in generate-v3 endpoint * Lint --- apps/studio/pages/api/ai/sql/generate-v3.ts | 28 +++++++-------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts index 55517ba1c0b3a..4218aa062d60d 100644 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ b/apps/studio/pages/api/ai/sql/generate-v3.ts @@ -1,25 +1,20 @@ import { openai } from '@ai-sdk/openai' -import { streamText } from 'ai' -import { getTools } from './tools' import pgMeta from '@supabase/pg-meta' -import { executeSql } from 'data/sql/execute-sql-query' +import { streamText } from 'ai' import { NextApiRequest, NextApiResponse } from 'next' +import { executeSql } from 'data/sql/execute-sql-query' +import { getTools } from './tools' + export const maxDuration = 30 const openAiKey = process.env.OPENAI_API_KEY const pgMetaSchemasList = pgMeta.schemas.list() export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) + return res.status(400).json({ + error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', + }) } const { method } = req @@ -28,13 +23,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) case 'POST': return handlePost(req, res) default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) } } From 98797fc6b8ceea68e22abb53719222b9c0d62741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Thu, 5 Dec 2024 16:39:19 +0800 Subject: [PATCH 2/3] feat: improved disk size insights (#30827) --- .../guides/platform/compute-and-disk.mdx | 15 +- .../content/guides/platform/database-size.mdx | 14 + .../database-size/disk-size-distribution.png | Bin 0 -> 65558 bytes .../fields/ComputeSizeField.tsx | 9 + .../DiskManagement/fields/DiskSizeField.tsx | 10 + .../DiskManagement/ui/DiskSpaceBar.tsx | 248 +++++++++++------- .../Usage/UsageSection/DiskUsage.tsx | 2 +- .../data/config/disk-breakdown-query.ts | 59 +++++ apps/studio/data/config/keys.ts | 2 + .../pages/project/[ref]/reports/database.tsx | 13 +- 10 files changed, 260 insertions(+), 112 deletions(-) create mode 100644 apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png create mode 100644 apps/studio/data/config/disk-breakdown-query.ts diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 0b4e26f21c0ca..74aeec43fefeb 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -108,13 +108,14 @@ Be aware that increasing IOPS or throughput incurs additional charges. When selecting your disk, it's essential to focus on the performance needs of your workload. Here's a comparison of our available disk types: -| | General Purpose SSD (gp3) | High Performance SSD (io2) | -| ----------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications | -| **Max Disk Size** | 16 TB | 60 TB | -| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) | -| **Throughput** | 125 MiB/s (default) to 1,000 MiB/s (maximum) | Automatically scales with IOPS | -| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements | +| | General Purpose SSD (gp3) | High Performance SSD (io2) | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications | +| **Max Disk Size** | 16 TB | 60 TB | +| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) | +| **Throughput** | 125 MiB/s (default) to 1,000 MiB/s (maximum) | Automatically scales with IOPS | +| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements | +| **Pricing** | Disk: 8 GB included, then $0.125 per GB
IOPS: 3,000 included, then $0.024 per IOPS
Throughput: 125 Mbps included, then $0.95 per Mbps | Disk: $0.195 per GB
IOPS: $0.119 per IOPS
Throughput: Scales with IOPS at no additional cost | For general, day-to-day operations, gp3 should be more than enough. If you need high throughput and IOPS for critical systems, io2 will provide the performance required. diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index bb9174b5f9f92..a4774aa07a084 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -139,8 +139,22 @@ Once you have reclaimed space, you can run the following to disable [read-only]( set default_transaction_read_only = 'off'; ``` +### Disk Size Distribution + +You can check the distribution of your disk size on your [project's compute and disk page](/dashboard/_/settings/compute-and-disk). + +![Disk Size Distribution](/docs/img/guides/platform/database-size/disk-size-distribution.png) + +Your disk size usage falls in three categories: + +- **Database** - Disk usage by the database. This includes the actual data, indexes, materialized views, ... +- **WAL** - Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database. +- **System** - Disk usage reserved by the system to ensure the database can operate smoothly. Users cannot modify this and it should only take very little space. + ### Reducing disk size Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB. +In case you have a large WAL directory, you may [modify WAL settings](/docs/guides/database/custom-postgres-config) such as `max_wal_size`. Use at your own risk as changing these settings can have side effects. To query your current WAL size, use `SELECT SUM(size) FROM pg_ls_waldir()`. + In the event that your project is already on the latest version of Postgres and cannot be upgraded, a new version of Postgres will be released approximately every week which you can then upgrade to once it becomes available. diff --git a/apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png b/apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png new file mode 100644 index 0000000000000000000000000000000000000000..32d0648b3d1a279550ca42dcc5226c26167e4a73 GIT binary patch literal 65558 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%0D&A@V^K8Z|hcd|7kV)Z?~Su_p#GsQYA)& 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

q~6*($tsSx4;SM%H1JQ+_*I31 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 Hardware resources allocated to your Postgres database

+ +
+ +
+ @@ -148,6 +151,13 @@ export function DiskSizeField({ {includedDiskGB > 0 && subscription?.plan.id && `Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`} + +
+ +
@@ -30,6 +30,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) { const { resolvedTheme } = useTheme() const { formState, watch } = form const isDarkMode = resolvedTheme?.includes('dark') + const project = useSelectedProject() const { data: diskUtil, @@ -38,30 +39,54 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) { projectRef: ref, }) - const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 - const totalSize = formState.defaultValues?.totalSize || 0 - const show = formState.dirtyFields.totalSize !== undefined && usedSize + const { data: diskBreakdown } = useDiskBreakdownQuery({ + projectRef: ref, + connectionString: project?.connectionString, + }) + + const diskBreakdownBytes = useMemo(() => { + return { + availableBytes: diskUtil?.metrics.fs_avail_bytes ?? 0, + totalUsedBytes: diskUtil?.metrics.fs_used_bytes ?? 0, + totalDiskSizeBytes: diskUtil?.metrics.fs_size_bytes, + dbSizeBytes: Math.max(0, diskBreakdown?.db_size_bytes ?? 0), + walSizeBytes: Math.max(0, diskBreakdown?.wal_size_bytes ?? 0), + systemBytes: Math.max( + 0, + (diskUtil?.metrics.fs_used_bytes ?? 0) - + (diskBreakdown?.db_size_bytes ?? 0) - + (diskBreakdown?.wal_size_bytes ?? 0) + ), + } + }, [diskUtil, diskBreakdown]) + + const showNewSize = formState.dirtyFields.totalSize !== undefined && diskBreakdown const newTotalSize = watch('totalSize') - const usedPercentage = (usedSize / totalSize) * 100 - const resizePercentage = AUTOSCALING_THRESHOLD * 100 + const totalSize = formState.defaultValues?.totalSize || 0 + const usedSizeTotal = Math.round(((diskBreakdownBytes?.totalUsedBytes ?? 0) / GB) * 100) / 100 + const usedTotalPercentage = Math.min((usedSizeTotal / totalSize) * 100, 100) - const newUsedPercentage = (usedSize / newTotalSize) * 100 - const newResizePercentage = AUTOSCALING_THRESHOLD * 100 + const usedSizeDatabase = Math.round(((diskBreakdownBytes?.dbSizeBytes ?? 0) / GB) * 100) / 100 + const usedPercentageDatabase = Math.min((usedSizeDatabase / totalSize) * 100, 100) + const newUsedPercentageDatabase = Math.min((usedSizeDatabase / newTotalSize) * 100, 100) - const { project } = useProjectContext() - const { data } = useDatabaseSizeQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { remainingDuration } = useRemainingDurationForDiskAttributeUpdate({ projectRef: ref }) + const usedSizeWAL = Math.round(((diskBreakdownBytes?.walSizeBytes ?? 0) / GB) * 100) / 100 + const usedPercentageWAL = Math.min((usedSizeWAL / totalSize) * 100, 100) + const newUsedPercentageWAL = Math.min((usedSizeWAL / newTotalSize) * 100, 100) + + const usedSizeSystem = Math.round(((diskBreakdownBytes?.systemBytes ?? 0) / GB) * 100) / 100 + const usedPercentageSystem = Math.min((usedSizeSystem / totalSize) * 100, 100) + const newUsedPercentageSystem = Math.min((usedSizeSystem / newTotalSize) * 100, 100) + + const resizePercentage = AUTOSCALING_THRESHOLD * 100 + const newResizePercentage = AUTOSCALING_THRESHOLD * 100 - const databaseSizeBytes = data ?? 0 return (
- {usedSize.toFixed(2)} + {usedSizeTotal.toFixed(2)} GB used of @@ -73,85 +98,67 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
- {!show ? ( - -
+ +
+
= 90 && remainingDuration > 0 - ? 'bg-destructive' - : 'bg-foreground', - 'relative overflow-hidden transition-all duration-500 ease-in-out' - )} - style={{ width: `${usedPercentage >= 100 ? 100 : usedPercentage}%` }} - > -
-
-
- - ) : ( - -
+ +
+ +
+ + {!showNewSize && (
= 100 ? 100 : newUsedPercentage}%` }} - > -
-
-
- - )} + className="bg-transparent-800 border-r transition-all duration-500 ease-in-out" + style={{ + width: `${resizePercentage - usedTotalPercentage <= 0 ? 0 : resizePercentage - usedTotalPercentage}%`, + }} + /> + )} +
+ - {show && ( + {showNewSize && (
- {!show && ( + {!showNewSize && (
@@ -202,24 +209,75 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) { )}
- {!show && ( -
-
-
- Used Space -
-
-
- Available space -
+ {!showNewSize && ( +
+ + + + + +
)}

Note: Disk Size refers to the total space your project occupies on disk, including the database itself (currently{' '} - {formatBytes(databaseSizeBytes, 2, 'GB')}), additional files like the - write-ahead log (WAL), and other internal resources. + {formatBytes(diskBreakdownBytes?.dbSizeBytes, 2, 'GB')}), additional files like + the write-ahead log (currently{' '} + {formatBytes(diskBreakdownBytes?.walSizeBytes, 2, 'GB')}), and other system + resources (currently {formatBytes(diskBreakdownBytes?.systemBytes, 2, 'GB')}). + Data can take 5 minutes to refresh.

) } + +const LegendItem = ({ + name, + description, + color, + size, +}: { + name: string + description: string + color: string + size: number +}) => ( + + +
+
+ {name} +
+ + +
+
+ + {name} - {formatBytes(size, 2, 'GB')} + +
+

{description}

+ + +) diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index 5383630a19980..df7486172fe31 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -178,7 +178,7 @@ const DiskUsage = ({ {project.name} diff --git a/apps/studio/data/config/disk-breakdown-query.ts b/apps/studio/data/config/disk-breakdown-query.ts new file mode 100644 index 0000000000000..5218d40d51f90 --- /dev/null +++ b/apps/studio/data/config/disk-breakdown-query.ts @@ -0,0 +1,59 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { ResponseError } from 'types' +import { configKeys } from './keys' +import { executeSql } from 'data/sql/execute-sql-query' + +export type DiskBreakdownVariables = { + projectRef?: string + connectionString?: string +} + +type DiskBreakdownResult = { + db_size_bytes: number + wal_size_bytes: number +} + +export async function getDiskBreakdown( + { projectRef, connectionString }: DiskBreakdownVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('Project ref is required') + if (!connectionString) throw new Error('Connection string is required') + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: ` + SELECT + ( + SELECT + SUM(pg_database_size(pg_database.datname)) AS db_size_bytes + FROM + pg_database + ), + ( + SELECT SUM(size) + FROM + pg_ls_waldir() + ) AS wal_size_bytes`, + }, + signal + ) + + return result[0] as DiskBreakdownResult +} + +export type DiskBreakdownData = Awaited> +export type DiskBreakdownError = ResponseError + +export const useDiskBreakdownQuery = ( + { projectRef, connectionString }: DiskBreakdownVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + configKeys.diskBreakdown(projectRef), + ({ signal }) => getDiskBreakdown({ projectRef, connectionString }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index a2aab7748f18a..e154ed6548159 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -14,6 +14,8 @@ export const configKeys = { ['projects', projectRef, 'upgrade-status'] as const, diskAttributes: (projectRef: string | undefined) => ['projects', projectRef, 'disk-attributes'] as const, + diskBreakdown: (projectRef: string | undefined) => + ['projects', projectRef, 'disk-breakdown'] as const, diskUtilization: (projectRef: string | undefined) => ['projects', projectRef, 'disk-utilization'] as const, projectCreationPostgresVersions: ( diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 6584073bb36c6..0bc73dd479566 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -22,7 +22,6 @@ import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mu import { useDatabaseSizeQuery } from 'data/database/database-size-query' import { useDatabaseReport } from 'data/reports/database-report-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' @@ -46,7 +45,6 @@ const DatabaseUsage = () => { const { project } = useProjectContext() const diskManagementV2 = useFlag('diskManagementV2') - const org = useSelectedOrganization() const state = useDatabaseSelectorStateSnapshot() const [dateRange, setDateRange] = useState(undefined) @@ -220,23 +218,20 @@ const DatabaseUsage = () => { renderer={(props) => { return (
-

- The data refreshes every 24 hours. -

Space used
{formatBytes(databaseSizeBytes, 2, 'GB')}
-
Total size
+
Provisioned disk size
{currentDiskSize} GB
{showNewDiskManagementUI ? ( @@ -309,11 +304,11 @@ const DatabaseUsage = () => {
From f650b5ae67eb86c524d972b913a085f262a81dd6 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Thu, 5 Dec 2024 17:23:14 +0800 Subject: [PATCH 3/3] Feat/connection string revamp (#30572) * add new page * moar * moar * added icons * improve icons * moved connect dialog to main header * update text * smaller screen support * moar * add python and sqlalchemyString * moar * add IPv4 warning * moar * Delete pooler-icons-v2.tsx * tidy * Delete DatabaseSettings.tsx * tidy * tidy * moar. Session pooler is de-prioritized * Update DatabaseConnectionString.tsx * type issue * moar * Update DatabaseConnectionString.tsx * Spelling * Clean up LayoutHeader * Clean up ConnectionPanel * Clean up ConnectionParameters * Last batch of clean up * Fix loading state padding * Shift old Connect files to new Connect folder outside of Home * Final clean up * Smol fix * FIX * Fix button link * Fixes * Lint --------- Co-authored-by: Terry Sutton Co-authored-by: Joshen Lim --- .../{Home => }/Connect/Connect.constants.ts | 61 +++ .../interfaces/{Home => }/Connect/Connect.tsx | 55 +- .../{Home => }/Connect/Connect.types.ts | 0 .../{Home => }/Connect/Connect.utils.ts | 0 .../interfaces/Connect/ConnectDropdown.tsx | 98 ++++ .../{Home => }/Connect/ConnectTabContent.tsx | 11 +- .../{Home => }/Connect/ConnectTabs.tsx | 11 +- .../{Home => }/Connect/ConnectionIcon.tsx | 9 +- .../interfaces/Connect/ConnectionPanel.tsx | 265 ++++++++++ .../Connect/ConnectionParameters.tsx | 91 ++++ .../Connect/DatabaseConnectionString.tsx | 477 +++++++++++++++++ .../Connect/DatabaseSettings.utils.ts | 388 ++++++++++++++ .../Connect/DirectConnectionExamples.tsx | 153 ++++++ .../interfaces/Connect/PoolerIcons.tsx | 485 ++++++++++++++++++ .../androidkotlin/supabasekt/content.tsx | 4 +- .../content/astro/supabasejs/content.tsx | 4 +- .../Connect/content/drizzle/content.tsx | 4 +- .../exporeactnative/supabasejs/content.tsx | 4 +- .../flutter/supabaseflutter/content.tsx | 4 +- .../ionicangular/supabasejs/content.tsx | 4 +- .../content/ionicreact/supabasejs/content.tsx | 4 +- .../content/nextjs/app/supabasejs/content.tsx | 4 +- .../nextjs/pages/supabasejs/content.tsx | 8 +- .../content/nuxt/supabasejs/content.tsx | 4 +- .../Connect/content/prisma/content.tsx | 4 +- .../create-react-app/supabasejs/content.tsx | 4 +- .../content/react/vite/supabasejs/content.tsx | 4 +- .../content/refine/supabasejs/content.tsx | 4 +- .../content/remix/supabasejs/content.tsx | 4 +- .../content/solidjs/supabasejs/content.tsx | 4 +- .../content/sveltekit/supabasejs/content.tsx | 4 +- .../content/swift/supabaseswift/content.tsx | 4 +- .../content/vuejs/supabasejs/content.tsx | 4 +- .../Home/Connect/ConnectDropdown.tsx | 95 ---- .../Database/ConnectionStringMoved.tsx | 53 ++ .../DatabaseConnectionString.tsx | 3 + .../AppLayout/OrganizationDropdown.tsx | 14 +- .../layouts/AppLayout/ProjectDropdown.tsx | 96 ++-- .../LayoutHeader/LayoutHeader.tsx | 119 ++--- .../studio/components/ui/DatabaseSelector.tsx | 42 +- apps/studio/components/ui/Panel.tsx | 4 +- apps/studio/lib/constants/telemetry.ts | 2 + apps/studio/pages/project/[ref]/index.tsx | 9 +- .../pages/project/[ref]/settings/database.tsx | 24 +- apps/studio/state/app-state.ts | 7 + apps/studio/styles/main.scss | 5 - .../ui/src/components/CodeBlock/CodeBlock.tsx | 47 +- .../ui/src/components/shadcn/ui/select.tsx | 3 +- 48 files changed, 2369 insertions(+), 338 deletions(-) rename apps/studio/components/interfaces/{Home => }/Connect/Connect.constants.ts (78%) rename apps/studio/components/interfaces/{Home => }/Connect/Connect.tsx (85%) rename apps/studio/components/interfaces/{Home => }/Connect/Connect.types.ts (100%) rename apps/studio/components/interfaces/{Home => }/Connect/Connect.utils.ts (100%) create mode 100644 apps/studio/components/interfaces/Connect/ConnectDropdown.tsx rename apps/studio/components/interfaces/{Home => }/Connect/ConnectTabContent.tsx (92%) rename apps/studio/components/interfaces/{Home => }/Connect/ConnectTabs.tsx (81%) rename apps/studio/components/interfaces/{Home => }/Connect/ConnectionIcon.tsx (89%) create mode 100644 apps/studio/components/interfaces/Connect/ConnectionPanel.tsx create mode 100644 apps/studio/components/interfaces/Connect/ConnectionParameters.tsx create mode 100644 apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx create mode 100644 apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts create mode 100644 apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx create mode 100644 apps/studio/components/interfaces/Connect/PoolerIcons.tsx rename apps/studio/components/interfaces/{Home => }/Connect/content/androidkotlin/supabasekt/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/astro/supabasejs/content.tsx (91%) rename apps/studio/components/interfaces/{Home => }/Connect/content/drizzle/content.tsx (92%) rename apps/studio/components/interfaces/{Home => }/Connect/content/exporeactnative/supabasejs/content.tsx (94%) rename apps/studio/components/interfaces/{Home => }/Connect/content/flutter/supabaseflutter/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/ionicangular/supabasejs/content.tsx (96%) rename apps/studio/components/interfaces/{Home => }/Connect/content/ionicreact/supabasejs/content.tsx (95%) rename apps/studio/components/interfaces/{Home => }/Connect/content/nextjs/app/supabasejs/content.tsx (96%) rename apps/studio/components/interfaces/{Home => }/Connect/content/nextjs/pages/supabasejs/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/nuxt/supabasejs/content.tsx (92%) rename apps/studio/components/interfaces/{Home => }/Connect/content/prisma/content.tsx (89%) rename apps/studio/components/interfaces/{Home => }/Connect/content/react/create-react-app/supabasejs/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/react/vite/supabasejs/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/refine/supabasejs/content.tsx (95%) rename apps/studio/components/interfaces/{Home => }/Connect/content/remix/supabasejs/content.tsx (94%) rename apps/studio/components/interfaces/{Home => }/Connect/content/solidjs/supabasejs/content.tsx (92%) rename apps/studio/components/interfaces/{Home => }/Connect/content/sveltekit/supabasejs/content.tsx (93%) rename apps/studio/components/interfaces/{Home => }/Connect/content/swift/supabaseswift/content.tsx (92%) rename apps/studio/components/interfaces/{Home => }/Connect/content/vuejs/supabasejs/content.tsx (92%) delete mode 100644 apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/ConnectionStringMoved.tsx diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.constants.ts b/apps/studio/components/interfaces/Connect/Connect.constants.ts similarity index 78% rename from apps/studio/components/interfaces/Home/Connect/Connect.constants.ts rename to apps/studio/components/interfaces/Connect/Connect.constants.ts index 80933e20cef18..4d94d82a20a5b 100644 --- a/apps/studio/components/interfaces/Home/Connect/Connect.constants.ts +++ b/apps/studio/components/interfaces/Connect/Connect.constants.ts @@ -1,3 +1,63 @@ +import { CodeBlockLang } from 'ui' + +export type DatabaseConnectionType = + | 'uri' + | 'psql' + | 'golang' + | 'jdbc' + | 'dotnet' + | 'nodejs' + | 'php' + | 'python' + | 'sqlalchemy' + +export const DATABASE_CONNECTION_TYPES: { + id: DatabaseConnectionType + label: string + contentType: 'input' | 'code' + lang: CodeBlockLang + fileTitle: string | undefined +}[] = [ + { id: 'uri', label: 'URI', contentType: 'input', lang: 'bash', fileTitle: undefined }, + { id: 'psql', label: 'PSQL', contentType: 'code', lang: 'bash', fileTitle: undefined }, + { id: 'golang', label: 'Golang', contentType: 'code', lang: 'go', fileTitle: '.env' }, + { id: 'jdbc', label: 'JDBC', contentType: 'input', lang: 'bash', fileTitle: undefined }, + { + id: 'dotnet', + label: '.NET', + contentType: 'code', + lang: 'csharp', + fileTitle: 'appsettings.json', + }, + { id: 'nodejs', label: 'Node.js', contentType: 'code', lang: 'js', fileTitle: '.env' }, + { id: 'php', label: 'PHP', contentType: 'code', lang: 'php', fileTitle: '.env' }, + { id: 'python', label: 'Python', contentType: 'code', lang: 'python', fileTitle: '.env' }, + { id: 'sqlalchemy', label: 'SQLAlchemy', contentType: 'code', lang: 'python', fileTitle: '.env' }, +] + +export const CONNECTION_PARAMETERS = { + host: { + key: 'host', + description: 'The hostname of your database', + }, + port: { + key: 'port', + description: 'Port number for the connection', + }, + database: { + key: 'database', + description: 'Default database name', + }, + user: { + key: 'user', + description: 'Database user', + }, + pool_mode: { + key: 'pool_mode', + description: 'Connection pooling behavior', + }, +} as const + export type ConnectionType = { key: string icon: string @@ -283,6 +343,7 @@ export const ORMS: ConnectionType[] = [ ] export const CONNECTION_TYPES = [ + { key: 'direct', label: 'Connection String', obj: [] }, { key: 'frameworks', label: 'App Frameworks', obj: FRAMEWORKS }, { key: 'mobiles', label: 'Mobile Frameworks', obj: MOBILES }, { key: 'orms', label: 'ORMs', obj: ORMS }, diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx similarity index 85% rename from apps/studio/components/interfaces/Home/Connect/Connect.tsx rename to apps/studio/components/interfaces/Connect/Connect.tsx index bcab28afdc2d2..9a4c306e578a9 100644 --- a/apps/studio/components/interfaces/Home/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -3,15 +3,17 @@ import { useParams } from 'common' import { ExternalLink, Plug } from 'lucide-react' import { useState } from 'react' -import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' -import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' +import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString' +import { DatabaseConnectionString as OldDatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' + import Panel from 'components/ui/Panel' import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useFlag } from 'hooks/ui/useFlag' +import { useAppStateSnapshot } from 'state/app-state' import { Button, DIALOG_PADDING_X, - DIALOG_PADDING_X_SMALL, DIALOG_PADDING_Y, Dialog, DialogContent, @@ -31,7 +33,9 @@ import ConnectDropdown from './ConnectDropdown' import ConnectTabContent from './ConnectTabContent' const Connect = () => { + const state = useAppStateSnapshot() const { ref: projectRef } = useParams() + const connectDialogUpdate = useFlag('connectDialogUpdate') const [connectionObject, setConnectionObject] = useState(FRAMEWORKS) const [selectedParent, setSelectedParent] = useState(connectionObject[0].key) // aka nextjs @@ -145,14 +149,18 @@ const Connect = () => { return ( <> - + - - + Connect to your project Get the connection strings and environment variables for your app @@ -163,10 +171,7 @@ const Connect = () => { defaultValue="direct" onValueChange={(value) => handleConnectionType(value)} > - - - Connection String - + {CONNECTION_TYPES.map((type) => ( {type.label} @@ -183,6 +188,26 @@ const Connect = () => { .find((parent) => parent.key === selectedParent) ?.children.find((child) => child.key === selectedChild)?.children.length || 0) > 0 + if (type.key === 'direct') { + return ( + +
+ {connectDialogUpdate ? ( + + ) : ( +
+ +
+ )} +
+
+ ) + } + return ( { className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')} >
-
+
{ ) })} - - -
- ) } diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.types.ts b/apps/studio/components/interfaces/Connect/Connect.types.ts similarity index 100% rename from apps/studio/components/interfaces/Home/Connect/Connect.types.ts rename to apps/studio/components/interfaces/Connect/Connect.types.ts diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.utils.ts b/apps/studio/components/interfaces/Connect/Connect.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Home/Connect/Connect.utils.ts rename to apps/studio/components/interfaces/Connect/Connect.utils.ts diff --git a/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx b/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx new file mode 100644 index 0000000000000..3a17f63e65956 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx @@ -0,0 +1,98 @@ +import { Box, Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' + +import { + Button, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, +} from 'ui' +import { ConnectionIcon } from './ConnectionIcon' + +interface ConnectDropdownProps { + state: string + updateState: (state: string) => void + label: string + items: any[] +} + +const ConnectDropdown = ({ + state, + updateState, + label, + + items, +}: ConnectDropdownProps) => { + const [open, setOpen] = useState(false) + + function onSelectLib(key: string) { + updateState(key) + setOpen(false) + } + + const selectedItem = items.find((item) => item.key === state) + + return ( + +
+ + {label} + + + + +
+ + + + + No results found. + + {items.map((item) => ( + { + onSelectLib(item.key) + setOpen(false) + }} + className="flex gap-2 items-center" + > + {item.icon ? : } + {item.label} + + + ))} + + + + +
+ ) +} + +export default ConnectDropdown diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx rename to apps/studio/components/interfaces/Connect/ConnectTabContent.tsx index 005b806419eaa..9237c6ecf6cf1 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic' -import { forwardRef, HTMLAttributes } from 'react' +import { forwardRef, HTMLAttributes, useMemo } from 'react' import { useParams } from 'common' import { getConnectionStrings } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils' @@ -49,16 +49,15 @@ const ConnectTabContentNew = forwardRef( const connectionStringPoolerTransaction = connectionStringsPooler.uri const connectionStringPoolerSession = connectionStringsPooler.uri.replace('6543', '5432') - const ContentFile = dynamic( - () => import(`./content/${filePath}/content`), - { + const ContentFile = useMemo(() => { + return dynamic(() => import(`./content/${filePath}/content`), { loading: () => (
), - } - ) + }) + }, [filePath]) return (
diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx b/apps/studio/components/interfaces/Connect/ConnectTabs.tsx similarity index 81% rename from apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx rename to apps/studio/components/interfaces/Connect/ConnectTabs.tsx index f5871de3f9147..ec7b1b5a61fe8 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabs.tsx @@ -1,8 +1,7 @@ -import { Tabs_Shadcn_ } from 'ui' import { FileJson2 } from 'lucide-react' -import { TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' -import { TabsContent_Shadcn_ } from 'ui' -import React, { ReactNode } from 'react' +import { isValidElement, ReactNode } from 'react' + +import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' interface ConnectTabTriggerProps { value: string @@ -22,7 +21,7 @@ interface ConnectTabContentProps { const ConnectTabs = ({ children }: ConnectFileTabProps) => { const firstChild = children[0] - const defaultValue = React.isValidElement(firstChild) + const defaultValue = isValidElement(firstChild) ? (firstChild.props as any)?.children[0]?.props?.value || '' : null @@ -57,4 +56,4 @@ export const ConnectTabContent = ({ value, children }: ConnectTabContentProps) = ) } -export { ConnectTabTrigger, ConnectTabTriggers, ConnectTabs } +export { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers } diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx b/apps/studio/components/interfaces/Connect/ConnectionIcon.tsx similarity index 89% rename from apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx rename to apps/studio/components/interfaces/Connect/ConnectionIcon.tsx index a79acebc468bf..fd2452d29619d 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionIcon.tsx @@ -1,12 +1,13 @@ -import { BASE_PATH } from 'lib/constants' - import { useTheme } from 'next-themes' import Image from 'next/image' + +import { BASE_PATH } from 'lib/constants' + interface ConnectionIconProps { connection: any } -const ConnectionIcon = ({ connection }: ConnectionIconProps) => { +export const ConnectionIcon = ({ connection }: ConnectionIconProps) => { const { resolvedTheme } = useTheme() const imageFolder = ['ionic-angular'].includes(connection) ? 'icons/frameworks' : 'libraries' @@ -28,5 +29,3 @@ const ConnectionIcon = ({ connection }: ConnectionIconProps) => { /> ) } - -export default ConnectionIcon diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx new file mode 100644 index 0000000000000..bce5602468da8 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -0,0 +1,265 @@ +import { ChevronRight, FileCode, X } from 'lucide-react' +import Link from 'next/link' + +import { + Button, + cn, + CodeBlock, + CodeBlockLang, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + WarningIcon, +} from 'ui' +import { ConnectionParameters } from './ConnectionParameters' +import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons' + +interface ConnectionPanelProps { + type?: 'direct' | 'transaction' | 'session' + title: string + description: string + connectionString: string + ipv4Status: { + type: 'error' | 'success' + title: string + description?: string + link?: { text: string; url: string } + } + notice?: string[] + parameters?: Array<{ + key: string + value: string + description?: string + }> + contentType?: 'input' | 'code' + lang?: CodeBlockLang + fileTitle?: string + onCopyCallback: () => void +} + +const IPv4StatusIcon = ({ className, active }: { className?: string; active: boolean }) => { + return ( +
+ + + + + {!active ? ( +
+ +
+ ) : ( +
+ + + +
+ )} +
+ ) +} + +export const CodeBlockFileHeader = ({ title }: { title: string }) => { + return ( +
+
+ + {title} +
+
+ ) +} + +export const ConnectionPanel = ({ + type = 'direct', + title, + description, + connectionString, + ipv4Status, + notice, + parameters = [], + lang = 'bash', + fileTitle, + onCopyCallback, +}: ConnectionPanelProps) => { + return ( +
+
+

{title}

+

{description}

+
+ {fileTitle && } + + {notice && ( +
+ {notice?.map((text: string) => ( +

+ {text} +

+ ))} +
+ )} + {parameters.length > 0 && } +
+
+
+
+ {type !== 'session' && ( + <> +
+
+ {type === 'transaction' ? : } +
+
+ + {type === 'transaction' + ? 'Suitable for a large number of connected clients' + : 'Suitable for long-lived, persistent connections'} + +
+
+
+
+ + {type === 'transaction' + ? 'Pre-warmed connection pool to Postgres' + : 'Each client has a dedicated connection to Postgres'} + +
+
+ + )} + +
+
+ +
+
+ {ipv4Status.title} + {ipv4Status.description && ( + {ipv4Status.description} + )} + {ipv4Status.type === 'error' && ( + + Use Session Pooler if on a IPv4 network or purchase IPv4 addon + + )} + {ipv4Status.link && ( +
+ +
+ )} +
+
+ + {type === 'session' && ( +
+
+ +
+
+ Only use on a IPv4 network + + Use Direct Connection if connecting via an IPv6 network + +
+
+ )} + + {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/Connect/ConnectionParameters.tsx b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx new file mode 100644 index 0000000000000..6a5e4ba168717 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx @@ -0,0 +1,91 @@ +import { Check, ChevronRight, Copy } from 'lucide-react' +import { useState } from 'react' + +import { copyToClipboard } from 'lib/helpers' +import { + Button, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Separator, +} from 'ui' + +interface Parameter { + key: string + value: string + description?: string +} + +interface ConnectionParametersProps { + parameters: Parameter[] +} + +export const ConnectionParameters = ({ parameters }: ConnectionParametersProps) => { + const [isOpen, setIsOpen] = useState(false) + const [copiedMap, setCopiedMap] = useState>({}) + + return ( + + + + + +
+ {parameters.map((param) => ( +
+
+ {param.key}: + {param.value} + +
+
+ ))} +
+ +
+ For security reasons, your database password is never shown. +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx new file mode 100644 index 0000000000000..cd489d0dc0be4 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -0,0 +1,477 @@ +import { ChevronDown } from 'lucide-react' +import { HTMLAttributes, ReactNode, useState } from 'react' + +import { useParams } from 'common' +import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import AlertError from 'components/ui/AlertError' +import DatabaseSelector from 'components/ui/DatabaseSelector' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { pluckObjectFields } from 'lib/helpers' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import { + CodeBlock, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, + DIALOG_PADDING_X, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, + Separator, + cn, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { + CONNECTION_PARAMETERS, + DATABASE_CONNECTION_TYPES, + DatabaseConnectionType, +} from './Connect.constants' +import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' +import { getConnectionStrings, getPoolerTld } from './DatabaseSettings.utils' +import examples, { Example } from './DirectConnectionExamples' + +const StepLabel = ({ + number, + children, + ...props +}: { number: number; children: ReactNode } & HTMLAttributes) => ( +
+
+ {number} +
+ {children} +
+) + +export const DatabaseConnectionString = () => { + const { ref: projectRef } = useParams() + const state = useDatabaseSelectorStateSnapshot() + + const [selectedTab, setSelectedTab] = useState('uri') + + const { + data: poolingInfo, + error: poolingInfoError, + isLoading: isLoadingPoolingInfo, + isError: isErrorPoolingInfo, + isSuccess: isSuccessPoolingInfo, + } = usePoolingConfigurationQuery({ + projectRef, + }) + const poolingConfiguration = poolingInfo?.find((x) => x.identifier === state.selectedDatabaseId) + + const { + data: databases, + error: readReplicasError, + isLoading: isLoadingReadReplicas, + isError: isErrorReadReplicas, + isSuccess: isSuccessReadReplicas, + } = useReadReplicasQuery({ projectRef }) + + const error = poolingInfoError || readReplicasError + const isLoading = isLoadingPoolingInfo || isLoadingReadReplicas + const isError = isErrorPoolingInfo || isErrorReadReplicas + const isSuccess = isSuccessPoolingInfo && isSuccessReadReplicas + + const selectedDatabase = (databases ?? []).find( + (db) => db.identifier === state.selectedDatabaseId + ) + + const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) + + const { mutate: sendEvent } = useSendEventMutation() + + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + const connectionInfo = pluckObjectFields(selectedDatabase || emptyState, DB_FIELDS) + + const handleCopy = (id: string) => { + const labelValue = DATABASE_CONNECTION_TYPES.find((type) => type.id === id)?.label + sendEvent({ + category: 'settings', + action: 'copy_connection_string', + label: labelValue ?? '', + }) + } + + const connectionStrings = + isSuccessPoolingInfo && poolingConfiguration !== undefined + ? getConnectionStrings(connectionInfo, poolingConfiguration, { + projectRef, + }) + : { + direct: { + uri: '', + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + pooler: { + uri: '', + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + } + + const poolerTld = + isSuccessPoolingInfo && poolingConfiguration !== undefined + ? getPoolerTld(poolingConfiguration?.connectionString) + : 'com' + + // @mildtomato - Possible reintroduce later + // + // const poolerConnStringSyntax = + // isSuccessPoolingInfo && poolingConfiguration !== undefined + // ? constructConnStringSyntax(poolingConfiguration?.connectionString, { + // selectedTab, + // usePoolerConnection: snap.usePoolerConnection, + // ref: projectRef as string, + // cloudProvider: isProjectLoading ? '' : project?.cloud_provider || '', + // region: isProjectLoading ? '' : project?.region || '', + // tld: snap.usePoolerConnection ? poolerTld : connectionTld, + // portNumber: `[5432 or 6543]`, + // }) + // : [] + // useEffect(() => { + // // if (poolingConfiguration?.pool_mode === 'session') { + // // setPoolingMode(poolingConfiguration.pool_mode) + // // } + // }, [poolingConfiguration?.pool_mode]) + + const lang = DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.lang ?? 'bash' + const contentType = + DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.contentType ?? 'input' + + const example: Example | undefined = examples[selectedTab as keyof typeof examples] + + const exampleFiles = example?.files + const exampleInstallCommands = example?.installCommands + const examplePostInstallCommands = example?.postInstallCommands + const hasCodeExamples = exampleFiles || exampleInstallCommands + const fileTitle = DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.fileTitle + + // [Refactor] See if we can do this in an immutable way, technically not a good practice to do this + let stepNumber = 0 + + return ( +
+
+
+ + Type + + + setSelectedTab(connectionType) + } + > + + + + + {DATABASE_CONNECTION_TYPES.map((type) => ( + + {type.label} + + ))} + + +
+ +
+ + {isLoading && ( +
+ +
+ )} + + {isError && ( +
+ +
+ )} + + {isSuccess && ( +
+ {/* // handle non terminal examples */} + {hasCodeExamples && ( +
+
+ + Install the following + + {exampleInstallCommands?.map((cmd, i) => ( + + {cmd} + + ))} +
+ {exampleFiles && exampleFiles?.length > 0 && ( +
+ + Add file to project + + {exampleFiles?.map((file, i) => ( +
+ + +
+ ))} +
+ )} +
+ )} + +
+ {hasCodeExamples && ( +
+ Choose type of connection +
+ )} +
+ handleCopy(selectedTab)} + /> + handleCopy(selectedTab)} + /> + {ipv4Addon && ( + +

+ If you are using Session Pooler, we recommend switching to Direct Connection. +

+
+ )} + + handleCopy(selectedTab)} + /> +
+
+ {examplePostInstallCommands && ( +
+
+ + Add the configuration package to read the settings + + {examplePostInstallCommands?.map((cmd, i) => ( + + {cmd} + + ))} +
+
+ )} +
+ )} + + {/* Possibly reintroduce later - @mildtomato */} + {/* + + +
+

+ How to connect to a different database or switch to another user +

+ +
+
+ +
+

+ You can use the following URI format to switch to a different database or user + {snap.usePoolerConnection ? ' when using connection pooling' : ''}. +

+

+ {poolerConnStringSyntax.map((x, idx) => { + if (x.tooltip) { + return ( + + + {x.value} + + {x.tooltip} + + ) + } else { + return ( + + {x.value} + + ) + } + })} +

+
+
+
*/} + + {selectedTab === 'python' && ( + <> + + + +
+

+ Connecting to SQL Alchemy +

+ +
+
+ +
+

+ Please use postgresql:// instead of postgres:// as your + dialect when connecting via SQLAlchemy. +

+

+ Example: + create_engine("postgresql+psycopg2://...") +

+

+
+
+
+ + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts new file mode 100644 index 0000000000000..398789c05d7b3 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts @@ -0,0 +1,388 @@ +import type { PoolingConfiguration } from 'data/database/pooling-configuration-query' + +type ConnectionStrings = { + psql: string + uri: string + golang: string + jdbc: string + dotnet: string + nodejs: string + php: string + python: string + sqlalchemy: string +} + +export const getConnectionStrings = ( + connectionInfo: { + db_user: string + db_port: number + db_host: string + db_name: string + }, + poolingInfo: PoolingConfiguration, + metadata: { + projectRef?: string + pgVersion?: string + } +): { + direct: ConnectionStrings + pooler: ConnectionStrings +} => { + const isMd5 = poolingInfo.connectionString.includes('options=reference') + const { projectRef } = metadata + const password = '[YOUR-PASSWORD]' + + // Direct connection variables + const directUser = connectionInfo.db_user + const directPort = connectionInfo.db_port + const directHost = connectionInfo.db_host + const directName = connectionInfo.db_name + + // Pooler connection variables + const poolerUser = poolingInfo.db_user + const poolerPort = poolingInfo.db_port + const poolerHost = poolingInfo.db_host + const poolerName = poolingInfo.db_name + + // Direct connection strings + const directPsqlString = isMd5 + ? `psql "postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}"` + : `psql -h ${directHost} -p ${directPort} -d ${directName} -U ${directUser}` + + const directUriString = `postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}` + + const directGolangString = `DATABASE_URL=${poolingInfo.connectionString}` + + const directJdbcString = `jdbc:postgresql://${directHost}:${directPort}/${directName}?user=${directUser}&password=${password}` + + // User Id=${directUser};Password=${password};Server=${directHost};Port=${directPort};Database=${directName}` + const directDotNetString = `{ + "ConnectionStrings": { + "DefaultConnection": "Host=${directHost};Database=${directName};Username=${directUser};Password=${password};SSL Mode=Require;Trust Server Certificate=true" + } +}` + + // `User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}` + const poolerDotNetString = `{ + "ConnectionStrings": { + "DefaultConnection": "User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}" + } +}` + + // Pooler connection strings + const poolerPsqlString = isMd5 + ? `psql "postgresql://${poolerUser}:${password}@${poolerHost}:${poolerPort}/${poolerName}?options=reference%3D${projectRef}"` + : `psql -h ${poolerHost} -p ${poolerPort} -d ${poolerName} -U ${poolerUser}.${projectRef}` + + const poolerUriString = poolingInfo.connectionString + + const nodejsPoolerUriString = `DATABASE_URL=${poolingInfo.connectionString}` + + const poolerGolangString = `user=${poolerUser} +password=${password} +host=${poolerHost} +port=${poolerPort} +dbname=${poolerName}${isMd5 ? `options=reference=${projectRef}` : ''}` + + const poolerJdbcString = `jdbc:postgresql://${poolerHost}:${poolerPort}/${poolerName}?user=${poolerUser}${isMd5 ? `&options=reference%3D${projectRef}` : ''}&password=${password}` + + const sqlalchemyString = `user=${directUser} +password=${password} +host=${directHost} +port=${directPort} +dbname=${directName}` + + const poolerSqlalchemyString = `user=${poolerUser} +password=${password} +host=${poolerHost} +port=${poolerPort} +dbname=${poolerName}` + + return { + direct: { + psql: directPsqlString, + uri: directUriString, + golang: directGolangString, + jdbc: directJdbcString, + dotnet: directDotNetString, + nodejs: nodejsPoolerUriString, + php: directGolangString, + python: directGolangString, + sqlalchemy: sqlalchemyString, + }, + pooler: { + psql: poolerPsqlString, + uri: poolerUriString, + golang: poolerGolangString, + jdbc: poolerJdbcString, + dotnet: poolerDotNetString, + nodejs: nodejsPoolerUriString, + php: poolerGolangString, + python: poolerGolangString, + sqlalchemy: poolerSqlalchemyString, + }, + } +} + +const DB_USER_DESC = 'Database user (e.g postgres)' +const DB_PASS_DESC = 'Database password' +const DB_NAME_DESC = 'Database name (e.g postgres)' +const PROJECT_REF_DESC = "Project's reference ID" +const PORT_NUMBER_DESC = 'Port number (Use 5432 if using prepared statements)' + +// [Joshen] This is to the best of interpreting the syntax from the API response +// // There's different format for PG13 (depending on authentication method being md5) and PG14 +export const constructConnStringSyntax = ( + connString: string, + { + selectedTab, + usePoolerConnection, + ref, + cloudProvider, + region, + tld, + portNumber, + }: { + selectedTab: 'uri' | 'psql' | 'golang' | 'jdbc' | 'dotnet' | 'nodejs' | 'php' | 'python' + usePoolerConnection: boolean + ref: string + cloudProvider: string + region: string + tld: string + portNumber: string + } +) => { + const isMd5 = connString.includes('options=reference') + const poolerHostDetails = [ + { value: cloudProvider.toLocaleLowerCase(), tooltip: 'Cloud provider' }, + { value: '-0-', tooltip: undefined }, + { value: region, tooltip: "Project's region" }, + { value: `.pooler.supabase.${tld}`, tooltip: undefined }, + ] + const dbHostDetails = [ + { value: 'db.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + { value: `.supabase.${tld}`, tooltip: undefined }, + ] + + if (selectedTab === 'uri' || selectedTab === 'nodejs') { + if (isMd5) { + return [ + { value: 'postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: `?options=reference%3D`, tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + if (selectedTab === 'psql') { + if (isMd5) { + return [ + { value: 'psql "postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: '?options=reference%3D', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'psql -h ', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' -p ', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' -d ', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: ' -U ', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } + } + + if (selectedTab === 'golang' || selectedTab === 'php' || selectedTab === 'python') { + if (isMd5) { + return [ + { value: 'user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ' password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ' host=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' dbname=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: ' options=reference=', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ' password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ' host=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' dbname=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + if (selectedTab === 'jdbc') { + if (isMd5) { + return [ + { value: 'jdbc:postgresql://', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: '?user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: '&password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + ...(usePoolerConnection + ? [ + { value: '&options=reference%3D', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'jdbc:postgresql://', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: `:`, tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: '?user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: '&password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + ] + } + } + + if (selectedTab === 'dotnet') { + if (isMd5) { + return [ + { value: 'User Id=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ';Password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ';Server=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ';Port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ';Database=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: ";Options='reference=", tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + { value: "'", tooltip: undefined }, + ] + : []), + ] + } else { + return [ + { value: 'User Id=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ';Password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ';Server=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ';Port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ';Database=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + return [] +} + +export const getPoolerTld = (connString: string) => { + try { + const segment = connString.split('pooler.supabase.')[1] + const tld = segment.split(':6543')[0] + return tld + } catch { + return 'com' + } +} diff --git a/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx b/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx new file mode 100644 index 0000000000000..80084d1337d57 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx @@ -0,0 +1,153 @@ +export type Example = { + installCommands?: string[] + postInstallCommands?: string[] + files?: { + name: string + content: string + }[] +} + +const examples = { + nodejs: { + installCommands: ['npm install postgres'], + files: [ + { + name: 'db.js', + content: `import postgres from 'postgres' + +const connectionString = process.env.DATABASE_URL +const sql = postgres(connectionString) + +export default sql`, + }, + ], + }, + golang: { + installCommands: ['go get github.com/jackc/pgx/v5'], + files: [ + { + name: 'main.go', + content: `package main + +import ( + "context" + "log" + "os" + "github.com/jackc/pgx/v5" +) + +func main() { + conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + log.Fatalf("Failed to connect to the database: %v", err) + } + defer conn.Close(context.Background()) + + // Example query to test connection + var version string + if err := conn.QueryRow(context.Background(), "SELECT version()").Scan(&version); err != nil { + log.Fatalf("Query failed: %v", err) + } + + log.Println("Connected to:", version) +}`, + }, + ], + }, + dotnet: { + installCommands: [ + 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', + ], + postInstallCommands: [ + 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', + ], + }, + python: { + installCommands: ['pip install python-dotenv psycopg2'], + files: [ + { + name: 'main.py', + content: `import psycopg2 +from dotenv import load_dotenv +import os + +# Load environment variables from .env +load_dotenv() + +# Fetch variables +USER = os.getenv("user") +PASSWORD = os.getenv("password") +HOST = os.getenv("host") +PORT = os.getenv("port") +DBNAME = os.getenv("dbname") + +# Connect to the database +try: + connection = psycopg2.connect( + user=USER, + password=PASSWORD, + host=HOST, + port=PORT, + dbname=DBNAME + ) + print("Connection successful!") + + # Create a cursor to execute SQL queries + cursor = connection.cursor() + + # Example query + cursor.execute("SELECT NOW();") + result = cursor.fetchone() + print("Current Time:", result) + + # Close the cursor and connection + cursor.close() + connection.close() + print("Connection closed.") + +except Exception as e: + print(f"Failed to connect: {e}")`, + }, + ], + }, + sqlalchemy: { + installCommands: ['pip install python-dotenv sqlalchemy psycopg2'], + files: [ + { + name: 'main.py', + content: `from sqlalchemy import create_engine +# from sqlalchemy.pool import NullPool +from dotenv import load_dotenv +import os + +# Load environment variables from .env +load_dotenv() + +# Fetch variables +USER = os.getenv("user") +PASSWORD = os.getenv("password") +HOST = os.getenv("host") +PORT = os.getenv("port") +DBNAME = os.getenv("dbname") + +# Construct the SQLAlchemy connection string +DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require" + +# Create the SQLAlchemy engine +engine = create_engine(DATABASE_URL) +# If using Transaction Pooler or Session Pooler, we want to ensure we disable SQLAlchemy client side pooling - +# https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations +# engine = create_engine(DATABASE_URL, poolclass=NullPool) + +# Test the connection +try: + with engine.connect() as connection: + print("Connection successful!") +except Exception as e: + print(f"Failed to connect: {e}")`, + }, + ], + }, +} + +export default examples diff --git a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx new file mode 100644 index 0000000000000..945052bbeb987 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx @@ -0,0 +1,485 @@ +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/Home/Connect/content/androidkotlin/supabasekt/content.tsx b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/androidkotlin/supabasekt/content.tsx rename to apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx index bcb90e3c9a5e8..9e1be901e598c 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/androidkotlin/supabasekt/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx similarity index 91% rename from apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx index a88f865dde21f..1ce13c63f32b0 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx b/apps/studio/components/interfaces/Connect/content/drizzle/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx rename to apps/studio/components/interfaces/Connect/content/drizzle/content.tsx index de06febc66033..5f33d70a75935 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/drizzle/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ connectionStringPooler }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx similarity index 94% rename from apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx index 00fc587397770..fdae1dcdba17a 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx rename to apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx index 8ae2c76a4d066..2eb7d6651eea0 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx similarity index 96% rename from apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx index 4a51e5f27a1b3..3beda75cbd043 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx similarity index 95% rename from apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx index 193a6e6719d98..0971e6979bcef 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx similarity index 96% rename from apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx index 2ff11fda1fcd8..e6d1be4fc26c8 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx index f22c1474db0b5..5ad87160931cb 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx @@ -1,12 +1,12 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' +import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' import { + ConnectTabContent, ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, - ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' -import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' +} from 'components/interfaces/Connect/ConnectTabs' const ContentFile = ({ projectKeys }: ContentFileProps) => { return ( diff --git a/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx index 6b0428be949f6..b07237bad6ead 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx b/apps/studio/components/interfaces/Connect/content/prisma/content.tsx similarity index 89% rename from apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx rename to apps/studio/components/interfaces/Connect/content/prisma/content.tsx index 1094ad05e2465..56c5371b9dbd8 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/prisma/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ connectionStringPooler }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx index de20afa32849d..c5b84f69027dc 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx index f7b0c82ff92f7..465d8bf097858 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx similarity index 95% rename from apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx index 6c5cd2ea118dd..c87fdff6bdaa7 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx similarity index 94% rename from apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx index 4487cf53afdf4..324eac69426eb 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx index 9ab97360e6f8e..9cd17ddd2576e 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx index 7b058645c0767..5f326bfb8e18a 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx rename to apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx index 43ced097a497a..83b5c4f0a39ae 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx index 5ab61da9c8ae7..9f10023c13557 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx deleted file mode 100644 index 7f4217fb52a9e..0000000000000 --- a/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Box, Check, ChevronDown } from 'lucide-react' -import { useState } from 'react' -import { - Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - cn, -} from 'ui' -import ConnectionIcon from './ConnectionIcon' - -interface ConnectDropdownProps { - state: string - updateState: (state: string) => void - label: string - items: any[] -} - -const ConnectDropdown = ({ - state, - updateState, - label, - - items, -}: ConnectDropdownProps) => { - const [open, setOpen] = useState(false) - - function onSelectLib(key: string) { - updateState(key) - setOpen(false) - } - - const selectedItem = items.find((item) => item.key === state) - - return ( - <> - -
- - {label} - - - - -
- - - - - No results found. - - {items.map((item) => ( - { - onSelectLib(item.key) - setOpen(false) - }} - className="flex gap-2 items-center" - > - {item.icon ? : } - {item.label} - - - ))} - - - - -
- - ) -} - -export default ConnectDropdown diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionStringMoved.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionStringMoved.tsx new file mode 100644 index 0000000000000..3852592b86259 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionStringMoved.tsx @@ -0,0 +1,53 @@ +import { Button } from 'ui' +import { Plug, GitBranch, ChevronsUpDown, Pointer } from 'lucide-react' + +export const ConnectionStringMoved = () => { + return ( +
+
+

Connection string has moved

+

+ You can find Project connect details by clicking 'Connect' in the top bar +

+
+
+
+
+
+
+
+ + + +
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx index ebfa5449d4365..14793602a1952 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx @@ -56,6 +56,9 @@ interface DatabaseConnectionStringProps { appearance: 'default' | 'minimal' } +/** + * @deprecated Will be removed once `connectDialogUpdate` flag is persisted + */ export const DatabaseConnectionString = ({ appearance }: DatabaseConnectionStringProps) => { const project = useSelectedProject() const { ref: projectRef, connectionString } = useParams() diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index e870b0801225d..039c9ff5d81e8 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -50,14 +50,12 @@ const OrganizationDropdown = ({ isNewNav = false }: OrganizationDropdownProps) =
-
- -
+
diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 5e477b21b4a58..e2d465bcaa6d2 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -117,58 +117,56 @@ const ProjectDropdown = ({ isNewNav = false }: ProjectDropdownProps) => { } return IS_PLATFORM ? ( -
- - - - - - - - - No projects found - - 7 ? 'h-[210px]' : ''}> - {projects?.map((project) => ( - - ))} - - - {projectCreationEnabled && ( - <> - - - { + + + + + + + + + No projects found + + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + + ))} + + + {projectCreationEnabled && ( + <> + + + { + setOpen(false) + router.push(`/new/${selectedOrganization?.slug}`) + }} + onClick={() => setOpen(false)} + > + { setOpen(false) - router.push(`/new/${selectedOrganization?.slug}`) }} - onClick={() => setOpen(false)} + className="w-full flex items-center gap-2" > - { - setOpen(false) - }} - className="w-full flex items-center gap-2" - > - -

New project

- -
-
- - )} -
-
-
-
-
+ +

New project

+ + + + + )} + +
+
+
) : (
)} diff --git a/apps/studio/lib/constants/telemetry.ts b/apps/studio/lib/constants/telemetry.ts index 87f03cf95b915..2030c342b8d24 100644 --- a/apps/studio/lib/constants/telemetry.ts +++ b/apps/studio/lib/constants/telemetry.ts @@ -4,6 +4,7 @@ export enum TELEMETRY_EVENTS { FEATURE_PREVIEWS = 'Dashboard UI Feature Previews', AI_ASSISTANT_V2 = 'AI Assistant V2', + CONNECT_UI = 'Connect UI', CRON_JOBS = 'Cron Jobs', } @@ -48,4 +49,5 @@ export enum TELEMETRY_VALUES { CRON_JOB_UPDATE_CLICKED = 'cron-job-update-clicked', CRON_JOB_CREATE_CLICKED = 'cron-job-create-clicked', CRON_JOBS_VIEW_PREVIOUS_RUNS = 'view-previous-runs-clicked', + COPY_CONNECTION_STRING = 'copy-connection-string', } diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index 836b11b3f7a71..b30d2e4dce7af 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react' import { useParams } from 'common' +import Connect from 'components/interfaces/Connect/Connect' import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' -import Connect from 'components/interfaces/Home/Connect/Connect' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' import { SecurityStatus } from 'components/interfaces/Home/SecurityStatus' @@ -13,6 +13,7 @@ import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' import ProjectUpgradeFailedBanner from 'components/ui/ProjectUpgradeFailedBanner' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useIsOrioleDb, useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' @@ -28,6 +29,8 @@ import { } from 'ui' const Home: NextPageWithLayout = () => { + const connectDialogUpdate = useFlag('connectDialogUpdate') + const organization = useSelectedOrganization() const project = useSelectedProject() @@ -87,7 +90,9 @@ const Home: NextPageWithLayout = () => {
{project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } - {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } + {IS_PLATFORM && + project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && + !connectDialogUpdate && }
diff --git a/apps/studio/pages/project/[ref]/settings/database.tsx b/apps/studio/pages/project/[ref]/settings/database.tsx index 902109f8a29ea..1ed247a0807ac 100644 --- a/apps/studio/pages/project/[ref]/settings/database.tsx +++ b/apps/studio/pages/project/[ref]/settings/database.tsx @@ -1,25 +1,25 @@ +import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm' import { ConnectionPooling, DatabaseSettings, NetworkRestrictions, } from 'components/interfaces/Settings/Database' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import type { NextPageWithLayout } from 'types' - -import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm' import BannedIPs from 'components/interfaces/Settings/Database/BannedIPs' +import { ConnectionStringMoved } from 'components/interfaces/Settings/Database/ConnectionStringMoved' import { DatabaseReadOnlyAlert } from 'components/interfaces/Settings/Database/DatabaseReadOnlyAlert' import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' +import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' -import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' -import { useFlag } from 'hooks/ui/useFlag' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' +import type { NextPageWithLayout } from 'types' const ProjectSettings: NextPageWithLayout = () => { const diskManagementV2 = useFlag('diskManagementV2') - const showDiskAndComputeForm = useFlag('diskAndComputeForm') + const connectDialogUpdate = useFlag('connectDialogUpdate') const project = useSelectedProject() const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' @@ -35,8 +35,14 @@ const ProjectSettings: NextPageWithLayout = () => {
- - + {connectDialogUpdate ? ( + + ) : ( + <> + + + + )}
diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 23efeaa44da21..9a9f4450a3b4b 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -68,6 +68,7 @@ const getInitialState = () => { showGenerateSqlModal: false, navigationPanelOpen: false, navigationPanelJustClosed: false, + showConnectDialog: false, } } @@ -111,6 +112,7 @@ const getInitialState = () => { showGenerateSqlModal: false, navigationPanelOpen: false, navigationPanelJustClosed: false, + showConnectDialog: false, } } @@ -210,6 +212,11 @@ export const appState = proxy({ ...value, } }, + + showConnectDialog: false, + setShowConnectDialog: (value: boolean) => { + appState.showConnectDialog = value + }, }) // Set up localStorage subscription diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index ad2f81cd43fcc..a4ed805f793c4 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -198,11 +198,6 @@ input[type='number'] { display: none; } -code { - // use supabase-ui code style - @apply text-code; -} - div[data-radix-portal]:not(.portal--toast) { z-index: 2147483646 !important; } diff --git a/packages/ui/src/components/CodeBlock/CodeBlock.tsx b/packages/ui/src/components/CodeBlock/CodeBlock.tsx index 2013320d5bafc..ccf629e76c114 100644 --- a/packages/ui/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/ui/src/components/CodeBlock/CodeBlock.tsx @@ -1,10 +1,12 @@ 'use client' +import { noop } from 'lodash' import { Check, Copy } from 'lucide-react' import { useTheme } from 'next-themes' import { Children, ReactNode, useState } from 'react' import { CopyToClipboard } from 'react-copy-to-clipboard' import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' + import { cn } from '../../lib/utils/cn' import { Button } from '../Button/Button' import { monokaiCustomTheme } from './CodeBlock.utils' @@ -13,29 +15,38 @@ import curl from 'highlightjs-curl' import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' import csharp from 'react-syntax-highlighter/dist/cjs/languages/hljs/csharp' import dart from 'react-syntax-highlighter/dist/cjs/languages/hljs/dart' +import go from 'react-syntax-highlighter/dist/cjs/languages/hljs/go' import http from 'react-syntax-highlighter/dist/cjs/languages/hljs/http' import js from 'react-syntax-highlighter/dist/cjs/languages/hljs/javascript' import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json' import kotlin from 'react-syntax-highlighter/dist/cjs/languages/hljs/kotlin' -import py from 'react-syntax-highlighter/dist/cjs/languages/hljs/python' +import php from 'react-syntax-highlighter/dist/cjs/languages/hljs/php' +import { + default as py, + default as python, +} from 'react-syntax-highlighter/dist/cjs/languages/hljs/python' import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql' import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' +export type CodeBlockLang = + | 'js' + | 'jsx' + | 'sql' + | 'py' + | 'bash' + | 'ts' + | 'dart' + | 'json' + | 'csharp' + | 'kotlin' + | 'curl' + | 'http' + | 'php' + | 'python' + | 'go' export interface CodeBlockProps { title?: ReactNode - language?: - | 'js' - | 'jsx' - | 'sql' - | 'py' - | 'bash' - | 'ts' - | 'dart' - | 'json' - | 'csharp' - | 'kotlin' - | 'curl' - | 'http' + language?: CodeBlockLang linesToHighlight?: number[] highlightBorder?: boolean styleConfig?: { @@ -52,6 +63,7 @@ export interface CodeBlockProps { children?: string renderer?: SyntaxHighlighterProps['renderer'] focusable?: boolean + onCopyCallback?: () => void wrapLines?: boolean } @@ -88,6 +100,7 @@ export const CodeBlock = ({ wrapLines = true, renderer, focusable = true, + onCopyCallback = noop, }: CodeBlockProps) => { const { resolvedTheme } = useTheme() const isDarkTheme = resolvedTheme?.includes('dark')! @@ -97,6 +110,7 @@ export const CodeBlock = ({ const handleCopy = () => { setCopied(true) + onCopyCallback() setTimeout(() => { setCopied(false) }, 1000) @@ -129,6 +143,9 @@ export const CodeBlock = ({ SyntaxHighlighter.registerLanguage('kotlin', kotlin) SyntaxHighlighter.registerLanguage('curl', curl) SyntaxHighlighter.registerLanguage('http', http) + SyntaxHighlighter.registerLanguage('php', php) + SyntaxHighlighter.registerLanguage('python', python) + SyntaxHighlighter.registerLanguage('go', go) const large = false // don't show line numbers if bash == lang @@ -157,7 +174,7 @@ export const CodeBlock = ({ style={monokaiTheme} className={cn( 'code-block border border-surface p-4 w-full !my-0 !bg-surface-100 outline-none focus:border-foreground-lighter/50', - `${!title ? '!rounded-md' : '!rounded-t-none !rounded-b-md'}`, + `${!title ? 'rounded-md' : 'rounded-t-none rounded-b-md'}`, `${!showLineNumbers ? 'pl-6' : ''}`, className )} diff --git a/packages/ui/src/components/shadcn/ui/select.tsx b/packages/ui/src/components/shadcn/ui/select.tsx index 8fd409dbcaebf..8b519980d132f 100644 --- a/packages/ui/src/components/shadcn/ui/select.tsx +++ b/packages/ui/src/components/shadcn/ui/select.tsx @@ -49,6 +49,7 @@ const SelectTrigger = React.forwardRef< className={cn( 'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control data-[placeholder]:text-foreground-lighter focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200', 'data-[state=open]:bg-selection data-[state=open]:border-stronger', + 'gap-2', SelectTriggerVariants({ size }), className )} @@ -56,7 +57,7 @@ const SelectTrigger = React.forwardRef< > {children} - + ))