From a60dc311d8d70d7a9c941fea69f715d0a819bdc8 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Sun, 20 Apr 2025 17:16:22 -0230 Subject: [PATCH 01/35] Remove vault keys (#35148) * Remove vault keys * fix typerr --------- Co-authored-by: Jordi Enric --- .../Landing/Integrations.constants.tsx | 4 - .../Vault/Secrets/AddNewSecretModal.tsx | 165 +++++++++--------- .../Vault/Secrets/EditSecretModal.tsx | 3 +- 3 files changed, 85 insertions(+), 87 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index 76fd804bd7a..a642e87976d 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -187,10 +187,6 @@ const supabaseIntegrations: IntegrationDefinition[] = [ route: 'overview', label: 'Overview', }, - { - route: 'keys', - label: 'Keys', - }, { route: 'secrets', label: 'Secrets', diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx index 4b005caa51e..2337b5b9ffb 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx @@ -24,22 +24,22 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { const { mutateAsync: addKeyMutation } = usePgSodiumKeyCreateMutation() const { mutateAsync: addSecret } = useVaultSecretCreateMutation() - const { data: keys, isLoading } = usePgSodiumKeysQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { - enabled: visible, - } - ) + // const { data: keys, isLoading } = usePgSodiumKeysQuery( + // { + // projectRef: project?.ref, + // connectionString: project?.connectionString, + // }, + // { + // enabled: visible, + // } + // ) - useEffect(() => { - if (visible && keys) { - setShowSecretValue(false) - setSelectedKeyId(keys[0]?.id ?? 'create-new') - } - }, [visible, keys]) + // useEffect(() => { + // if (visible && keys) { + // setShowSecretValue(false) + // setSelectedKeyId(keys[0]?.id ?? 'create-new') + // } + // }, [visible, keys]) const validate = (values: any) => { const errors: any = {} @@ -86,81 +86,82 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { return ( - {isLoading ? ( + {/* {isLoading ? ( - ) : ( -
- {({ isSubmitting }: any) => { - return ( - <> - - - - - - - - - ) - }} -
- )} + /> */} + {/* } + url="https://github.com/supabase/vault" + urlLabel="Vault documentation" + title="What is a key?" + description={ +
+

+ Keys are used to encrypt data inside your database, and every secret in the + Vault is encrypted with a key. +

+

+ You may create different keys for different purposes, such as one for + encrypting user data, and another for application data. +

+
+ } + /> */} + + + + + + + + ) + }} + + {/* )} */}
) } diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx index f0ffbcf03f8..aa3104c5e4b 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx @@ -161,6 +161,7 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => { } /> + {/* { selectedKeyId={selectedKeyId} onSelectKey={setSelectedKeyId} /> - + */} + {isIntroduction && ( + + )} From 323ac72cb11a61acdc31098218b43a72a40dc930 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 21 Apr 2025 15:36:42 +0700 Subject: [PATCH 09/35] FIx layout in support form (#35065) --- .../interfaces/Database/Migrations/Migrations.tsx | 6 +++--- .../DiskManagement/fields/ComputeSizeField.tsx | 2 +- .../interfaces/Settings/Addons/Addons.tsx | 2 +- .../Database/DiskSizeConfigurationModal.tsx | 2 +- .../CustomDomainConfig/CustomDomainConfig.tsx | 2 +- apps/studio/components/interfaces/Sidebar.tsx | 13 +------------ .../components/interfaces/Support/SupportFormV2.tsx | 2 +- .../ProjectLayout/LayoutHeader/HelpPopover.tsx | 2 +- .../layouts/StorageLayout/StorageBucketsError.tsx | 2 +- apps/studio/hooks/misc/useHideSidebar.ts | 2 ++ .../database/backups/restore-to-new-project.tsx | 12 ++++++------ 11 files changed, 19 insertions(+), 28 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index 7a3285b3045..317f81ccd45 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -7,10 +7,10 @@ import Table from 'components/to-be-cleaned/Table' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query' -import { Button, Input, SidePanel } from 'ui' -import MigrationsEmptyState from './MigrationsEmptyState' import { Search } from 'lucide-react' +import { Button, Input, SidePanel } from 'ui' import { Admonition } from 'ui-patterns' +import MigrationsEmptyState from './MigrationsEmptyState' const Migrations = () => { const [search, setSearch] = useState('') @@ -55,7 +55,7 @@ const Migrations = () => { > + + + +
+
+
+
+ +
+
+

+ Hi! I'm your AI assistant. How can I help you today? +

+
+
+
+
+

+ I can help you with database queries, API endpoints, or any other technical + questions you might have. +

+
+
+
+
+
+
+ + )} + + ) +} diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index b13c1502d56..d1adafc13dd 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -58,13 +58,18 @@ const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] const CONTAINER_CLASSES = 'px-6' interface SupportFormV2Props { + onProjectSelected: (value: string) => void + onOrganizationSelected: (value: string) => void setSentCategory: (value: string) => void - setSelectedProject: (value: string) => void } // [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view // This is a rewrite of the old SupportForm to use the new form components -export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFormV2Props) => { +export const SupportFormV2 = ({ + onProjectSelected: setSelectedProject, + onOrganizationSelected: setSelectedOrganization, + setSentCategory, +}: SupportFormV2Props) => { const { profile } = useProfile() const { projectRef: ref, @@ -295,6 +300,18 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo if (urlMessage) form.setValue('message', urlMessage) }, [urlMessage]) + // Sync organization selection with parent state + // Initialized as 'no-org' in parent if no org is selected + useEffect(() => { + setSelectedOrganization(organizationSlug) + }, [organizationSlug, setSelectedOrganization]) + + // Sync project selection with parent state + // Initialized as 'no-project' in parent if no project is selected + useEffect(() => { + setSelectedProject(projectRef) + }, [projectRef, setSelectedProject]) + return (
{ const [sentCategory, setSentCategory] = useState() + // For AIAssistantOption projectRef prop const [selectedProject, setSelectedProject] = useState('no-project') + // For AIAssistantOption organizationSlug prop + const [selectedOrganization, setSelectedOrganization] = useState('no-org') const { data, isLoading } = usePlatformStatusQuery() const isHealthy = data?.isHealthy @@ -34,6 +38,7 @@ const SupportPage: NextPageWithLayout = () => {

Supabase support

+
- +
{ /> ) : ( )}
From 5adade9c95b5c5a57c23b84d79426565b6fd7d2e Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:30:45 +1200 Subject: [PATCH 23/35] chore: update self-hosted image versions (#34823) * chore: update image versions for docker/docker-compose.yml * chore: update image versions for docker/docker-compose.yml * Update docker/docker-compose.yml --------- Co-authored-by: Han Qiao --- docker/docker-compose.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8fea15b886c..54e2575df5e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,7 +11,7 @@ services: studio: container_name: supabase-studio - image: supabase/studio:20250317-6955350 + image: supabase/studio:2025.04.21-sha-173cc56 restart: unless-stopped healthcheck: test: @@ -79,7 +79,7 @@ services: auth: container_name: supabase-auth - image: supabase/gotrue:v2.170.0 + image: supabase/gotrue:v2.171.0 restart: unless-stopped healthcheck: test: @@ -162,7 +162,7 @@ services: rest: container_name: supabase-rest - image: postgrest/postgrest:v12.2.8 + image: postgrest/postgrest:v12.2.10 restart: unless-stopped depends_on: db: @@ -186,7 +186,7 @@ services: realtime: # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain container_name: realtime-dev.supabase-realtime - image: supabase/realtime:v2.34.43 + image: supabase/realtime:v2.34.47 restart: unless-stopped depends_on: db: @@ -231,7 +231,7 @@ services: # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up storage: container_name: supabase-storage - image: supabase/storage-api:v1.19.3 + image: supabase/storage-api:v1.22.3 restart: unless-stopped volumes: - ./volumes/storage:/var/lib/storage:z @@ -296,7 +296,7 @@ services: meta: container_name: supabase-meta - image: supabase/postgres-meta:v0.87.1 + image: supabase/postgres-meta:v0.88.9 restart: unless-stopped depends_on: db: @@ -474,7 +474,7 @@ services: # Update the DATABASE_URL if you are using an external Postgres database supavisor: container_name: supabase-pooler - image: supabase/supavisor:2.4.14 + image: supabase/supavisor:2.5.1 restart: unless-stopped ports: - ${POSTGRES_PORT}:5432 From e2e3449ff8e9add63b61ce80b43394828f76064a Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:31:10 +1200 Subject: [PATCH 24/35] chore: update cli reference doc (#35193) --- apps/docs/spec/cli_v1_commands.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 6a2a6120096..e8781085ddb 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.20.12 + version: 2.22.4 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -871,7 +871,7 @@ commands: title: supabase services summary: Show versions of all Supabase services tags: - - management-api + - local-dev links: [] usage: supabase services subcommands: [] From 6794f4c6ebb6073ff938da9a2296e1b8aeefb097 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Apr 2025 12:41:39 +0700 Subject: [PATCH 25/35] Chore/prepare org nav rollout (#35175) * Support default opt in feature previews via feature flag * Add notice banner in /org/[slug] and /organizations if user is opted into the changes by default * Clean up * Update copy --- .../FeaturePreview/FeaturePreviewContext.tsx | 26 ++++++++-- .../FeaturePreview/FeaturePreviewModal.tsx | 6 +-- .../FeaturePreview/LayoutUpdatePreview.tsx | 52 ++++++++++++++++++- apps/studio/lib/constants/index.ts | 7 +-- apps/studio/pages/org/[slug]/index.tsx | 4 ++ apps/studio/pages/organizations.tsx | 2 + packages/common/feature-flags.tsx | 2 +- 7 files changed, 83 insertions(+), 16 deletions(-) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 5a8a0221444..d4d4b770dfa 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -1,5 +1,7 @@ import { noop } from 'lodash' +import { FeatureFlagContext } from 'common' +import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' @@ -15,7 +17,7 @@ export const FEATURE_PREVIEWS = [ key: LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW, name: 'Layout Update for Organizations', content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', + discussionsUrl: 'https://github.com/orgs/supabase/discussions/33670', isNew: false, isPlatformOnly: true, }, @@ -72,6 +74,19 @@ const FeaturePreviewContext = createContext({ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { + const { hasLoaded } = useContext(FeatureFlagContext) + const enableNewLayoutPreview = useFlag('newLayoutPreview') + + // [Joshen] Similar logic to feature flagging previews, we can use flags to default opt in previews + const isDefaultOptIn = (feature: (typeof FEATURE_PREVIEWS)[number]) => { + switch (feature.key) { + case LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW: + return enableNewLayoutPreview + default: + return false + } + } + const [flags, setFlags] = useState(() => FEATURE_PREVIEWS.reduce((a, b) => { return { ...a, [b.key]: false } @@ -82,11 +97,16 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{} if (typeof window !== 'undefined') { setFlags( FEATURE_PREVIEWS.reduce((a, b) => { - return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' } + const defaultOptIn = isDefaultOptIn(b) + const localStorageValue = localStorage.getItem(b.key) + return { + ...a, + [b.key]: !localStorageValue ? defaultOptIn : localStorageValue === 'true', + } }, {}) ) } - }, []) + }, [hasLoaded]) const value = { flags, diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 71862a31f93..5f78dde3e6b 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -5,7 +5,6 @@ import { useEffect } from 'react' import { useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import { removeTabsByEditor } from 'state/tabs' @@ -19,12 +18,9 @@ const FeaturePreviewModal = () => { const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() - const enableNewLayoutPreview = useFlag('newLayoutPreview') - + // [Joshen] Use this if we want to feature flag previews function isReleasedToPublic(feature: (typeof FEATURE_PREVIEWS)[number]) { switch (feature.key) { - case LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW: - return enableNewLayoutPreview default: return true } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/LayoutUpdatePreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/LayoutUpdatePreview.tsx index 0c143b92305..be462dde866 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/LayoutUpdatePreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/LayoutUpdatePreview.tsx @@ -1,5 +1,10 @@ -import { BASE_PATH } from 'lib/constants' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants' +import { ExternalLink, X } from 'lucide-react' import Image from 'next/image' +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Button } from 'ui' +import { useIsNewLayoutEnabled } from './FeaturePreviewContext' export const LayoutUpdatePreview = () => { return ( @@ -37,3 +42,48 @@ export const LayoutUpdatePreview = () => { ) } + +export const LayoutUpdateBanner = () => { + const newLayoutPreview = useIsNewLayoutEnabled() + const [newLayoutPreviewState] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW, '') + const [newLayoutAcknowledged, setNewLayoutAcknowledged] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.NEW_LAYOUT_NOTICE_ACKNOWLEDGED, + false + ) + const isDefaultOptedInNewLayout = newLayoutPreview && newLayoutPreviewState === '' + + if (!isDefaultOptedInNewLayout || newLayoutAcknowledged) return null + + return ( + + + + NEW + + Dashboard layout has been updated! + + + We've updated the dashboard layout to make it easier to find and manage your organization + settings. + + + + + } + className="absolute top-2 right-2 px-1" + onClick={() => setNewLayoutAcknowledged(true)} + tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} + /> + + ) +} diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index ac4f7e22636..01897488056 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -53,7 +53,7 @@ export const LOCAL_STORAGE_KEYS = { UI_SQL_EDITOR_TABS: 'supabase-ui-sql-editor-tabs', UI_NEW_LAYOUT_PREVIEW: 'supabase-ui-new-layout-preview', - SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', + NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, @@ -72,7 +72,6 @@ export const LOCAL_STORAGE_KEYS = { GRAPHIQL_RLS_BYPASS_WARNING: 'graphiql-rls-bypass-warning-dismissed', CLS_DIFF_WARNING: 'cls-diff-warning-dismissed', CLS_SELECT_STAR_WARNING: 'cls-select-star-warning-dismissed', - PROJECT_LINT_IGNORE_LIST: 'supabase-project-lint-ignore-list', QUERY_PERF_SHOW_BOTTOM_SECTION: 'supabase-query-perf-show-bottom-section', // Key to track account deletion requests ACCOUNT_DELETION_REQUEST: 'supabase-account-deletion-request', @@ -82,8 +81,6 @@ export const LOCAL_STORAGE_KEYS = { LAST_SIGN_IN_METHOD: 'supabase-last-sign-in-method', // Key to track the last selected schema. The project ref is intentionally put at the end for easier search in the browser console. LAST_SELECTED_SCHEMA: (ref: string) => `last-selected-schema-${ref}`, - // Key to show a warning on the SQL Editor AI Assistant that the org hasn't opted-in to sending anon data - SHOW_AI_NOT_OPTIMIZED_WARNING: (ref: string) => `supabase-show-ai-not-optimized-${ref}`, // Track position of nodes for schema visualizer SCHEMA_VISUALIZER_POSITIONS: (ref: string, schemaId: number) => `schema-visualizer-positions-${ref}-${schemaId}`, @@ -92,9 +89,7 @@ export const LOCAL_STORAGE_KEYS = { GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys FLY_POSTGRES_DEPRECATION_WARNING: 'fly-postgres-deprecation-warning-dismissed', - AUTH_USERS_COLUMNS_CONFIGURATION: (ref: string) => `supabase-auth-users-columns-${ref}`, - STORAGE_BUCKETS_COLUMN_CONFIG: (ref: string) => `supabase-storage-buckets-columns-${ref}`, // api keys view switcher for new and legacy api keys API_KEYS_VIEW: (ref: string) => `supabase-api-keys-view-${ref}`, diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 044935bcd8e..2cccccb89fd 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'common' import { useIsNewLayoutEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { LayoutUpdateBanner } from 'components/interfaces/App/FeaturePreview/LayoutUpdatePreview' import { ProjectList } from 'components/interfaces/Home/ProjectList' import HomePageActions from 'components/interfaces/HomePageActions' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -37,12 +38,15 @@ const ProjectsPage: NextPageWithLayout = () => { return (
+ + +
{ return ( <> + Your Organizations diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx index a715439a88b..f79131f2fea 100644 --- a/packages/common/feature-flags.tsx +++ b/packages/common/feature-flags.tsx @@ -33,7 +33,7 @@ export type FeatureFlagContextType = { hasLoaded?: boolean } -const FeatureFlagContext = createContext({ +export const FeatureFlagContext = createContext({ API_URL: undefined, configcat: {}, posthog: {}, From f81387de90babf9674fcebf49ce179d0b483171d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Apr 2025 14:24:48 +0700 Subject: [PATCH 26/35] Fix edge functions logs refresh button (#35194) * Fix edge functions logs refresh button * add comment to move state --------- Co-authored-by: Jordi Enric --- .../Settings/Logs/LogsPreviewer.tsx | 60 +++++++++++++++---- .../Settings/Logs/PreviewFilterPanel.tsx | 29 ++------- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 1f6f6221287..76f8b95560c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -18,7 +18,13 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Button } from 'ui' import { LogsBarChart } from 'ui-patterns/LogsBarChart' import LogTable from './LogTable' -import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants' +import { DatePickerValue } from './Logs.DatePickers' +import { + LOGS_TABLES, + LOG_ROUTES_WITH_REPLICA_SUPPORT, + LogsTableName, + PREVIEWER_DATEPICKER_HELPERS, +} from './Logs.constants' import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types' import { maybeShowUpgradePrompt } from './Logs.utils' import UpgradePrompt from './UpgradePrompt' @@ -54,17 +60,42 @@ export const LogsPreviewer = ({ }: PropsWithChildren) => { const router = useRouter() const { db } = useParams() - const [showChart, setShowChart] = useState(true) - const organization = useSelectedOrganization() const state = useDatabaseSelectorStateSnapshot() + + const [showChart, setShowChart] = useState(true) + const [selectedDatePickerValue, setSelectedDatePickerValue] = useState( + getDefaultDatePickerValue() + ) + const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } = useLogsUrlState() - const [selectedLogId, setSelectedLogId] = useSelectedLog() - const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef }) + // TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi + function getDefaultDatePickerValue() { + const iso_timestamp_start = router.query.iso_timestamp_start as string + const iso_timestamp_end = router.query.iso_timestamp_end as string + + if (iso_timestamp_start && iso_timestamp_end) { + return { + to: iso_timestamp_end, + from: iso_timestamp_start, + text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`, + isHelper: false, + } + } + + const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default) + return { + to: defaultDatePickerValue!.calcTo(), + from: defaultDatePickerValue!.calcFrom(), + text: defaultDatePickerValue!.text, + isHelper: true, + } + } + const table = !tableName ? LOGS_TABLES[queryType] : tableName const { @@ -96,13 +127,18 @@ export const LogsPreviewer = ({ setFilters({ ...filters, search_query: template.searchString }) } - // [Joshen 180425] This logic here all seems unnecessary IMO? handleRefresh just should call refresh? + // [Joshen] For helper date picker values, reset the timestamp start to prevent data caching + // Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result + // Whereas if a specific range is selected, you'd not expect new data to show up const handleRefresh = () => { - if (timestampStart) { - const newTimestampStart = dayjs(timestampStart).toISOString() - setTimeRange(newTimestampStart, timestampEnd) - } else { - setTimeRange('', '') + if (selectedDatePickerValue.isHelper) { + const helper = PREVIEWER_DATEPICKER_HELPERS.find( + (x) => x.text === selectedDatePickerValue.text + ) + if (helper) { + const newTimestampStart = helper.calcFrom() + setTimeRange(newTimestampStart, timestampEnd) + } } refresh() } @@ -180,6 +216,8 @@ export const LogsPreviewer = ({ query: id !== projectRef ? { ...router.query, db: id } : params, }) }} + selectedDatePickerValue={selectedDatePickerValue} + setSelectedDatePickerValue={setSelectedDatePickerValue} /> {children}
void className?: string + selectedDatePickerValue: DatePickerValue + setSelectedDatePickerValue: (value: DatePickerValue) => void } /** @@ -64,6 +65,8 @@ const PreviewFilterPanel = ({ table, onSelectedDatabaseChange, className, + selectedDatePickerValue, + setSelectedDatePickerValue, }: PreviewFilterPanelProps) => { const router = useRouter() const { ref } = useParams() @@ -79,30 +82,6 @@ const PreviewFilterPanel = ({ const hasEdits = search !== defaultSearchValue - function getDefaultDatePickerValue() { - // if we have values in the URL, use them - const iso_timestamp_start = router.query.iso_timestamp_start as string - const iso_timestamp_end = router.query.iso_timestamp_end as string - if (iso_timestamp_start && iso_timestamp_end) { - return { - to: iso_timestamp_end, - from: iso_timestamp_start, - text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`, - isHelper: false, - } - } - return { - to: PREVIEWER_DATEPICKER_HELPERS[2].calcTo(), - from: PREVIEWER_DATEPICKER_HELPERS[2].calcFrom(), - text: 'Last hour', - isHelper: true, - } - } - - const [selectedDatePickerValue, setSelectedDatePickerValue] = useState( - getDefaultDatePickerValue() - ) - const handleInputSearch = (query: string) => onSearch('search-input-change', { query }) // Sync local state with provided default value From 246dee70e3180402ac9b8471c86a2517b2520e0c Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo <31685197+soedirgo@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:20:30 +0800 Subject: [PATCH 27/35] docs: remove references to pgsodium/keys on Vault guide (#35178) --- apps/docs/content/guides/database/vault.mdx | 82 ++++----------------- 1 file changed, 13 insertions(+), 69 deletions(-) diff --git a/apps/docs/content/guides/database/vault.mdx b/apps/docs/content/guides/database/vault.mdx index 12aa4cc193b..dae16254c24 100644 --- a/apps/docs/content/guides/database/vault.mdx +++ b/apps/docs/content/guides/database/vault.mdx @@ -9,9 +9,9 @@ video: 'https://www.youtube.com/v/J9mTPY8rIXE' Vault is a Postgres extension and accompanying Supabase UI that makes it safe and easy to store encrypted secrets and other data in your database. This opens up a lot of possibilities to use Postgres in ways that go beyond what is available in a stock distribution. -Under the hood, the Vault is a table of Secrets and Encryption Keys that are stored using [Authenticated Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption) on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged. +Under the hood, the Vault is a table of Secrets that are stored using [Authenticated Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption) on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged. -Supabase provides a dashboard UI for the Vault that makes storing secrets easy. Click a button, type in your secret, and save. Optionally create your own keys you can use to encrypt your secret. Your secret will then be stored on disk encrypted using the specified key. +Supabase provides a dashboard UI for the Vault that makes storing secrets easy. Click a button, type in your secret, and save. -There are two main parts to the Vault UI, Secrets and Encryption Keys: +You can use Vault to store secrets - everything from Environment Variables to API Keys. You can then use these secrets anywhere in your database: Postgres [Functions](/docs/guides/database/functions), Triggers, and [Webhooks](/docs/guides/database/webhooks). From a SQL perspective, accessing secrets is as easy as querying a table (or in this case, a view). The underlying secrets tables will be stored in encrypted form. -## Secrets +## Using Vault -You can use the Vault to store secrets - everything from Environment Variables to API Keys. You can then use these secrets anywhere in your database: Postgres [Functions](/docs/guides/database/functions), Triggers, and [Webhooks](/docs/guides/database/webhooks). From a SQL perspective, accessing secrets is as easy as querying a table (or in this case, a view). The underlying secrets tables will be stored in encrypted form. - -## Encryption keys - -These are keys used to encrypt data inside your database. You can create different Encryption Keys for different purposes, for example: one for encrypting user-data, and another for application-data. Each key is encrypted itself using a root encryption key that lives outside of the database. See **[Encryption key location](#encryption-key-location)** for more details. - -## Using the Vault - -You can manage secrets and encryption keys from the UI or using SQL. +You can manage secrets from the UI or using SQL. ### Adding secrets @@ -71,7 +63,7 @@ id | 7095d222-efe5-4cd5-b5c6-5755b451e223 name | unique_name description | This is the description secret | 3mMeOcoG84a5F2uOfy2ugWYDp9sdxvCTmi6kTeT97bvA8rCEsG5DWWZtTU8VVeE= -key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354 +key_id | nonce | \x9f2d60954ba5eb566445736e0760b0e3 created_at | 2022-12-14 02:34:23.85159+00 updated_at | 2022-12-14 02:34:23.85159+00 @@ -79,31 +71,6 @@ updated_at | 2022-12-14 02:34:23.85159+00 -Alternatively, you can create a secret by inserting data into the `vault.secret` table: - -{/* prettier-ignore */} -```sql -insert into vault.secrets (secret) -values ('s3kre3t_k3y') returning *; -``` - -
-Show Result - -```sql --[ RECORD 1 ]------------------------------------------------------------- -id | d91596b8-1047-446c-b9c0-66d98af6d001 -name | -description | -secret | S02eXS9BBY+kE3r621IS8beAytEEtj+dDHjs9/0AoMy7HTbog+ylxcS22A== -key_id | 7f5ad44b-6bd5-4c99-9f68-4b6c7486f927 -nonce | \x3aa2e92f9808e496aa4163a59304b895 -created_at | 2022-12-14 02:29:21.3625+00 -updated_at | 2022-12-14 02:29:21.3625+00 -``` - -
- ### Viewing secrets If you look in the `vault.secrets` table, you will see that your data is stored encrypted. To decrypt the data, there is an automatically created view `vault.decrypted_secrets`. This view will decrypt secret data on the fly: @@ -126,7 +93,7 @@ name | unique_name description | This is the description secret | 3mMeOcoG84a5F2uOfy2ugWYDp9sdxvCTmi6kTeT97bvA8rCEsG5DWWZtTU8VVeE= decrypted_secret | another_s3kre3t -key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354 +key_id | nonce | \x9f2d60954ba5eb566445736e0760b0e3 created_at | 2022-12-14 02:34:23.85159+00 updated_at | 2022-12-14 02:34:23.85159+00 @@ -136,7 +103,7 @@ name | description | secret | a1CE4vXwQ53+N9bllJj1D7fasm59ykohjb7K90PPsRFUd9IbBdxIGZNoSQLIXl4= decrypted_secret | another_s3kre3t -key_id | 8c72b05e-b931-4372-abf9-a09cfad18489 +key_id | nonce | \x1d3b2761548c4efb2d29ca11d44aa22f created_at | 2022-12-14 02:32:50.58921+00 updated_at | 2022-12-14 02:32:50.58921+00 @@ -146,7 +113,7 @@ name | description | secret | S02eXS9BBY+kE3r621IS8beAytEEtj+dDHjs9/0AoMy7HTbog+ylxcS22A== decrypted_secret | s3kre3t_k3y -key_id | 7f5ad44b-6bd5-4c99-9f68-4b6c7486f927 +key_id | nonce | \x3aa2e92f9808e496aa4163a59304b895 created_at | 2022-12-14 02:29:21.3625+00 updated_at | 2022-12-14 02:29:21.3625+00 @@ -186,7 +153,7 @@ name | updated_unique_name description | This is the updated description secret | lhb3HBFxF+qJzp/HHCwhjl4QFb5dYDsIQEm35DaZQOovdkgp2iy6UMufTKJGH4ThMrU= decrypted_secret | n3w_upd@ted_s3kret -key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354 +key_id | nonce | \x9f2d60954ba5eb566445736e0760b0e3 created_at | 2022-12-14 02:34:23.85159+00 updated_at | 2022-12-14 02:51:13.938396+00 @@ -206,7 +173,7 @@ updated_at | 2022-12-14 02:51:13.938396+00 >
-As we mentioned, the Vault uses `pgsodium`'s Transparent Column Encryption (TCE) to store secrets in an authenticated encrypted form. There are some details around that you may be curious about, what does authenticated mean, and where are encryption keys store? This section explains those details. +As we mentioned, Vault uses Transparent Column Encryption (TCE) to store secrets in an authenticated encrypted form. There are some details around that you may be curious about. What does authenticated mean? Where is the encryption key stored? This section explains those details. ### Authenticated encryption with associated data @@ -218,34 +185,11 @@ The first important feature of TCE is that it uses an [Authenticated Encryption **Associated Data** means that you can include any other columns from the same row as part of the signature computation. This doesn't encrypt those other columns - rather it ensures that your encrypted value is only associated with columns from that row. If an attacker were to copy an encrypted value from another row to the current one, the signature would be rejected (assuming you used a unique column in the associated data). -Another important feature of `pgsodium` is that the encryption keys are never stored in the database alongside the encrypted data. Instead, only a **Key ID** is stored, which is a reference to the key that is only accessible outside of SQL. Even if an attacker can capture a dump of your entire database, they will see only encrypted data and key IDs, _never the raw key itself_. +Another important feature is that the encryption key is never stored in the database alongside the encrypted data. Even if an attacker can capture a dump of your entire database, they will see only encrypted data, _never the encryption key itself_. This is an important safety precaution - there is little value in storing the encryption key in the database itself as this would be like locking your front door but leaving the key in the lock! Storing the key outside the database fixes this issue. -Where are the keys stored? Supabase creates and manages the root keys (from which all key IDs are derived) in our secured backend systems. We keep this root key safe and separate from your data. You remain in control of your keys - a separate API endpoint is available that you can use to access the key if you want to decrypt your data outside of Supabase. - -### Internal details - -To encrypt data, you need a _key id_. You can use the default key id created automatically for every project, or create your own key ids Using the `pgsodium.create_key()` function. Key ids are used to internally derive the encryption key used to encrypt secrets in the vault. Vault users typically do not have access to the key itself, only the key id. - -Both `vault.create_secret()` and `vault.update_secret()` take an optional fourth `new_key_id` argument. This argument can be used to store a different key id for the secret instead of the default value. - -{/* prettier-ignore */} -```sql -select vault.create_secret( - 'another_s3kre3t_key', - 'another_unique_name', - 'This is another description', - (pgsodium.create_key()).id -); -``` - -Result: - -```sh --[ RECORD 1 ]-+------------------------------------- -create_secret | cec9e005-a44d-4b19-86e1-febf3cd40619 -``` +Where is the key stored? Supabase creates and manages the encryption key in our secured backend systems. We keep this key safe and separate from your data. You remain in control of your key - a separate API endpoint is available that you can use to access the key if you want to decrypt your data outside of Supabase. Which roles should have access to the `vault.secrets` table should be carefully considered. There are two ways to grant access, the first is that the `postgres` user can explicitly grant access to the vault table itself. From 58a9db9db543220095af157163f86a65e51faca9 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Apr 2025 17:33:21 +0700 Subject: [PATCH 28/35] Chore/fix searching in table editor clearing tabs (#35198) * Fix searching in table editor clears tabs * Clean * clean * Make close button slightly bigger --- apps/studio/components/grid/SupabaseGrid.tsx | 3 +- .../UpdateRolesPanel/UpdateRolesPanel.tsx | 2 - .../TableGridEditor/NotFoundState.tsx | 21 --- .../TableGridEditor/TableGridEditor.tsx | 56 +++++- .../interfaces/TableGridEditor/index.ts | 3 +- .../TableEditorLayout/TableEditorMenu.tsx | 16 +- .../components/layouts/Tabs/SortableTab.tsx | 132 ++++++++++++++ .../components/layouts/Tabs/TabPreview.tsx | 29 +++ .../components/layouts/{ => Tabs}/Tabs.tsx | 165 +----------------- .../layouts/editors/EditorBaseLayout.tsx | 5 +- .../data/table-editor/table-editor-query.ts | 2 + .../pages/project/[ref]/editor/[id].tsx | 2 +- apps/studio/state/tabs.ts | 47 ++--- 13 files changed, 253 insertions(+), 230 deletions(-) delete mode 100644 apps/studio/components/interfaces/TableGridEditor/NotFoundState.tsx create mode 100644 apps/studio/components/layouts/Tabs/SortableTab.tsx create mode 100644 apps/studio/components/layouts/Tabs/TabPreview.tsx rename apps/studio/components/layouts/{ => Tabs}/Tabs.tsx (52%) diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 7074b3e6781..d24332ae8f5 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -7,7 +7,7 @@ import { createPortal } from 'react-dom' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' -import { useUrlState } from 'hooks/ui/useUrlState' +import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort' import { RoleImpersonationState } from 'lib/role-impersonation' import { EMPTY_ARR } from 'lib/void' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' @@ -25,7 +25,6 @@ import { Grid } from './components/grid/Grid' import Header, { HeaderProps } from './components/header/Header' import { RowContextMenu } from './components/menu' import { Filter, GridProps } from './types' -import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort' export const SupabaseGrid = ({ customHeader, diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx index 4782245ccd3..186beb2ea4d 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx @@ -142,14 +142,12 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP if (isApplyAllProjects) { if (originalConfigurationType === 'org-scope') { - console.log('Reset: Org scoped') setProjectsRoleConfiguration(originalConfiguration) } else { setProjectsRoleConfiguration([{ ref: undefined, roleId: roleIdToApply }]) } } else { if (originalConfigurationType === 'project-scope') { - console.log('Reset: Project scoped') setProjectsRoleConfiguration(originalConfiguration) } else { setProjectsRoleConfiguration( diff --git a/apps/studio/components/interfaces/TableGridEditor/NotFoundState.tsx b/apps/studio/components/interfaces/TableGridEditor/NotFoundState.tsx deleted file mode 100644 index 120e6a0354d..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/NotFoundState.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import InformationBox from 'components/ui/InformationBox' -import { AlertCircle } from 'lucide-react' - -interface NotFoundStateProps { - id: string | number -} - -const NotFoundState = ({ id }: NotFoundStateProps) => { - return ( -
-
- } - title={`Unable to find your table with ID ${id}`} - /> -
-
- ) -} - -export default NotFoundState diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 7530dea458d..df3d071c0cb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -6,6 +6,7 @@ import { useCallback } from 'react' import { useParams } from 'common' import { SupabaseGrid } from 'components/grid/SupabaseGrid' import { useLoadTableEditorStateFromLocalStorageIntoUrl } from 'components/grid/SupabaseGrid.utils' +import { useEditorType } from 'components/layouts/editors/EditorsLayout.hooks' import { Entity, isMaterializedView, @@ -16,10 +17,12 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useAppStateSnapshot } from 'state/app-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' -import { makeActiveTabPermanent } from 'state/tabs' -import { GenericSkeletonLoader } from 'ui-patterns' -import NotFoundState from './NotFoundState' +import { handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs' +import { Button } from 'ui' +import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' import TableDefinition from './TableDefinition' @@ -28,13 +31,17 @@ export interface TableGridEditorProps { selectedTable?: Entity } -const TableGridEditor = ({ +export const TableGridEditor = ({ isLoadingSelectedTable = false, selectedTable, }: TableGridEditorProps) => { const router = useRouter() + const editor = useEditorType() const project = useSelectedProject() + const appSnap = useAppStateSnapshot() const { ref: projectRef, id } = useParams() + const tabs = useTabsStore(projectRef) + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() useLoadTableEditorStateFromLocalStorageIntoUrl({ projectRef, @@ -46,6 +53,7 @@ const TableGridEditor = ({ const canEditTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const isReadOnly = !canEditTables && !canEditColumns + const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined const onTableCreated = useCallback( (table: { id: number }) => { @@ -54,6 +62,12 @@ const TableGridEditor = ({ [projectRef, router] ) + const onClearDashboardHistory = () => { + if (projectRef && editor) { + appSnap.setDashboardHistory(projectRef, editor === 'table' ? 'editor' : editor, undefined) + } + } + // NOTE: DO NOT PUT HOOKS AFTER THIS LINE if (isLoadingSelectedTable || !projectRef) { return ( @@ -68,7 +82,37 @@ const TableGridEditor = ({ } if (isUndefined(selectedTable)) { - return + return ( +
+
+ + {isTableEditorTabsEnabled && ( + + )} + +
+
+ ) } const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable) @@ -121,5 +165,3 @@ const TableGridEditor = ({
) } - -export default TableGridEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/index.ts b/apps/studio/components/interfaces/TableGridEditor/index.ts index 86131e01172..50e1839b986 100644 --- a/apps/studio/components/interfaces/TableGridEditor/index.ts +++ b/apps/studio/components/interfaces/TableGridEditor/index.ts @@ -1,3 +1,2 @@ -export { default as TableGridEditor } from './TableGridEditor' -export { default as SidePanelEditor } from './SidePanelEditor/SidePanelEditor' export { default as EmptyState } from './EmptyState' +export { default as SidePanelEditor } from './SidePanelEditor/SidePanelEditor' diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index ba2e93a9a29..1474176cd1a 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' import { Filter, Plus } from 'lucide-react' -import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' @@ -41,13 +40,11 @@ import { InnerSideBarFilters, } from 'ui-patterns/InnerSideMenu' import { useProjectContext } from '../ProjectLayout/ProjectContext' -import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils' import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { const { ref, id: _id } = useParams() - const router = useRouter() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() @@ -117,13 +114,6 @@ const TableEditorMenu = () => { } }, [selectedTable?.schema]) - useEffect(() => { - // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session - if (isTableEditorTabsEnabled && ref && entityTypes) { - tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes }) - } - }, [entityTypes]) - return ( <>
@@ -183,12 +173,10 @@ const TableEditorMenu = () => { { - setSearchText(e.target.value) - }} value={searchText} placeholder="Search tables..." + aria-labelledby="Search tables" + onChange={(e) => setSearchText(e.target.value)} > void +}) => { + const router = useRouter() + const { ref } = useParams() + const currentSchema = (router.query.schema as string) || 'public' + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tab.id, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0, + } + + // Update schema visibility check to include URL param comparison + const shouldShowSchema = useMemo(() => { + // For both table and schema tabs, show schema if: + // Any tab has a different schema than the current schema parameter + if (tab.type === 'r') { + const anyTabHasDifferentSchema = openTabs + .filter((t) => t.type === 'r') + .some((t) => t.metadata?.schema !== currentSchema) + + return anyTabHasDifferentSchema + } + + return false + }, [openTabs, currentSchema, tab.type]) + + // Create a motion version of TabsTrigger while preserving all functionality + // const MotionTabsTrigger = motion(TabsTrigger_Shadcn_) + + return ( + + makeTabPermanent(ref, tab.id)} + className={cn( + 'flex items-center gap-2 pl-3 pr-2.5 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'border-b border-default', + 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100', + 'relative group h-full', + 'hover:bg-surface-300 dark:hover:bg-surface-100', + tab.isPreview && 'italic font-light' // Optional: style preview tabs differently + )} + {...listeners} + > + +
+ + {shouldShowSchema && ( + + {tab?.metadata?.schema}. + + )} + + {tab.label || 'Untitled'} +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="p-0.5 ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + +
+ + {index < openTabs.length && ( +
+ )} + + ) +} diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx new file mode 100644 index 00000000000..1b271bb3401 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -0,0 +1,29 @@ +import { motion } from 'framer-motion' +import { useSnapshot } from 'valtio' + +import { useParams } from 'common' +import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' +import { getTabsStore } from 'state/tabs' + +export const TabPreview = ({ tab }: { tab: string }) => { + const { ref } = useParams() + const store = getTabsStore(ref) + const tabs = useSnapshot(store) + + const tabData = tabs.tabsMap[tab] + + if (!tabData) return null + + return ( + + + {tabData.label || 'Untitled'} +
+ + ) +} diff --git a/apps/studio/components/layouts/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx similarity index 52% rename from apps/studio/components/layouts/Tabs.tsx rename to apps/studio/components/layouts/Tabs/Tabs.tsx index 760ede2f516..8c9c79b1e87 100644 --- a/apps/studio/components/layouts/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -6,183 +6,34 @@ import { useSensor, useSensors, } from '@dnd-kit/core' -import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' +import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable' import { AnimatePresence, motion } from 'framer-motion' import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' -import { useMemo } from 'react' -import { useSnapshot } from 'valtio' import { useParams } from 'common' -import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' import { useAppStateSnapshot } from 'state/app-state' import { editorEntityTypes, - getTabsStore, handleTabClose, handleTabDragEnd, handleTabNavigation, - makeTabPermanent, + useTabsStore, type Tab, } from 'state/tabs' import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' -import { useEditorType } from './editors/EditorsLayout.hooks' -import { CollapseButton } from './Tabs/CollapseButton' +import { useEditorType } from '../editors/EditorsLayout.hooks' +import { CollapseButton } from './CollapseButton' +import { SortableTab } from './SortableTab' +import { TabPreview } from './TabPreview' -/** - * Individual draggable tab component that handles: - * - Drag - * - Drop functionality - * - Dynamic schema name display - * - Tab label animations - * - Close button interactions - */ -const SortableTab = ({ - tab, - index, - openTabs, - onClose, -}: { - tab: Tab - index: number - openTabs: Tab[] - onClose: (id: string) => void -}) => { - const router = useRouter() - const { ref } = useParams() - const currentSchema = (router.query.schema as string) || 'public' - - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: tab.id, - }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 1 : 0, - } - - // Update schema visibility check to include URL param comparison - const shouldShowSchema = useMemo(() => { - // For both table and schema tabs, show schema if: - // Any tab has a different schema than the current schema parameter - if (tab.type === 'r') { - const anyTabHasDifferentSchema = openTabs - .filter((t) => t.type === 'r') - .some((t) => t.metadata?.schema !== currentSchema) - - return anyTabHasDifferentSchema - } - - return false - }, [openTabs, currentSchema, tab.type]) - - // Create a motion version of TabsTrigger while preserving all functionality - // const MotionTabsTrigger = motion(TabsTrigger_Shadcn_) - - return ( - - makeTabPermanent(ref, tab.id)} - className={cn( - 'flex items-center gap-2 px-3 text-xs', - 'bg-dash-sidebar/50 dark:bg-surface-100/50', - 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', - 'border-b border-default', - // bottom border active rule - 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100', - 'relative group h-full', - 'hover:bg-surface-300 dark:hover:bg-surface-100', - tab.isPreview && 'italic font-light' // Optional: style preview tabs differently - )} - {...listeners} - > - -
- - {shouldShowSchema && ( - - {tab?.metadata?.schema}. - - )} - - {tab.label || 'Untitled'} -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - onClose(tab.id) - }} - > - - -
- - {index < openTabs.length && ( -
- )} - - ) -} - -const TabPreview = ({ tab }: { tab: string }) => { - const { ref } = useParams() - const store = getTabsStore(ref) - const tabs = useSnapshot(store) - - const tabData = tabs.tabsMap[tab] - - if (!tabData) return null - - return ( - - - {tabData.label || 'Untitled'} -
- - ) -} - -export function Tabs() { +export const EditorTabs = () => { const { ref } = useParams() const router = useRouter() const appSnap = useAppStateSnapshot() const editor = useEditorType() - const store = getTabsStore(ref) - const tabs = useSnapshot(store) + const tabs = useTabsStore(ref) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index 7bbb23035b0..72fe6c50c0b 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -8,8 +8,8 @@ import { } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { cn } from 'ui' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' -import { Tabs } from '../Tabs' import { CollapseButton } from '../Tabs/CollapseButton' +import { EditorTabs } from '../Tabs/Tabs' import { useEditorType } from './EditorsLayout.hooks' export interface ExplorerLayoutProps extends ComponentProps { @@ -41,8 +41,7 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore !hideTabs ? 'bg-surface-200 dark:bg-alternative' : 'bg-surface-100' )} > - {hideTabs && } - {!hideTabs && } + {hideTabs ? : }
) : null}
{children}
diff --git a/apps/studio/data/table-editor/table-editor-query.ts b/apps/studio/data/table-editor/table-editor-query.ts index b9fcdc975d9..8346ca7ae01 100644 --- a/apps/studio/data/table-editor/table-editor-query.ts +++ b/apps/studio/data/table-editor/table-editor-query.ts @@ -49,6 +49,8 @@ export const useTableEditorQuery = ( { enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined' && !isNaN(id), + refetchOnWindowFocus: false, + refetchOnMount: false, staleTime: 5 * 60 * 1000, // 5 minutes ...options, } diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index ea6977367ca..c1f16f54964 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect } from 'react' import { useParams } from 'common' import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { TableGridEditor } from 'components/interfaces/TableGridEditor' import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs' +import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' diff --git a/apps/studio/state/tabs.ts b/apps/studio/state/tabs.ts index cae1ed4fa49..7ddd4ea72ce 100644 --- a/apps/studio/state/tabs.ts +++ b/apps/studio/state/tabs.ts @@ -1,7 +1,7 @@ import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { NextRouter } from 'next/router' import { ReactNode } from 'react' -import { proxy, subscribe } from 'valtio' +import { proxy, subscribe, useSnapshot } from 'valtio' import { addRecentItem } from './recent-items' /** @@ -13,9 +13,28 @@ import { addRecentItem } from './recent-items' * So do not use this as reference for writing new valtio stores - refer to database-selector instead */ +// Object mapping editor types to their corresponding tab types +export const editorEntityTypes = { + table: ['r', 'v', 'm', 'f', 'p'], + sql: ['sql'], +} + // Define the type of tabs available in the application export type TabType = ENTITY_TYPE | 'sql' +type CreateTabIdParams = { + r: { id: number } + v: { id: number } + m: { id: number } + f: { id: number } + p: { id: number } + sql: { id: string } + schema: { schema: string } + view: never + function: never + new: never +} + export interface Tab { id: string // Unique identifier for the tab type: TabType // Type of the tab, which can be an entity type or specific string values @@ -60,7 +79,7 @@ const defaultState: TabsState = { export const tabsStore = proxy({}) // Helper to get/create state for a specific ref -export const getTabsStore = (ref: string | undefined): TabsState => { +export const getTabsStore = (ref?: string): TabsState => { if (!ref) return proxy(defaultState) if (!tabsStore[ref]) { const stored = localStorage.getItem(getStorageKey(ref)) @@ -87,6 +106,11 @@ export const getTabsStore = (ref: string | undefined): TabsState => { return tabsStore[ref] } +export const useTabsStore = (ref?: string) => { + const store = getTabsStore(ref) + return useSnapshot(store) +} + // Subscribe to changes for each ref and save to localStorage if (typeof window !== 'undefined') { subscribe(tabsStore, () => { @@ -349,19 +373,6 @@ export const handleTabDragEnd = ( handleTabNavigation(ref, tabId, router) } -type CreateTabIdParams = { - r: { id: number } - v: { id: number } - m: { id: number } - f: { id: number } - p: { id: number } - sql: { id: string } - schema: { schema: string } - view: never - function: never - new: never -} - // Function to create a unique tab ID based on type and parameters export function createTabId(type: T, params: CreateTabIdParams[T]): string { switch (type) { @@ -382,12 +393,6 @@ export function createTabId(type: T, params: CreateTabIdParam } } -// Object mapping editor types to their corresponding tab types -export const editorEntityTypes = { - table: ['r', 'v', 'm', 'f', 'p'], - sql: ['sql'], -} - // Function to remove tabs based on their editor type export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') { if (!ref) return From f183ba2c688477b389780a0a8e5306655734e741 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Apr 2025 18:02:30 +0700 Subject: [PATCH 29/35] Clean up unused logs selection renderers (#35116) Clean up unused logs selection rendereds --- .../AuthSelectionRenderer.tsx | 56 --------------- .../DatabaseApiSelectionRender.tsx | 69 ------------------- .../DatabasePostgresSelectionRender.tsx | 65 ----------------- .../FunctionInvocationSelectionRender.tsx | 69 ------------------- .../FunctionLogsSelectionRender.tsx | 47 ------------- 5 files changed, 306 deletions(-) delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/AuthSelectionRenderer.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/AuthSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/AuthSelectionRenderer.tsx deleted file mode 100644 index c6d18724f4e..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/AuthSelectionRenderer.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' -import type { PreviewLogData } from '../Logs.types' -import { - jsonSyntaxHighlight, - ResponseCodeFormatter, - SelectionDetailedRow, - SelectionDetailedTimestampRow, - SeverityFormatter, -} from '../LogsFormatters' - -const AuthSelectionRenderer = ({ log }: { log: PreviewLogData }) => { - return ( -
-
-

Event Message

-
- {log.metadata?.msg || log.event_message} -
-
- - - {log.metadata?.status && ( - } - /> - )} - {log.metadata?.level && ( - } - /> - )} - {log.metadata?.path && ( - - )} - {log.metadata?.error && ( - - )} - -
-

Metadata

-
-      
-
- ) -} - -export default AuthSelectionRenderer diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx deleted file mode 100644 index 055552e3e7a..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' -import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' -import LogsDivider from '../Logs.Divider' -import { - jsonSyntaxHighlight, - ResponseCodeFormatter, - SelectionDetailedRow, - SelectionDetailedTimestampRow, -} from '../LogsFormatters' - -const DatabaseApiSelectionRender = ({ log }: any) => { - const request = log?.metadata[0]?.request?.[0] - const response = log?.metadata[0]?.response?.[0] - const method = request?.method - const status = response?.status_code - const ipAddress = request?.headers?.[0]?.cf_connecting_ip - const countryOrigin = request?.headers?.[0]?.cf_ipcountry - const clientInfo = request?.headers?.[0]?.x_client_info - const referer = request?.headers?.[0]?.referer - const redirectIdentifier = log?.metadata[0]?.load_balancer_redirect_identifier - - return ( - <> -
- } - /> - - - - - {clientInfo && } - {referer && } - {redirectIdentifier && ( - - )} -
- -
-

Request Metadata

-
-          
-
-
- -
-

- Response Metadata -

-
-          
-
-
- - ) -} - -export default DatabaseApiSelectionRender diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx deleted file mode 100644 index ff4cf03666f..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Alert } from 'ui' -import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' -import LogsDivider from '../Logs.Divider' -import { - jsonSyntaxHighlight, - SelectionDetailedRow, - SelectionDetailedTimestampRow, - SeverityFormatter, -} from '../LogsFormatters' - -const DatabasePostgresSelectionRender = ({ log }: any) => { - const postgresUsername = log?.metadata[0]?.parsed[0]?.user_name - const sessionId = log?.metadata[0]?.parsed[0]?.session_id - const hint = log?.metadata[0]?.parsed[0]?.hint - const errorSeverity = log?.metadata[0]?.parsed[0]?.error_severity - - return ( - <> -
- Event message - -
- {log.event_message} -
-
- -
- } - /> - - - -
- {hint && ( -
- -
- )} - -
-

Metadata

-
-          
-
-
- - ) -} - -export const DatabaseApiSelectionHeaderRender = (log: any) => { - const method = log?.metadata[0]?.request[0]?.method - const path = log?.metadata[0]?.request[0]?.path - - return `${method} ${path}` -} - -export default DatabasePostgresSelectionRender diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx deleted file mode 100644 index 1035b3b7007..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { filterFunctionsRequestResponse } from 'lib/logs' -import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' -import type { PreviewLogData } from '../Logs.types' -import { - jsonSyntaxHighlight, - ResponseCodeFormatter, - SelectionDetailedRow, - SelectionDetailedTimestampRow, -} from '../LogsFormatters' - -const FunctionInvocationSelectionRender = ({ log }: { log: PreviewLogData }) => { - const metadata = log.metadata?.[0] - const request = metadata?.request?.[0] - const response = metadata?.response?.[0] - const method = request?.method - const status = response?.status_code - const requestUrl = request?.url !== undefined ? new URL(request?.url) : undefined - const executionTimeMs = metadata.execution_time_ms - const deploymentId = metadata.deployment_id - - return ( - <> -
- } - /> - - - - - - - {requestUrl !== undefined && ( - - )} -
-
-

Request Metadata

-
-          
-
-
-
-

- Response Metadata -

-
-          
-
-
- - ) -} - -export default FunctionInvocationSelectionRender diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx deleted file mode 100644 index d9e4ff82f68..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' -import { - jsonSyntaxHighlight, - SelectionDetailedRow, - SelectionDetailedTimestampRow, - SeverityFormatter, -} from '../LogsFormatters' - -const FunctionLogsSelectionRender = ({ log }: any) => { - const metadata = log.metadata[0] - - return ( - <> -
- Event message -
- {log.event_message} -
-
-
-
- } - /> - - - - -
-
-

Metadata

-
-          
-
-
- - ) -} - -export default FunctionLogsSelectionRender From bc6cca73bbe46a1a4dd2abe94dc0721400b450af Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo <31685197+soedirgo@users.noreply.github.com> Date: Tue, 22 Apr 2025 20:49:18 +0800 Subject: [PATCH 30/35] fix: remove refs to pgsodium keys from vault & wrappers (#35168) --- .../Landing/Integrations.constants.tsx | 10 - .../Vault/Keys/EncryptionKeySelector.tsx | 98 ------ .../Vault/Keys/EncryptionKeysManagement.tsx | 310 ------------------ .../Vault/Secrets/AddNewSecretModal.tsx | 73 +---- .../Vault/Secrets/DeleteSecretModal.tsx | 2 +- .../Vault/Secrets/EditSecretModal.tsx | 33 -- .../Integrations/Vault/Secrets/SecretRow.tsx | 16 +- .../Vault/Secrets/SecretsManagement.tsx | 14 +- apps/studio/data/fdw/fdw-create-mutation.ts | 2 - apps/studio/data/fdw/fdw-delete-mutation.ts | 2 - apps/studio/data/fdw/fdw-update-mutation.ts | 2 - apps/studio/data/pg-sodium-keys/keys.ts | 3 - .../pg-sodium-key-create-mutation.ts | 64 ---- .../pg-sodium-key-delete-mutation.ts | 60 ---- .../pg-sodium-keys/pg-sodium-keys-query.ts | 64 ---- .../vault/vault-secret-create-mutation.ts | 3 +- .../vault/vault-secret-update-mutation.ts | 3 +- apps/studio/data/vault/vault-secrets-query.ts | 2 +- apps/studio/types/ui.ts | 1 - 19 files changed, 15 insertions(+), 747 deletions(-) delete mode 100644 apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx delete mode 100644 apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx delete mode 100644 apps/studio/data/pg-sodium-keys/keys.ts delete mode 100644 apps/studio/data/pg-sodium-keys/pg-sodium-key-create-mutation.ts delete mode 100644 apps/studio/data/pg-sodium-keys/pg-sodium-key-delete-mutation.ts delete mode 100644 apps/studio/data/pg-sodium-keys/pg-sodium-keys-query.ts diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index a642e87976d..528a6438755 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -204,16 +204,6 @@ const supabaseIntegrations: IntegrationDefinition[] = [ loading: Loading, } ) - case 'keys': - return dynamic( - () => - import('../Vault/Keys/EncryptionKeysManagement').then( - (mod) => mod.EncryptionKeysManagement - ), - { - loading: Loading, - } - ) case 'secrets': return dynamic( () => import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement), diff --git a/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx b/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx deleted file mode 100644 index 071eae7ef06..00000000000 --- a/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { noop } from 'lodash' -import { Plus } from 'lucide-react' -import { - Input, - SelectContent_Shadcn_, - SelectGroup_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - Select_Shadcn_, -} from 'ui' - -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query' - -interface EncryptionKeySelectorProps { - nameId?: string - label?: string - labelOptional?: string - selectedKeyId?: any - error?: string - onSelectKey: (keyId: string) => void - onUpdateDescription?: (desc: string) => void -} - -const EncryptionKeySelector = ({ - nameId = 'keyName', - label = 'Encryption key', - labelOptional, - selectedKeyId, - error, - onSelectKey = noop, - onUpdateDescription = noop, -}: EncryptionKeySelectorProps) => { - const { project } = useProjectContext() - - const { data } = usePgSodiumKeysQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const keys = data || [] - - return ( - <> -
- {label && ( -
- - {labelOptional && ( - {labelOptional} - )} -
- )} - - - {keys.find((key) => key.id === selectedKeyId)?.name || 'Select an encryption key'} - - - - - - - Create a new Encryption Key - - - {keys.length > 0 && ( - <> -
- {keys.map((key) => ( - -
-

{key.name || 'No name provided'}

-

- ID: {key.id} -

-
-
- ))} - - )} - - - -
- {selectedKeyId === 'create-new' && ( - onUpdateDescription(event.target.value)} - descriptionText="Provide a name for your key for easier identification" - /> - )} - - ) -} - -export default EncryptionKeySelector diff --git a/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx deleted file mode 100644 index ef7a070cb40..00000000000 --- a/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' -import { sortBy } from 'lodash' -import { Fragment, useEffect, useState } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { DocsButton } from 'components/ui/DocsButton' -import { usePgSodiumKeyCreateMutation } from 'data/pg-sodium-keys/pg-sodium-key-create-mutation' -import { usePgSodiumKeyDeleteMutation } from 'data/pg-sodium-keys/pg-sodium-key-delete-mutation' -import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Key, Loader, Search, Trash, X } from 'lucide-react' -import { Button, Form, Input, Listbox, Modal, Separator } from 'ui' -import { Admonition } from 'ui-patterns' - -const DEFAULT_KEY_NAME = 'No description provided' - -export const EncryptionKeysManagement = () => { - const { search } = useParams() - const { project } = useProjectContext() - - const [searchValue, setSearchValue] = useState('') - const [selectedSort, setSelectedSort] = useState<'name' | 'created'>('created') - const [showAddKeyModal, setShowAddKeyModal] = useState(false) - const [selectedKeyToRemove, setSelectedKeyToRemove] = useState() - const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - - useEffect(() => { - if (search !== undefined) setSearchValue(search) - }, [search]) - - const { data, isLoading } = usePgSodiumKeysQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { mutate: addKeyMutation, isLoading: isCreating } = usePgSodiumKeyCreateMutation({ - onSuccess: () => { - toast.success('Successfully added new key') - setShowAddKeyModal(false) - }, - }) - const { mutate: deleteKeyMutation, isLoading: isDeleting } = usePgSodiumKeyDeleteMutation({ - onSuccess: () => { - toast.success(`Successfully deleted encryption key`) - setSelectedKeyToRemove(undefined) - }, - }) - - const allKeys = data || [] - const keys = sortBy( - searchValue - ? allKeys.filter( - (key) => - (key?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) || - key.id.toLowerCase().includes(searchValue.toLowerCase()) - ) - : allKeys, - (k) => { - if (selectedSort === 'created') { - return Number(new Date(k.created)) - } else { - return k[selectedSort] - } - } - ) - - const addKey = async (values: any, { setSubmitting }: any) => { - if (!project) return console.error('Project is required') - - addKeyMutation({ - projectRef: project.ref, - connectionString: project.connectionString, - name: values.name, - }) - } - - const confirmDeleteKey = async () => { - if (!selectedKeyToRemove) return - if (!project) return console.error('Project is required') - - deleteKeyMutation({ - projectRef: project.ref, - connectionString: project.connectionString, - id: selectedKeyToRemove.id, - }) - } - - return ( - <> -
-
-
- setSearchValue(event.target.value)} - icon={} - actions={ - searchValue.length > 0 - ? [ -
-
- - setShowAddKeyModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canManageKeys ? 'You need additional permissions to add keys' : undefined, - }, - }} - > - Add new key - -
-
- - {/* Table of keys */} -
- {isLoading ? ( -
- -

Loading keys from the Vault

-
- ) : ( - <> - {keys.map((key, idx) => { - return ( - -
- -
-

- {key.name || DEFAULT_KEY_NAME} -

-

- ID: {key.id} -

-
-
-

- Added on {dayjs(key.created).format('MMM D, YYYY')} -

- } - disabled={!canManageKeys} - onClick={() => setSelectedKeyToRemove(key)} - tooltip={{ - content: { - side: 'bottom', - text: !canManageKeys - ? 'You need additional permissions to delete keys' - : undefined, - }, - }} - /> -
-
- {idx !== keys.length - 1 && } -
- ) - })} - {keys.length === 0 && ( - <> - {searchValue.length === 0 ? ( -
-

No encryption keys added yet

-

- Encryption keys are created by the pgsodium extension and can be used to - encrypt your columns and secrets -

-
- ) : ( -
-

No results found

-

- Your search for "{searchValue}" did not return any results -

-
- )} - - )} - - )} -
-
- - setSelectedKeyToRemove(undefined)} - onConfirm={confirmDeleteKey} - loading={isDeleting} - header="Confirm to delete key" - > - - -

- The following key will be permanently removed and cannot be recovered. -

-
-

- {selectedKeyToRemove?.name ?? DEFAULT_KEY_NAME} -

-

- ID: {selectedKeyToRemove?.id} -

-
-
-
- - setShowAddKeyModal(false)} - header="Add a new key" - > - { - const errors: any = {} - if (values.name.length === 0) errors.name = 'Please provide a name for your key' - return errors - }} - onSubmit={addKey} - > - {() => { - return ( - <> - -

Provide a name for your key for easier identification.

- -
- - - - - - - ) - }} - -
- - ) -} diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx index 2337b5b9ffb..0f9996d60da 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx @@ -4,12 +4,9 @@ import { toast } from 'sonner' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import InformationBox from 'components/ui/InformationBox' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { usePgSodiumKeyCreateMutation } from 'data/pg-sodium-keys/pg-sodium-key-create-mutation' -import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query' import { useVaultSecretCreateMutation } from 'data/vault/vault-secret-create-mutation' import { Eye, EyeOff, HelpCircle } from 'lucide-react' import { Button, Form, Input, Modal } from 'ui' -import EncryptionKeySelector from '../Keys/EncryptionKeySelector' interface AddNewSecretModalProps { visible: boolean @@ -18,35 +15,20 @@ interface AddNewSecretModalProps { const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { const [showSecretValue, setShowSecretValue] = useState(false) - const [selectedKeyId, setSelectedKeyId] = useState() const { project } = useProjectContext() - const { mutateAsync: addKeyMutation } = usePgSodiumKeyCreateMutation() const { mutateAsync: addSecret } = useVaultSecretCreateMutation() - // const { data: keys, isLoading } = usePgSodiumKeysQuery( - // { - // projectRef: project?.ref, - // connectionString: project?.connectionString, - // }, - // { - // enabled: visible, - // } - // ) - - // useEffect(() => { - // if (visible && keys) { - // setShowSecretValue(false) - // setSelectedKeyId(keys[0]?.id ?? 'create-new') - // } - // }, [visible, keys]) + useEffect(() => { + if (visible) { + setShowSecretValue(false) + } + }, [visible]) const validate = (values: any) => { const errors: any = {} if (values.name.length === 0) errors.name = 'Please provide a name for your secret' if (values.secret.length === 0) errors.secret = 'Please enter your secret value' - if (selectedKeyId === 'create-new' && values.keyName.length === 0) - errors.keyName = 'Please provide a name for your new key' return errors } @@ -54,18 +36,9 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { if (!project) return console.error('Project is required') setSubmitting(true) - let encryptionKeyId = selectedKeyId try { setSubmitting(true) - if (selectedKeyId === 'create-new') { - const addKeyRes = await addKeyMutation({ - projectRef: project?.ref!, - connectionString: project?.connectionString, - name: values.keyName || undefined, - }) - encryptionKeyId = addKeyRes[0].id - } await addSecret({ projectRef: project.ref, @@ -73,7 +46,6 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { name: values.name, description: values.description, secret: values.secret, - key_id: encryptionKeyId, }) toast.success(`Successfully added new secret ${values.name}`) onClose() @@ -86,11 +58,6 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => { return ( - {/* {isLoading ? ( - - - - ) : ( */}
{ /> - - <> - {/* */} - {/* } - url="https://github.com/supabase/vault" - urlLabel="Vault documentation" - title="What is a key?" - description={ -
-

- Keys are used to encrypt data inside your database, and every secret in the - Vault is encrypted with a key. -

-

- You may create different keys for different purposes, such as one for - encrypting user data, and another for application data. -

-
- } - /> */} -
-
- - - {secret.key_id} - + +

+ {secret.id} +

diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index c88dd085827..2faf363d538 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -39,10 +39,8 @@ export const SecretsManagement = () => { const allSecrets = data || [] const secrets = sortBy( searchValue.length > 0 - ? allSecrets.filter( - (secret) => - (secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) || - secret.key_id.toLowerCase().includes(searchValue.toLowerCase()) + ? allSecrets.filter((secret) => + (secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ) : allSecrets, (s) => { @@ -99,14 +97,6 @@ export const SecretsManagement = () => { > Name - - Key ID -
diff --git a/apps/studio/data/fdw/fdw-create-mutation.ts b/apps/studio/data/fdw/fdw-create-mutation.ts index 536057117cc..47bf99ae9b7 100644 --- a/apps/studio/data/fdw/fdw-create-mutation.ts +++ b/apps/studio/data/fdw/fdw-create-mutation.ts @@ -7,7 +7,6 @@ import { } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' -import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' import { executeSql } from 'data/sql/execute-sql-query' import { wrapWithTransaction } from 'data/sql/utils/transaction' import { vaultSecretsKeys } from 'data/vault/keys' @@ -243,7 +242,6 @@ export const useFDWCreateMutation = ({ queryClient.invalidateQueries(fdwKeys.list(projectRef), { refetchType: 'all' }), queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), queryClient.invalidateQueries(foreignTableKeys.list(projectRef)), - queryClient.invalidateQueries(pgSodiumKeys.list(projectRef)), queryClient.invalidateQueries(vaultSecretsKeys.list(projectRef)), ]) diff --git a/apps/studio/data/fdw/fdw-delete-mutation.ts b/apps/studio/data/fdw/fdw-delete-mutation.ts index 2e520b9b441..bed193cb09c 100644 --- a/apps/studio/data/fdw/fdw-delete-mutation.ts +++ b/apps/studio/data/fdw/fdw-delete-mutation.ts @@ -4,7 +4,6 @@ import { toast } from 'sonner' import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' -import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' import { executeSql } from 'data/sql/execute-sql-query' import { wrapWithTransaction } from 'data/sql/utils/transaction' import { vaultSecretsKeys } from 'data/vault/keys' @@ -113,7 +112,6 @@ export const useFDWDeleteMutation = ({ queryClient.invalidateQueries(fdwKeys.list(projectRef), { refetchType: 'all' }), queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), queryClient.invalidateQueries(foreignTableKeys.list(projectRef)), - queryClient.invalidateQueries(pgSodiumKeys.list(projectRef)), queryClient.invalidateQueries(vaultSecretsKeys.list(projectRef)), ]) diff --git a/apps/studio/data/fdw/fdw-update-mutation.ts b/apps/studio/data/fdw/fdw-update-mutation.ts index c49c729559f..710413255e7 100644 --- a/apps/studio/data/fdw/fdw-update-mutation.ts +++ b/apps/studio/data/fdw/fdw-update-mutation.ts @@ -4,7 +4,6 @@ import { toast } from 'sonner' import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' -import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' import { executeSql } from 'data/sql/execute-sql-query' import { wrapWithTransaction } from 'data/sql/utils/transaction' import { vaultSecretsKeys } from 'data/vault/keys' @@ -76,7 +75,6 @@ export const useFDWUpdateMutation = ({ queryClient.invalidateQueries(fdwKeys.list(projectRef), { refetchType: 'all' }), queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), queryClient.invalidateQueries(foreignTableKeys.list(projectRef)), - queryClient.invalidateQueries(pgSodiumKeys.list(projectRef)), queryClient.invalidateQueries(vaultSecretsKeys.list(projectRef)), ]) diff --git a/apps/studio/data/pg-sodium-keys/keys.ts b/apps/studio/data/pg-sodium-keys/keys.ts deleted file mode 100644 index 9c35fc21e50..00000000000 --- a/apps/studio/data/pg-sodium-keys/keys.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const pgSodiumKeys = { - list: (projectRef: string | undefined) => ['projects', projectRef, 'pg-sodium-keys'] as const, -} diff --git a/apps/studio/data/pg-sodium-keys/pg-sodium-key-create-mutation.ts b/apps/studio/data/pg-sodium-keys/pg-sodium-key-create-mutation.ts deleted file mode 100644 index d92bb1c6cb4..00000000000 --- a/apps/studio/data/pg-sodium-keys/pg-sodium-key-create-mutation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { executeSql } from 'data/sql/execute-sql-query' -import type { ResponseError } from 'types' -import { pgSodiumKeys } from './keys' - -export type CreateEncryptionKeyVariables = { - projectRef: string - connectionString?: string - name: string -} - -export async function createEncryptionKey({ - projectRef, - connectionString, - name, -}: CreateEncryptionKeyVariables) { - const sql = - name !== undefined - ? `select * from pgsodium.create_key(name := '${name}')` - : `select * from pgsodium.create_key()` - - const { result } = await executeSql({ - projectRef, - connectionString, - sql, - queryKey: ['projects', projectRef, 'pg-sodium-keys', 'create'], - }) - - return result -} - -type EncryptionKeyCreateData = Awaited> - -export const usePgSodiumKeyCreateMutation = ({ - onError, - onSuccess, - ...options -}: Omit< - UseMutationOptions, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation( - (vars) => createEncryptionKey(vars), - { - async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(pgSodiumKeys.list(projectRef)) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to create key: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/data/pg-sodium-keys/pg-sodium-key-delete-mutation.ts b/apps/studio/data/pg-sodium-keys/pg-sodium-key-delete-mutation.ts deleted file mode 100644 index 32d42c08b08..00000000000 --- a/apps/studio/data/pg-sodium-keys/pg-sodium-key-delete-mutation.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { Query } from '@supabase/pg-meta/src/query' -import { executeSql } from 'data/sql/execute-sql-query' -import type { ResponseError } from 'types' -import { pgSodiumKeys } from './keys' - -export type VaultSecretDeleteVariables = { - projectRef: string - connectionString?: string - id: string -} - -export async function deleteVaultSecret({ - projectRef, - connectionString, - id, -}: VaultSecretDeleteVariables) { - const sql = new Query().from('key', 'pgsodium').delete().match({ id }).toSql() - const { result } = await executeSql({ - projectRef, - connectionString, - sql, - queryKey: ['projects', projectRef, 'pg-sodium-keys', 'delete', id], - }) - return result -} - -type VaultSecretDeleteData = Awaited> - -export const usePgSodiumKeyDeleteMutation = ({ - onError, - onSuccess, - ...options -}: Omit< - UseMutationOptions, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation( - (vars) => deleteVaultSecret(vars), - { - async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(pgSodiumKeys.list(projectRef)) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to delete key: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/data/pg-sodium-keys/pg-sodium-keys-query.ts b/apps/studio/data/pg-sodium-keys/pg-sodium-keys-query.ts deleted file mode 100644 index c6bfbc52693..00000000000 --- a/apps/studio/data/pg-sodium-keys/pg-sodium-keys-query.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query' -import { Query } from '@supabase/pg-meta/src/query' -import { sortBy } from 'lodash' -import { executeSql } from '../sql/execute-sql-query' -import { pgSodiumKeys } from './keys' - -export interface EncryptionKey { - id: string - key_id: number - name: string - comment: string - created: string - status: string -} - -export const getPgSodiumKeysQuery = () => { - const sql = new Query() - .from('key', 'pgsodium') - .select('id,key_id,name,comment,created,status') - .toSql() - - return sql -} - -export type PgSodiumKeysVariables = { - projectRef?: string - connectionString?: string -} - -const getPgSodiumKeys = async ( - { projectRef, connectionString }: PgSodiumKeysVariables, - signal?: AbortSignal -) => { - const sql = getPgSodiumKeysQuery() - const { result } = await executeSql( - { - projectRef, - connectionString, - sql, - queryKey: pgSodiumKeys.list(projectRef), - }, - signal - ) - return result -} - -export type PgSodiumKeysData = EncryptionKey[] -export type PgSodiumKeysError = unknown - -export const usePgSodiumKeysQuery = ( - { projectRef, connectionString }: PgSodiumKeysVariables, - { enabled = true, ...options }: UseQueryOptions = {} -) => - useQuery( - pgSodiumKeys.list(projectRef), - ({ signal }) => getPgSodiumKeys({ projectRef, connectionString }, signal), - { - select(data) { - return sortBy(data, (k) => Number(new Date(k.created))) as any - }, - enabled: enabled && typeof projectRef !== 'undefined', - ...options, - } - ) diff --git a/apps/studio/data/vault/vault-secret-create-mutation.ts b/apps/studio/data/vault/vault-secret-create-mutation.ts index bb28f59cd5c..bba2b6300c3 100644 --- a/apps/studio/data/vault/vault-secret-create-mutation.ts +++ b/apps/studio/data/vault/vault-secret-create-mutation.ts @@ -16,13 +16,12 @@ export async function createVaultSecret({ connectionString, ...newSecret }: VaultSecretCreateVariables) { - const { name, description, secret, key_id } = newSecret + const { name, description, secret } = newSecret const sql = /* SQL */ ` select vault.create_secret( new_secret := ${quoteLiteral(secret)} ${name ? `, new_name := ${quoteLiteral(name)}` : ''} ${description ? `, new_description := ${quoteLiteral(description)}` : ''} - ${key_id ? `, new_key_id := ${quoteLiteral(key_id)}` : ''} ) ` diff --git a/apps/studio/data/vault/vault-secret-update-mutation.ts b/apps/studio/data/vault/vault-secret-update-mutation.ts index a1467eb50d0..779d2cf45ce 100644 --- a/apps/studio/data/vault/vault-secret-update-mutation.ts +++ b/apps/studio/data/vault/vault-secret-update-mutation.ts @@ -18,14 +18,13 @@ export async function updateVaultSecret({ id, ...payload }: VaultSecretUpdateVariables) { - const { name, description, secret, key_id } = payload + const { name, description, secret } = payload const sql = /* SQL */ ` select vault.update_secret( secret_id := ${quoteLiteral(id)} ${secret ? `, new_secret := ${quoteLiteral(secret)}` : ''} ${name ? `, new_name := ${quoteLiteral(name)}` : ''} ${description ? `, new_description := ${quoteLiteral(description)}` : ''} - ${key_id ? `, new_key_id := ${quoteLiteral(key_id)}` : ''} ) ` diff --git a/apps/studio/data/vault/vault-secrets-query.ts b/apps/studio/data/vault/vault-secrets-query.ts index 52ed1229707..283a339bcd3 100644 --- a/apps/studio/data/vault/vault-secrets-query.ts +++ b/apps/studio/data/vault/vault-secrets-query.ts @@ -7,7 +7,7 @@ import { vaultSecretsKeys } from './keys' export const getVaultSecretsSql = () => { const sql = new Query() .from('secrets', 'vault') - .select('id,name,description,secret,key_id,created_at,updated_at') + .select('id,name,description,secret,created_at,updated_at') .toSql() return sql diff --git a/apps/studio/types/ui.ts b/apps/studio/types/ui.ts index 79637a5b3ad..69d3cea5cf4 100644 --- a/apps/studio/types/ui.ts +++ b/apps/studio/types/ui.ts @@ -31,7 +31,6 @@ export interface VaultSecret { description: string secret: string decryptedSecret?: string - key_id: string created_at: string updated_at: string } From 39a12342ea0bd31cbf5657c204ab6e39d297b915 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:14:02 -0400 Subject: [PATCH 31/35] fix (docs): middleware rewrites for reference pages (#35201) Before: There was a bug with middleware rewrites for reference pages without crawler versions (basically all references except the client SDKs). There was a branch that was not handled (the requesting user agent is a a bot, but the reference is not a client SDK), so it failed through to the base case of NextResponse.next(), which 404s as that page does not exist for a path like /reference/api/v1-deploy-a-function After: Fixed the branch. If all of the following are true: - Is a reference page - Requesting user agent is a bot - Reference is not a client SDK Then it should follow the same path as a non-bot request, because there is no bot-specific version. That means it should be rewritten to teh slugless page: /reference/api/v1-deploya-function => /reference/api --- .../docs/app/api/crawlers/route.smoke.test.ts | 7 +++ apps/docs/middleware.ts | 48 +++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/apps/docs/app/api/crawlers/route.smoke.test.ts b/apps/docs/app/api/crawlers/route.smoke.test.ts index e2224180cec..649a26e93c3 100644 --- a/apps/docs/app/api/crawlers/route.smoke.test.ts +++ b/apps/docs/app/api/crawlers/route.smoke.test.ts @@ -70,3 +70,10 @@ describe('prod smoke test: crawler pages return correct data', () => { expect(headings.includes('examples')).toBe(true) }) }) + +describe('prod smoke test: ref pages without crawler versions', async () => { + it('returns 200', async () => { + const result = await fetch(REFERENCE_DOCS_URL + '/api/v1-deploy-a-function') + expect(result.status).toBe(200) + }) +}) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 8d784d4313e..cdc560edfc6 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,15 +1,12 @@ import { isbot } from 'isbot' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' - +import { type NextRequest, NextResponse } from 'next/server' import { clientSdkIds } from '~/content/navigation.references' import { BASE_PATH } from '~/lib/constants' -const REFERENCE_PATH = `${(BASE_PATH ?? '') + '/'}reference` +const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` export function middleware(request: NextRequest) { const url = new URL(request.url) - if (!url.pathname.startsWith(REFERENCE_PATH)) { return NextResponse.next() } @@ -26,38 +23,37 @@ export function middleware(request: NextRequest) { if (slug.length > 0) { const rewriteUrl = new URL(url) rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers' - return NextResponse.rewrite(rewriteUrl) } } - } else { - const [, lib, maybeVersion] = url.pathname.replace(REFERENCE_PATH, '').split('/') + } - if (lib === 'cli') { - const rewritePath = [REFERENCE_PATH, 'cli'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) - } + const [, lib, maybeVersion] = url.pathname.replace(REFERENCE_PATH, '').split('/') - if (lib === 'api') { - const rewritePath = [REFERENCE_PATH, 'api'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) - } + if (lib === 'cli') { + const rewritePath = [REFERENCE_PATH, 'cli'].join('/') + return NextResponse.rewrite(new URL(rewritePath, request.url)) + } - if (lib?.startsWith('self-hosting-')) { - const rewritePath = [REFERENCE_PATH, lib].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) - } + if (lib === 'api') { + const rewritePath = [REFERENCE_PATH, 'api'].join('/') + return NextResponse.rewrite(new URL(rewritePath, request.url)) + } - if (clientSdkIds.includes(lib)) { - const version = /v\d+/.test(maybeVersion) ? maybeVersion : null - const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) - } + if (lib?.startsWith('self-hosting-')) { + const rewritePath = [REFERENCE_PATH, lib].join('/') + return NextResponse.rewrite(new URL(rewritePath, request.url)) + } + + if (clientSdkIds.includes(lib)) { + const version = /v\d+/.test(maybeVersion) ? maybeVersion : null + const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') + return NextResponse.rewrite(new URL(rewritePath, request.url)) } return NextResponse.next() } export const config = { - matcher: '/((?!api|_next|static|public|favicon.ico).*)', + matcher: '/reference/:path*', } From f504436af71ae3c0a68b5c91f3fed87e4943bb88 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 23 Apr 2025 06:32:44 +0200 Subject: [PATCH 32/35] chore: Bump version of monaco editor (#35207) * Bump monaco editor to 0.52.2 * Bump the cloudflare loaded instances of monaco. * Fix the types. --- apps/studio/package.json | 2 +- apps/studio/pages/_app.tsx | 4 +-- apps/studio/pages/_document.tsx | 2 +- .../ui-patterns/SqlToRest/sql-to-rest.tsx | 30 +++++++++---------- pnpm-lock.yaml | 26 ++++++++-------- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index adc7d6966a3..5986b14ca65 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -82,7 +82,7 @@ "mime-db": "^1.53.0", "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", - "monaco-editor": "0.33.0", + "monaco-editor": "0.52.2", "next": "catalog:", "next-themes": "^0.3.0", "nuqs": "^2.4.1", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f19ef04cae4..6f00417d4d1 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -27,9 +27,9 @@ import relativeTime from 'dayjs/plugin/relativeTime' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import Head from 'next/head' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { FeatureFlagProvider, PageTelemetry, ThemeProvider, useThemeSandbox } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' @@ -67,7 +67,7 @@ loader.config({ // The alternative was to import * as monaco from 'monaco-editor' but i couldn't get it working paths: { vs: IS_PLATFORM - ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.0/min/vs' + ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' : `${BASE_PATH}/monaco-editor`, }, }) diff --git a/apps/studio/pages/_document.tsx b/apps/studio/pages/_document.tsx index e7e79c945fb..c8c36e2c348 100644 --- a/apps/studio/pages/_document.tsx +++ b/apps/studio/pages/_document.tsx @@ -20,7 +20,7 @@ class MyDocument extends Document { data-name="vs/editor/editor.main" href={ IS_PLATFORM - ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.0/min/vs/editor/editor.main.css' + ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/editor/editor.main.css' : `${BASE_PATH}/monaco-editor/editor/editor.main.css` } /> diff --git a/packages/ui-patterns/SqlToRest/sql-to-rest.tsx b/packages/ui-patterns/SqlToRest/sql-to-rest.tsx index 5a1187c8e39..4928133a33f 100644 --- a/packages/ui-patterns/SqlToRest/sql-to-rest.tsx +++ b/packages/ui-patterns/SqlToRest/sql-to-rest.tsx @@ -1,19 +1,6 @@ 'use client' import Editor, { useMonaco } from '@monaco-editor/react' -import { ChevronUp, GitPullRequest } from 'lucide-react' -import type { editor } from 'monaco-editor' -import { useTheme } from 'next-themes' -import { - PropsWithChildren, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useState, -} from 'react' -import Markdown from 'react-markdown' -import { format } from 'sql-formatter' import { HttpRequest, ParsingError, @@ -28,6 +15,19 @@ import { renderHttp, renderSupabaseJs, } from '@supabase/sql-to-rest' +import { ChevronUp, GitPullRequest } from 'lucide-react' +import type { editor } from 'monaco-editor' +import { useTheme } from 'next-themes' +import { + PropsWithChildren, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react' +import Markdown from 'react-markdown' +import { format } from 'sql-formatter' import { CodeBlock, Collapsible, Tabs, cn } from 'ui' import { Alert } from 'ui/src/components/shadcn/ui/alert' import { assumptions } from './assumptions' @@ -287,11 +287,11 @@ export default function SqlToRest({ // Format on cmd+s editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, async () => { - await editor.getAction('editor.action.formatDocument').run() + await editor.getAction('editor.action.formatDocument')?.run() }) // Run format on the initial value - await editor.getAction('editor.action.formatDocument').run() + await editor.getAction('editor.action.formatDocument')?.run() }} onChange={async (sql) => { if (!sql) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea740c02e5f..13294f6b304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -585,7 +585,7 @@ importers: version: 3.3.1(react-hook-form@7.47.0(react@18.3.1)) '@monaco-editor/react': specifier: ^4.6.0 - version: 4.6.0(monaco-editor@0.33.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@next/bundle-analyzer': specifier: ^14.2.3 version: 14.2.3 @@ -722,8 +722,8 @@ importers: specifier: ^4.0.5 version: 4.0.5(mobx@6.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) monaco-editor: - specifier: 0.33.0 - version: 0.33.0 + specifier: 0.52.2 + version: 0.52.2 next: specifier: 'catalog:' version: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) @@ -1963,7 +1963,7 @@ importers: version: 3.3.1(react-hook-form@7.47.0(react@18.3.1)) '@monaco-editor/react': specifier: ^4.6.0 - version: 4.6.0(monaco-editor@0.33.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@supabase/sql-to-rest': specifier: ^0.1.6 version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) @@ -2002,7 +2002,7 @@ importers: version: 3.0.0 monaco-editor: specifier: '*' - version: 0.33.0 + version: 0.52.2 next: specifier: '*' version: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) @@ -10897,8 +10897,8 @@ packages: module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} - monaco-editor@0.33.0: - resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -16303,15 +16303,15 @@ snapshots: '@mjackson/node-fetch-server@0.2.0': {} - '@monaco-editor/loader@1.4.0(monaco-editor@0.33.0)': + '@monaco-editor/loader@1.4.0(monaco-editor@0.52.2)': dependencies: - monaco-editor: 0.33.0 + monaco-editor: 0.52.2 state-local: 1.0.7 - '@monaco-editor/react@4.6.0(monaco-editor@0.33.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@monaco-editor/react@4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@monaco-editor/loader': 1.4.0(monaco-editor@0.33.0) - monaco-editor: 0.33.0 + '@monaco-editor/loader': 1.4.0(monaco-editor@0.52.2) + monaco-editor: 0.52.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -25882,7 +25882,7 @@ snapshots: module-details-from-path@1.0.3: {} - monaco-editor@0.33.0: {} + monaco-editor@0.52.2: {} moo@0.5.2: {} From 23ceb9072dd39b7cce613aae7af3c78c0dea1054 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 23 Apr 2025 10:52:46 +0530 Subject: [PATCH 33/35] Add replication UI in Studio (#30090) * add dummy sinks and pipelines pages * update api types * show empty sources state * show empty replication state at /replication * create source when enable replication button is clicked * improve replication page when replication is enabled * replace sources page with publications page * publications table * show publications in table * create publication wip * show toast error instead of throwing an exception * user can now delete a publication * show empty sinks page * create and list sinks * add ui to delete a sink * show pipelines on the pipelines page * add ui to create and delete pipelines * get pipeline status wip * show pipeline status wip * show correct label on action buttons * start and stop pipelines * remove a couple of console.logs * fix error when deleting a pipeline * only consider replication enabled when a source with name = ref is present * add source and sink names * correct colspan for 'no pipelines' row * hide 'supabase_realtime' publication on ui * move filtering to fetch query * show sink name in ui * show source/sink names on pipelines page * fix start/stop status shown on ui * fix prettier formatting * update api types * extract pipeline action button as a separate component * fix a crashing page * fixed publications page crash * update to match with changes in api * add new replication page under database * hide replication page behind feature flag * update types * update api types * show destinations empty state * add destinations table * factor out components from Destinations table * show status dot * move pipeline fetch query to parent component * add ability to enable/disable a pipeline * show loader when starting or stopping a pipeline * fix a bug in which loading & empty states were shown together * fix a bug in which error & empty states were shown together * wrap in default layout * add new destination panel * add type field * fix a forwardRef error * fix layout * create destination * delete destination * add ability to create or delete destinations with pipelines * create source if missing * show only a single error * add an enable switch * new layout * add subsections * comment out unused code * show enabled switch only in the header * close panel when destination is created * disable buttons when api requests in flight * reduce panel size * remove commented out code * treat max size and max fill secs as numbers * use drop down to show publications * simpler vertical layout * add separators * add form validation * remove publications drop down padding * add new publication button * hide advanced settings behind an accordion * add some margin between icon and text * show publications panel on clicking new publication button * add header to new publication panel * fix validation not running for publication drop down * create publication in the new publication panel * add table selector in new publication panel * update api types * remove old code * update platform.d.ts * update navigation bar utils * remove a redirect from replication page to publications page * ask user for confirmation before deleting destination * edit destination panel * edit destination panel values fixed * bug fixes * fix prettier formatting * enable/disable pipeline after editing * rename snake_case params to camelCase * loading button when editing * remove merge markers * update api types * add max_staleness parameter in sinks for bigquery * add read replicas flow diagram to replication page * remove an unused import * Revert "add read replicas flow diagram to replication page" This reverts commit 8852d7847b457885603dba786141a8aaf8e99350. * add panel to warn users about additional cost before creating a destination * hide replication page contents behind a feature flag * fix merge conflicts * styling changes * revert static flag * styling updates * fixes * fix switch * copy * fix layout --------- Co-authored-by: Saxon Fletcher --- .../Replication/DeleteDestination.tsx | 41 ++ .../Database/Replication/DestinationPanel.tsx | 519 ++++++++++++++++++ .../Database/Replication/DestinationRow.tsx | 206 +++++++ .../Database/Replication/Destinations.tsx | 138 +++++ .../Replication/NewPublicationPanel.tsx | 179 ++++++ .../Database/Replication/PipelineStatus.tsx | 57 ++ .../Replication/PublicationsComboBox.tsx | 132 +++++ .../Database/Replication/RowMenu.tsx | 72 +++ .../layouts/DatabaseLayout/DatabaseLayout.tsx | 3 + .../DatabaseLayout/DatabaseMenu.utils.tsx | 14 +- .../replication/create-pipeline-mutation.ts | 77 +++ .../create-publication-mutation.ts | 66 +++ .../data/replication/create-sink-mutation.ts | 75 +++ .../replication/create-source-mutation.ts | 56 ++ .../replication/delete-pipeline-mutation.ts | 60 ++ .../delete-publication-mutation.ts | 64 +++ .../data/replication/delete-sink-mutation.ts | 54 ++ apps/studio/data/replication/keys.ts | 15 + .../data/replication/pipeline-by-id-query.ts | 42 ++ .../data/replication/pipeline-status-query.ts | 45 ++ .../data/replication/pipelines-query.ts | 39 ++ .../data/replication/publications-query.ts | 47 ++ .../data/replication/sink-by-id-query.ts | 42 ++ apps/studio/data/replication/sinks-query.ts | 33 ++ apps/studio/data/replication/sources-query.ts | 36 ++ .../replication/start-pipeline-mutation.ts | 60 ++ .../replication/stop-pipeline-mutation.ts | 57 ++ apps/studio/data/replication/tables-query.ts | 40 ++ .../replication/update-pipeline-mutation.ts | 79 +++ .../data/replication/update-sink-mutation.ts | 77 +++ apps/studio/next.config.js | 5 - .../project/[ref]/database/replication.tsx | 42 ++ 32 files changed, 2466 insertions(+), 6 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/Destinations.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/RowMenu.tsx create mode 100644 apps/studio/data/replication/create-pipeline-mutation.ts create mode 100644 apps/studio/data/replication/create-publication-mutation.ts create mode 100644 apps/studio/data/replication/create-sink-mutation.ts create mode 100644 apps/studio/data/replication/create-source-mutation.ts create mode 100644 apps/studio/data/replication/delete-pipeline-mutation.ts create mode 100644 apps/studio/data/replication/delete-publication-mutation.ts create mode 100644 apps/studio/data/replication/delete-sink-mutation.ts create mode 100644 apps/studio/data/replication/keys.ts create mode 100644 apps/studio/data/replication/pipeline-by-id-query.ts create mode 100644 apps/studio/data/replication/pipeline-status-query.ts create mode 100644 apps/studio/data/replication/pipelines-query.ts create mode 100644 apps/studio/data/replication/publications-query.ts create mode 100644 apps/studio/data/replication/sink-by-id-query.ts create mode 100644 apps/studio/data/replication/sinks-query.ts create mode 100644 apps/studio/data/replication/sources-query.ts create mode 100644 apps/studio/data/replication/start-pipeline-mutation.ts create mode 100644 apps/studio/data/replication/stop-pipeline-mutation.ts create mode 100644 apps/studio/data/replication/tables-query.ts create mode 100644 apps/studio/data/replication/update-pipeline-mutation.ts create mode 100644 apps/studio/data/replication/update-sink-mutation.ts create mode 100644 apps/studio/pages/project/[ref]/database/replication.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx new file mode 100644 index 00000000000..4782500013a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx @@ -0,0 +1,41 @@ +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' + +interface DeleteDestinationProps { + visible: boolean + setVisible: (value: boolean) => void + onDelete: () => void + isLoading: boolean + name: string +} + +const DeleteDestination = ({ + visible, + setVisible, + onDelete, + isLoading, + name, +}: DeleteDestinationProps) => { + return ( + <> + setVisible(!visible)} + onConfirm={onDelete} + title="Delete this destination" + loading={isLoading} + confirmLabel={`Delete destination`} + confirmPlaceholder="Type in name of destination" + confirmString={name ?? 'Unknown'} + text={ + <> + This will delete the destination{' '} + + } + alert={{ title: 'You cannot recover this destination once deleted.' }} + /> + + ) +} + +export default DeleteDestination diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx new file mode 100644 index 00000000000..8acd2964420 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -0,0 +1,519 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useParams } from 'common' +import { useCreatePipelineMutation } from 'data/replication/create-pipeline-mutation' +import { useCreateSinkMutation } from 'data/replication/create-sink-mutation' +import { useCreateSourceMutation } from 'data/replication/create-source-mutation' +import { useReplicationPublicationsQuery } from 'data/replication/publications-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useUpdateSinkMutation } from 'data/replication/update-sink-mutation' +import { useUpdatePipelineMutation } from 'data/replication/update-pipeline-mutation' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { + Accordion_Shadcn_, + AccordionContent_Shadcn_, + AccordionItem_Shadcn_, + AccordionTrigger_Shadcn_, + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + TextArea_Shadcn_, + WarningIcon, + Label_Shadcn_ as Label, +} from 'ui' +import * as z from 'zod' +import PublicationsComboBox from './PublicationsComboBox' +import NewPublicationPanel from './NewPublicationPanel' +import { useState, useMemo, useEffect } from 'react' +import { useReplicationSinkByIdQuery } from 'data/replication/sink-by-id-query' +import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface DestinationPanelProps { + visible: boolean + sourceId: number | undefined + onClose: () => void + existingDestination?: { + sourceId?: number + sinkId: number + pipelineId?: number + enabled: boolean + } +} + +const DestinationPanel = ({ + visible, + sourceId, + onClose, + existingDestination, +}: DestinationPanelProps) => { + const { ref: projectRef } = useParams() + const [publicationPanelVisible, setPublicationPanelVisible] = useState(false) + const { mutateAsync: createSource, isLoading: creatingSource } = useCreateSourceMutation() + const { mutateAsync: createSink, isLoading: creatingSink } = useCreateSinkMutation() + const { mutateAsync: createPipeline, isLoading: creatingPipeline } = useCreatePipelineMutation() + const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline, isLoading: stoppingPipeline } = useStopPipelineMutation() + const { mutateAsync: updateSink, isLoading: updatingSink } = useUpdateSinkMutation() + const { mutateAsync: updatePipeline, isLoading: updatingPipeline } = useUpdatePipelineMutation() + const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({ + projectRef, + sourceId, + }) + + const { data: sinkData } = useReplicationSinkByIdQuery({ + projectRef, + sinkId: existingDestination?.sinkId, + }) + + const { data: pipelineData } = useReplicationPipelineByIdQuery({ + projectRef, + pipelineId: existingDestination?.pipelineId, + }) + + const isCreating = creatingSource || creatingSink || creatingPipeline || startingPipeline + const isUpdating = updatingSink || updatingPipeline || stoppingPipeline || startingPipeline + const isSubmitting = isCreating || isUpdating + const editMode = !!existingDestination + + const formId = 'destination-editor' + const types = ['BigQuery'] as const + const TypeEnum = z.enum(types) + const FormSchema = z.object({ + type: TypeEnum, + name: z.string().min(1, 'Name is required'), + projectId: z.string().min(1, 'Project id is required'), + datasetId: z.string().min(1, 'Dataset id is required'), + serviceAccountKey: z.string().min(1, 'Service account key is required'), + publicationName: z.string().min(1, 'Publication is required'), + maxSize: z.number().min(1, 'Max Size must be greater than 0').int(), + maxFillSecs: z.number().min(1, 'Max Fill seconds should be greater than 0').int(), + maxStalenessMins: z.number().nonnegative(), + enabled: z.boolean(), + }) + const defaultValues = useMemo( + () => ({ + type: TypeEnum.enum.BigQuery, + name: sinkData?.name ?? '', + projectId: sinkData?.config?.big_query?.project_id ?? '', + datasetId: sinkData?.config?.big_query?.dataset_id ?? '', + serviceAccountKey: sinkData?.config?.big_query?.service_account_key ?? '', + publicationName: pipelineData?.publication_name ?? '', + maxSize: pipelineData?.config?.config?.max_size ?? 1000, + maxFillSecs: pipelineData?.config?.config?.max_fill_secs ?? 10, + maxStalenessMins: sinkData?.config?.big_query?.max_staleness_mins ?? 5, + enabled: existingDestination?.enabled ?? true, + }), + [sinkData, pipelineData, existingDestination] + ) + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues, + }) + const onSubmit = async (data: z.infer) => { + if (!projectRef) return console.error('Project ref is required') + try { + if (editMode && existingDestination) { + if (!sourceId) { + console.error('Source id is required') + return + } + if (!existingDestination.pipelineId) { + console.error('Pipeline id is required') + return + } + // Update existing destination + await updateSink({ + projectRef, + sinkId: existingDestination.sinkId, + sinkName: data.name, + projectId: data.projectId, + datasetId: data.datasetId, + serviceAccountKey: data.serviceAccountKey, + maxStalenessMins: data.maxStalenessMins, + }) + + if (existingDestination.pipelineId) { + await updatePipeline({ + projectRef, + pipelineId: existingDestination.pipelineId, + sourceId, + sinkId: existingDestination.sinkId, + publicationName: data.publicationName, + config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } }, + }) + } + if (data.enabled) { + await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) + } else { + await stopPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) + } + + toast.success('Successfully updated destination') + } else { + // Create new destination + if (!sourceId) { + console.error('Source id is required') + return + } + const { id: sinkId } = await createSink({ + projectRef, + sinkName: data.name, + projectId: data.projectId, + datasetId: data.datasetId, + serviceAccountKey: data.serviceAccountKey, + maxStalenessMins: data.maxStalenessMins, + }) + const { id: pipelineId } = await createPipeline({ + projectRef, + sourceId, + sinkId, + publicationName: data.publicationName, + config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } }, + }) + if (data.enabled) { + await startPipeline({ projectRef, pipelineId }) + } + toast.success('Successfully created destination') + } + onClose() + } catch (error) { + toast.error(`Failed to ${editMode ? 'update' : 'create'} destination`) + } + } + const onEnableReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + await createSource({ projectRef }) + } + + const { enabled } = form.watch() + + useEffect(() => { + if (editMode && sinkData && pipelineData) { + form.reset(defaultValues) + } + }, [sinkData, pipelineData, editMode, defaultValues, form]) + + return ( + <> + {sourceId ? ( + <> + + +
+ +
+ {editMode ? 'Edit Destination' : 'New Destination'} + + {editMode ? null : 'Send data to a new destination'} + +
+
+ { + form.setValue('enabled', checked) + }} + /> + +
+
+ + +
+ ( + + + + + + )} + /> +

What data to send

+ + ( + + + pub.name) || []} + loading={loadingPublications} + field={field} + onNewPublicationClick={() => setPublicationPanelVisible(true)} + /> + + + )} + /> +

Where to send that data

+ + ( + + + + {field.value} + + + + BigQuery + + + + + + + )} + > + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + Advanced Settings + + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + + +
+ ( + + + + + + )} + /> +
+ +
+
+ + + + +
+
+
+ setPublicationPanelVisible(false)} + /> + + ) : ( + <> + + +
+ + New Destination + + + + + + {/* Pricing to be decided yet */} + Enabling replication will cost additional $xx.xx + + + +
+ +
+
+
+
+ + + +
+
+
+ + )} + + ) +} + +export default DestinationPanel diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx new file mode 100644 index 00000000000..465a35cba47 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -0,0 +1,206 @@ +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { ReplicationPipelinesData } from 'data/replication/pipelines-query' +import { ResponseError } from 'types' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import RowMenu from './RowMenu' +import PipelineStatus from './PipelineStatus' +import { useParams } from 'common' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' +import { useState } from 'react' +import { toast } from 'sonner' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { useDeleteSinkMutation } from 'data/replication/delete-sink-mutation' +import { useDeletePipelineMutation } from 'data/replication/delete-pipeline-mutation' +import DeleteDestination from './DeleteDestination' +import DestinationPanel from './DestinationPanel' + +export type Pipeline = ReplicationPipelinesData['pipelines'][0] + +interface DestinationRowProps { + sourceId: number | undefined + sinkId: number + sinkName: string + type: string + pipeline: Pipeline | undefined + error: ResponseError | null + isLoading: boolean + isError: boolean + isSuccess: boolean +} + +const DestinationRow = ({ + sourceId, + sinkId, + sinkName, + type, + pipeline, + error: pipelineError, + isLoading: isPipelineLoading, + isError: isPipelineError, + isSuccess: isPipelineSuccess, +}: DestinationRowProps) => { + const { ref: projectRef } = useParams() + const [refetchInterval, setRefetchInterval] = useState(false) + const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false) + const [showEditDestinationPanel, setShowEditDestinationPanel] = useState(false) + + const { + data: pipelineStatusData, + error: pipelineStatusError, + isLoading: isPipelineStatusLoading, + isError: isPipelineStatusError, + isSuccess: isPipelineStatusSuccess, + } = useReplicationPipelineStatusQuery( + { + projectRef, + pipelineId: pipeline?.id, + }, + { refetchInterval } + ) + const [requestStatus, setRequestStatus] = useState< + 'None' | 'EnableRequested' | 'DisableRequested' + >('None') + const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline } = useStopPipelineMutation() + const pipelineStatus = pipelineStatusData?.status + if ( + (requestStatus === 'EnableRequested' && pipelineStatus === 'Started') || + (requestStatus === 'DisableRequested' && pipelineStatus === 'Stopped') + ) { + setRefetchInterval(false) + setRequestStatus('None') + } + + const onEnableClick = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error('No pipeline found') + return + } + + try { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + } catch (error) { + toast.error('Failed to enable destination') + } + setRequestStatus('EnableRequested') + setRefetchInterval(5000) + } + const onDisableClick = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error('No pipeline found') + return + } + + try { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + } catch (error) { + toast.error('Failed to disable destination') + } + setRequestStatus('DisableRequested') + setRefetchInterval(5000) + } + const { mutateAsync: deleteSink } = useDeleteSinkMutation({}) + const { mutateAsync: deletePipeline } = useDeletePipelineMutation({ + onSuccess: (_res: any) => { + toast.success('Successfully deleted destination') + }, + }) + + const onDeleteClick = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error('No pipeline found') + return + } + + try { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + await deletePipeline({ projectRef, pipelineId: pipeline.id }) + await deleteSink({ projectRef, sinkId }) + } catch (error) { + toast.error('Failed to delete destination') + } + } + + return ( + <> + {isPipelineError && ( + + )} + {isPipelineSuccess && ( + + + {isPipelineLoading ? : sinkName} + + {isPipelineLoading ? : type} + + {isPipelineLoading || !pipeline ? ( + + ) : ( + + )} + + + {isPipelineLoading || !pipeline ? ( + + ) : ( + pipeline.publication_name + )} + + + setShowDeleteDestinationForm(true)} + onEditClick={() => setShowEditDestinationPanel(true)} + > + + + )} + + setShowEditDestinationPanel(false)} + sourceId={sourceId} + existingDestination={{ + sourceId, + sinkId, + pipelineId: pipeline?.id, + enabled: pipelineStatusData?.status === 'Started', + }} + /> + + ) +} + +export default DestinationRow diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx new file mode 100644 index 00000000000..a48f109fa11 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -0,0 +1,138 @@ +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useReplicationSinksQuery } from 'data/replication/sinks-query' +import { Plus } from 'lucide-react' +import { Button, cn } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import DestinationRow from './DestinationRow' +import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import { useState } from 'react' +import NewDestinationPanel from './DestinationPanel' +import { useReplicationSourcesQuery } from 'data/replication/sources-query' +import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' + +const Destinations = () => { + const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false) + const { ref: projectRef } = useParams() + + const { + data: sourcesData, + error: sourcesError, + isLoading: isSourcesLoading, + isError: isSourcesError, + isSuccess: isSourcesSuccess, + } = useReplicationSourcesQuery({ + projectRef, + }) + + let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + + const { + data: sinksData, + error: sinksError, + isLoading: isSinksLoading, + isError: isSinksError, + isSuccess: isSinksSuccess, + } = useReplicationSinksQuery({ + projectRef, + }) + + const { + data: pipelinesData, + error: pipelinesError, + isLoading: isPipelinesLoading, + isError: isPipelinesError, + isSuccess: isPipelinesSuccess, + } = useReplicationPipelinesQuery({ + projectRef, + }) + + const anySinks = isSinksSuccess && sinksData.sinks.length > 0 + + return ( + <> + +
+ Destinations + +
+ {(isSourcesLoading || isSinksLoading) && } + + {(isSourcesError || isSinksError) && ( + + )} + + {anySinks ? ( + Name, + Type, + Status, + Publication, + , + ]} + body={sinksData.sinks.map((sink) => { + const pipeline = pipelinesData?.pipelines.find((p) => p.sink_id === sink.id) + return ( + + ) + })} + >
+ ) : ( + !isSourcesLoading && + !isSinksLoading && + !isSourcesError && + !isSinksError && ( +
+

Send data to your first destination

+

+ Use destinations to improve performance or run analysis on your data via + integrations like BigQuery +

+ +
+ ) + )} +
+ + setShowNewDestinationPanel(false)} + > + + ) +} + +export default Destinations diff --git a/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx new file mode 100644 index 00000000000..2d06e06b4c0 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx @@ -0,0 +1,179 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useParams } from 'common' +import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation' +import { useReplicationTablesQuery } from 'data/replication/tables-query' +import { X } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetClose, + cn, + Button, + SheetFooter, + SheetSection, + Form_Shadcn_, + FormLabel_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormControl_Shadcn_, + Input_Shadcn_, + FormMessage_Shadcn_, + Card, + CardContent, + SheetDescription, +} from 'ui' +import { MultiSelector } from 'ui-patterns/multi-select' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' + +interface NewPublicationPanelProps { + visible: boolean + sourceId?: number + onClose: () => void +} + +const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => { + const { ref: projectRef } = useParams() + const { mutateAsync: createPublication, isLoading: creatingPublication } = + useCreatePublicationMutation() + const { data: tables } = useReplicationTablesQuery({ + projectRef, + sourceId, + }) + const formId = 'publication-editor' + const FormSchema = z.object({ + name: z.string().min(1, 'Name is required'), + tables: z.array(z.string()).min(1, 'At least one table is required'), + }) + const defaultValues = { + name: '', + tables: [], + } + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues, + }) + + const onSubmit = async (data: z.infer) => { + if (!projectRef) return console.error('Project ref is required') + if (!sourceId) return console.error('Source id is required') + try { + await createPublication({ + projectRef, + sourceId, + name: data.name, + tables: data.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }), + }) + toast.success('Successfully created publication') + onClose() + } catch (error) { + toast.error('Failed to create publication') + } + form.reset(defaultValues) + } + + return ( + <> + + +
+ +
+
+ New Publication + + Create a new publication to share table changes for replication + +
+ + + Close + +
+
+ + +
+ ( + + + + + + )} + /> + ( + + + + + + + + + {tables?.tables.map((table) => ( + + {`${table.schema}.${table.name}`} + + ))} + + + + + + )} + /> + +
+
+ + + + +
+
+
+ + ) +} + +export default NewPublicationPanel diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx new file mode 100644 index 00000000000..2569c32cd14 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -0,0 +1,57 @@ +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { cn } from 'ui' +import { ResponseError } from 'types' +import { Loader2 } from 'lucide-react' + +interface PipelineStatusProps { + pipelineStatus: string | undefined + error: ResponseError | null + isLoading: boolean + isError: boolean + isSuccess: boolean + requestStatus: 'None' | 'EnableRequested' | 'DisableRequested' +} + +const PipelineStatus = ({ + pipelineStatus, + error, + isLoading, + isError, + isSuccess, + requestStatus, +}: PipelineStatusProps) => { + const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true + const requestInFlight = requestStatus !== 'None' + const status = + requestStatus === 'EnableRequested' + ? 'Enabling' + : requestStatus === 'DisableRequested' + ? 'Disabling' + : pipelineStatus === 'Stopped' + ? 'Disabled' + : 'Enabled' + return ( + <> + {isLoading && } + {isError && } + {isSuccess && ( +
+ {requestInFlight ? ( + + ) : ( +
+ )} + {status} +
+ )} + + ) +} + +export default PipelineStatus diff --git a/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx b/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx new file mode 100644 index 00000000000..7ed6599795b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx @@ -0,0 +1,132 @@ +import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react' +import { useState } from 'react' +import { + Button, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + CommandSeparator_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, +} from 'ui' +import { ControllerRenderProps } from 'react-hook-form' + +interface PublicationsComboBoxProps { + publications: string[] + loading: boolean + onNewPublicationClick: () => void + field: ControllerRenderProps +} + +const PublicationsComboBox = ({ + publications, + loading, + onNewPublicationClick, + field, +}: PublicationsComboBoxProps) => { + const [dropdownOpen, setDropdownOpen] = useState(false) + const [selectedPublication, setSelectedPublication] = useState(field?.value || '') + const [searchTerm, setSearchTerm] = useState('') + + function handleSearchChange(value: string) { + setSearchTerm(value) + } + + function handlePublicationSelect(pub: string) { + setSelectedPublication(pub) + setDropdownOpen(false) + field.onChange(pub) + } + + return ( + { + setDropdownOpen(open) + if (!open && field?.onBlur) { + field.onBlur() + } + }} + > + + + + + + + + + {loading ? ( +
+ + Loading... +
+ ) : ( + 'No publications found' + )} +
+ + 7 ? 'h-[210px]' : ''}> + {publications.map((pub) => ( + { + handlePublicationSelect(pub) + }} + onClick={() => { + handlePublicationSelect(pub) + }} + > + {pub} + {selectedPublication === pub && ( + + )} + + ))} + + + + + + +

New publication

+
+
+
+
+
+
+ ) +} + +export default PublicationsComboBox diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx new file mode 100644 index 00000000000..2852aea3577 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -0,0 +1,72 @@ +import AlertError from 'components/ui/AlertError' +import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react' +import { ResponseError } from 'types' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +interface RowMenuProps { + pipelineStatus: string | undefined + error: ResponseError | null + isLoading: boolean + isError: boolean + onEnableClick: () => void + onDisableClick: () => void + onEditClick: () => void + onDeleteClick: () => void +} + +const RowMenu = ({ + pipelineStatus, + error, + isLoading, + isError, + onEnableClick, + onDisableClick, + onEditClick, + onDeleteClick, +}: RowMenuProps) => { + const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true + return ( +
+ {isLoading && } + {isError && } + + + +
+ ) +} + +export default RowMenu diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index 82594b17652..232a284f306 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -9,6 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { generateDatabaseMenu } from './DatabaseMenu.utils' +import { useFlag } from 'hooks/ui/useFlag' export interface DatabaseLayoutProps { title?: string @@ -29,6 +30,7 @@ const DatabaseProductMenu = () => { const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined const pitrEnabled = addons?.selected_addons.find((addon) => addon.type === 'pitr') !== undefined const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() + const enablePgReplicate = useFlag('enablePgReplicate') return ( <> @@ -38,6 +40,7 @@ const DatabaseProductMenu = () => { pgNetExtensionExists, pitrEnabled, columnLevelPrivileges, + enablePgReplicate, })} /> diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index de282e97098..c440c327ed6 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -9,10 +9,12 @@ export const generateDatabaseMenu = ( pgNetExtensionExists: boolean pitrEnabled: boolean columnLevelPrivileges: boolean + enablePgReplicate: boolean } ): ProductMenuGroup[] => { const ref = project?.ref ?? 'default' - const { pgNetExtensionExists, pitrEnabled, columnLevelPrivileges } = flags || {} + const { pgNetExtensionExists, pitrEnabled, columnLevelPrivileges, enablePgReplicate } = + flags || {} return [ { @@ -62,6 +64,16 @@ export const generateDatabaseMenu = ( url: `/project/${ref}/database/publications`, items: [], }, + ...(enablePgReplicate + ? [ + { + name: 'Replication', + key: 'replication', + url: `/project/${ref}/database/replication`, + items: [], + }, + ] + : []), ], }, { diff --git a/apps/studio/data/replication/create-pipeline-mutation.ts b/apps/studio/data/replication/create-pipeline-mutation.ts new file mode 100644 index 00000000000..98cf620977f --- /dev/null +++ b/apps/studio/data/replication/create-pipeline-mutation.ts @@ -0,0 +1,77 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type CreatePipelineParams = { + projectRef: string + sourceId: number + sinkId: number + publicationName: string + config: { config: { maxSize: number; maxFillSecs: number } } +} + +async function createPipeline( + { + projectRef, + sourceId, + sinkId, + publicationName, + config: { + config: { maxSize, maxFillSecs }, + }, + }: CreatePipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/pipelines', { + params: { path: { ref: projectRef } }, + body: { + source_id: sourceId, + sink_id: sinkId, + publication_name: publicationName, + config: { config: { max_size: maxSize, max_fill_secs: maxFillSecs } }, + }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type CreatePipelineData = Awaited> + +export const useCreatePipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createPipeline(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create pipeline: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/create-publication-mutation.ts b/apps/studio/data/replication/create-publication-mutation.ts new file mode 100644 index 00000000000..8b721eb6862 --- /dev/null +++ b/apps/studio/data/replication/create-publication-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type CreatePublicationParams = { + projectRef: string + sourceId: number + name: string + tables: { schema: string; name: string }[] +} + +async function createPublication( + { projectRef, sourceId, name, tables }: CreatePublicationParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post( + '/platform/replication/{ref}/sources/{source_id}/publications', + { + params: { path: { ref: projectRef, source_id: sourceId } }, + body: { name, tables }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +type CreatePublicationData = Awaited> + +export const useCreatePublicationMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createPublication(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, sourceId } = variables + await queryClient.invalidateQueries(replicationKeys.publications(projectRef, sourceId)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create publication: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/create-sink-mutation.ts b/apps/studio/data/replication/create-sink-mutation.ts new file mode 100644 index 00000000000..d71aac67304 --- /dev/null +++ b/apps/studio/data/replication/create-sink-mutation.ts @@ -0,0 +1,75 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type CreateSinkParams = { + projectRef: string + sinkName: string + projectId: string + datasetId: string + serviceAccountKey: string + maxStalenessMins: number +} + +async function createSink( + { + projectRef, + sinkName, + projectId, + datasetId, + serviceAccountKey, + maxStalenessMins, + }: CreateSinkParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/sinks', { + params: { path: { ref: projectRef } }, + body: { + project_id: projectId, + dataset_id: datasetId, + service_account_key: serviceAccountKey, + sink_name: sinkName, + max_staleness_mins: maxStalenessMins, + }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type CreateSinkData = Awaited> + +export const useCreateSinkMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation((vars) => createSink(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(replicationKeys.sinks(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create sink: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/replication/create-source-mutation.ts b/apps/studio/data/replication/create-source-mutation.ts new file mode 100644 index 00000000000..c68438bcf29 --- /dev/null +++ b/apps/studio/data/replication/create-source-mutation.ts @@ -0,0 +1,56 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type CreateSourceParams = { + projectRef: string +} + +async function createSource({ projectRef }: CreateSourceParams, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/sources', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type CreateSourceData = Awaited> + +export const useCreateSourceMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createSource(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(replicationKeys.sources(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create source: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/delete-pipeline-mutation.ts b/apps/studio/data/replication/delete-pipeline-mutation.ts new file mode 100644 index 00000000000..75833d37757 --- /dev/null +++ b/apps/studio/data/replication/delete-pipeline-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, del } from 'data/fetchers' + +export type DeletePipelineParams = { + projectRef: string + pipelineId: number +} + +async function deletePipeline( + { projectRef, pipelineId }: DeletePipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/platform/replication/{ref}/pipelines/{pipeline_id}', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type DeletePipelineData = Awaited> + +export const useDeletePipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deletePipeline(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, pipelineId } = variables + await queryClient.removeQueries(replicationKeys.pipelines(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete pipeline: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/delete-publication-mutation.ts b/apps/studio/data/replication/delete-publication-mutation.ts new file mode 100644 index 00000000000..5fac136e151 --- /dev/null +++ b/apps/studio/data/replication/delete-publication-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, del } from 'data/fetchers' + +export type DeletePublicationParams = { + projectRef: string + sourceId: number + publicationName: string +} + +async function deletePublication( + { projectRef, sourceId, publicationName: publication_name }: DeletePublicationParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await del( + '/platform/replication/{ref}/sources/{source_id}/publications/{publication_name}', + { + params: { path: { ref: projectRef, source_id: sourceId, publication_name } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +type DeletePublicationData = Awaited> + +export const useDeletePublicationMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deletePublication(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, sourceId } = variables + await queryClient.invalidateQueries(replicationKeys.publications(projectRef, sourceId)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete publication: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/delete-sink-mutation.ts b/apps/studio/data/replication/delete-sink-mutation.ts new file mode 100644 index 00000000000..101d56255dd --- /dev/null +++ b/apps/studio/data/replication/delete-sink-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, del } from 'data/fetchers' + +export type DeleteSinkParams = { + projectRef: string + sinkId: number +} + +async function deleteSink({ projectRef, sinkId }: DeleteSinkParams, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/platform/replication/{ref}/sinks/{sink_id}', { + params: { path: { ref: projectRef, sink_id: sinkId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type DeleteSinkData = Awaited> + +export const useDeleteSinkMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation((vars) => deleteSink(vars), { + async onSuccess(data, variables, context) { + const { projectRef, sinkId } = variables + await queryClient.invalidateQueries(replicationKeys.sinks(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete sink: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts new file mode 100644 index 00000000000..1dcdd61c0f6 --- /dev/null +++ b/apps/studio/data/replication/keys.ts @@ -0,0 +1,15 @@ +export const replicationKeys = { + sources: (projectRef: string | undefined) => ['projects', projectRef, 'sources'] as const, + sinks: (projectRef: string | undefined) => ['projects', projectRef, 'sinks'] as const, + sinkById: (projectRef: string | undefined, sinkId: number | undefined) => + ['projects', projectRef, 'sinks', sinkId] as const, + publications: (projectRef: string | undefined, source_id: number | undefined) => + ['projects', projectRef, 'sources', source_id, 'publications'] as const, + tables: (projectRef: string | undefined, source_id: number | undefined) => + ['projects', projectRef, 'sources', source_id, 'tables'] as const, + pipelines: (projectRef: string | undefined) => ['projects', projectRef, 'pipelines'] as const, + pipelineById: (projectRef: string | undefined, pipelineId: number | undefined) => + ['projects', projectRef, 'pipelines', pipelineId] as const, + pipelinesStatus: (projectRef: string | undefined, pipelineId: number | undefined) => + ['projects', projectRef, 'pipelines', pipelineId, 'status'] as const, +} diff --git a/apps/studio/data/replication/pipeline-by-id-query.ts b/apps/studio/data/replication/pipeline-by-id-query.ts new file mode 100644 index 00000000000..6482dc49c82 --- /dev/null +++ b/apps/studio/data/replication/pipeline-by-id-query.ts @@ -0,0 +1,42 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPipelineByIdParams = { projectRef?: string; pipelineId?: number } + +async function fetchReplicationPipelineById( + { projectRef, pipelineId }: ReplicationPipelineByIdParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!pipelineId) throw new Error('pipelineId is required') + const { data, error } = await get('/platform/replication/{ref}/pipelines/{pipeline_id}', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationPipelineByIdData = Awaited> + +export const useReplicationPipelineByIdQuery = ( + { projectRef, pipelineId }: ReplicationPipelineByIdParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.pipelineById(projectRef, pipelineId), + ({ signal }) => fetchReplicationPipelineById({ projectRef, pipelineId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/replication/pipeline-status-query.ts b/apps/studio/data/replication/pipeline-status-query.ts new file mode 100644 index 00000000000..66aaa6633e2 --- /dev/null +++ b/apps/studio/data/replication/pipeline-status-query.ts @@ -0,0 +1,45 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPipelinesStatusParams = { projectRef?: string; pipelineId?: number } + +async function fetchReplicationPipelineStatus( + { projectRef, pipelineId }: ReplicationPipelinesStatusParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!pipelineId) throw new Error('pipelineId is required') + + const { data, error } = await get('/platform/replication/{ref}/pipelines/{pipeline_id}/status', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationPipelineStatusData = Awaited< + ReturnType +> + +export const useReplicationPipelineStatusQuery = ( + { projectRef, pipelineId }: ReplicationPipelinesStatusParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.pipelinesStatus(projectRef, pipelineId), + ({ signal }) => fetchReplicationPipelineStatus({ projectRef, pipelineId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/replication/pipelines-query.ts b/apps/studio/data/replication/pipelines-query.ts new file mode 100644 index 00000000000..6e09e1cc409 --- /dev/null +++ b/apps/studio/data/replication/pipelines-query.ts @@ -0,0 +1,39 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPipelinesParams = { projectRef?: string } + +async function fetchReplicationPipelines( + { projectRef }: ReplicationPipelinesParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/replication/{ref}/pipelines', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationPipelinesData = Awaited> + +export const useReplicationPipelinesQuery = ( + { projectRef }: ReplicationPipelinesParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.pipelines(projectRef), + ({ signal }) => fetchReplicationPipelines({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/data/replication/publications-query.ts b/apps/studio/data/replication/publications-query.ts new file mode 100644 index 00000000000..da56da7d824 --- /dev/null +++ b/apps/studio/data/replication/publications-query.ts @@ -0,0 +1,47 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPublicationsParams = { projectRef?: string; sourceId?: number } + +async function fetchReplicationPublications( + { projectRef, sourceId }: ReplicationPublicationsParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + if (!sourceId) throw new Error('sourceId is required') + + const { data, error } = await get( + '/platform/replication/{ref}/sources/{source_id}/publications', + { + params: { path: { ref: projectRef, source_id: sourceId } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data.publications.filter((pub) => pub.name !== 'supabase_realtime') +} + +export type ReplicationPublicationsData = Awaited> + +export const useReplicationPublicationsQuery = ( + { projectRef, sourceId }: ReplicationPublicationsParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.publications(projectRef, sourceId), + ({ signal }) => fetchReplicationPublications({ projectRef, sourceId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof sourceId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/replication/sink-by-id-query.ts b/apps/studio/data/replication/sink-by-id-query.ts new file mode 100644 index 00000000000..6ad00e2d831 --- /dev/null +++ b/apps/studio/data/replication/sink-by-id-query.ts @@ -0,0 +1,42 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationSinkByIdParams = { projectRef?: string; sinkId?: number } + +async function fetchReplicationSinkById( + { projectRef, sinkId }: ReplicationSinkByIdParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!sinkId) throw new Error('sinkId is required') + const { data, error } = await get('/platform/replication/{ref}/sinks/{sink_id}', { + params: { path: { ref: projectRef, sink_id: sinkId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationSinkByIdData = Awaited> + +export const useReplicationSinkByIdQuery = ( + { projectRef, sinkId }: ReplicationSinkByIdParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.sinkById(projectRef, sinkId), + ({ signal }) => fetchReplicationSinkById({ projectRef, sinkId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof sinkId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/replication/sinks-query.ts b/apps/studio/data/replication/sinks-query.ts new file mode 100644 index 00000000000..de08ebb4f1f --- /dev/null +++ b/apps/studio/data/replication/sinks-query.ts @@ -0,0 +1,33 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationSinksParams = { projectRef?: string } + +async function fetchReplicationSinks({ projectRef }: ReplicationSinksParams, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/replication/{ref}/sinks', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationSinksData = Awaited> + +export const useReplicationSinksQuery = ( + { projectRef }: ReplicationSinksParams, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.sinks(projectRef), + ({ signal }) => fetchReplicationSinks({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/data/replication/sources-query.ts b/apps/studio/data/replication/sources-query.ts new file mode 100644 index 00000000000..3009b432fa7 --- /dev/null +++ b/apps/studio/data/replication/sources-query.ts @@ -0,0 +1,36 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationSourcesParams = { projectRef?: string } + +async function fetchReplicationSources( + { projectRef }: ReplicationSourcesParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/replication/{ref}/sources', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationSourcesData = Awaited> + +export const useReplicationSourcesQuery = ( + { projectRef }: ReplicationSourcesParams, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.sources(projectRef), + ({ signal }) => fetchReplicationSources({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/data/replication/start-pipeline-mutation.ts b/apps/studio/data/replication/start-pipeline-mutation.ts new file mode 100644 index 00000000000..7bcf93917c9 --- /dev/null +++ b/apps/studio/data/replication/start-pipeline-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type StartPipelineParams = { + projectRef: string + pipelineId: number +} + +async function startPipeline( + { projectRef, pipelineId }: StartPipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}/start', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type StartPipelineData = Awaited> + +export const useStartPipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => startPipeline(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, pipelineId } = variables + await queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to start pipeline: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/stop-pipeline-mutation.ts b/apps/studio/data/replication/stop-pipeline-mutation.ts new file mode 100644 index 00000000000..ab4c63bcbec --- /dev/null +++ b/apps/studio/data/replication/stop-pipeline-mutation.ts @@ -0,0 +1,57 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type StopPipelineParams = { + projectRef: string + pipelineId: number +} + +async function stopPipeline({ projectRef, pipelineId }: StopPipelineParams, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}/stop', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type StartPipelineData = Awaited> + +export const useStopPipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => stopPipeline(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, pipelineId } = variables + await queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to stop pipeline: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/tables-query.ts b/apps/studio/data/replication/tables-query.ts new file mode 100644 index 00000000000..42a7256f1d9 --- /dev/null +++ b/apps/studio/data/replication/tables-query.ts @@ -0,0 +1,40 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationTablesParams = { projectRef?: string; sourceId?: number } + +async function fetchReplicationTables( + { projectRef, sourceId }: ReplicationTablesParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!sourceId) throw new Error('sourceId is required') + + const { data, error } = await get('/platform/replication/{ref}/sources/{source_id}/tables', { + params: { path: { ref: projectRef, source_id: sourceId } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationTablesData = Awaited> + +export const useReplicationTablesQuery = ( + { projectRef, sourceId }: ReplicationTablesParams, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.tables(projectRef, sourceId), + ({ signal }) => fetchReplicationTables({ projectRef, sourceId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof sourceId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/replication/update-pipeline-mutation.ts b/apps/studio/data/replication/update-pipeline-mutation.ts new file mode 100644 index 00000000000..63b287a635c --- /dev/null +++ b/apps/studio/data/replication/update-pipeline-mutation.ts @@ -0,0 +1,79 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type UpdatePipelineParams = { + pipelineId: number + projectRef: string + sourceId: number + sinkId: number + publicationName: string + config: { config: { maxSize: number; maxFillSecs: number } } +} + +async function updatePipeline( + { + pipelineId, + projectRef, + sourceId, + sinkId, + publicationName, + config: { + config: { maxSize, maxFillSecs }, + }, + }: UpdatePipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}', { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + body: { + source_id: sourceId, + sink_id: sinkId, + publication_name: publicationName, + config: { config: { max_size: maxSize, max_fill_secs: maxFillSecs } }, + }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type UpdatePipelineData = Awaited> + +export const useUpdatePipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updatePipeline(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update pipeline: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/replication/update-sink-mutation.ts b/apps/studio/data/replication/update-sink-mutation.ts new file mode 100644 index 00000000000..5f9029aef1e --- /dev/null +++ b/apps/studio/data/replication/update-sink-mutation.ts @@ -0,0 +1,77 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { ResponseError } from 'types' +import { replicationKeys } from './keys' +import { handleError, post } from 'data/fetchers' + +export type UpdateSinkParams = { + sinkId: number + projectRef: string + sinkName: string + projectId: string + datasetId: string + serviceAccountKey: string + maxStalenessMins: number +} + +async function updateSink( + { + sinkId, + projectRef, + sinkName, + projectId, + datasetId, + serviceAccountKey, + maxStalenessMins, + }: UpdateSinkParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/replication/{ref}/sinks/{sink_id}', { + params: { path: { ref: projectRef, sink_id: sinkId } }, + body: { + project_id: projectId, + dataset_id: datasetId, + service_account_key: serviceAccountKey, + sink_name: sinkName, + max_staleness_mins: maxStalenessMins, + }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +type UpdateSinkData = Awaited> + +export const useUpdateSinkMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation((vars) => updateSink(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(replicationKeys.sinks(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update sink: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index adc90c7eb26..94104ae469c 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -222,11 +222,6 @@ const nextConfig = { destination: '/project/:ref/database/tables', permanent: true, }, - { - source: '/project/:ref/database/replication', - destination: '/project/:ref/database/publications', - permanent: true, - }, { source: '/project/:ref/database/graphiql', destination: '/project/:ref/api/graphiql', diff --git a/apps/studio/pages/project/[ref]/database/replication.tsx b/apps/studio/pages/project/[ref]/database/replication.tsx new file mode 100644 index 00000000000..bedb7735d5a --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/replication.tsx @@ -0,0 +1,42 @@ +import type { NextPageWithLayout } from 'types' +import Destinations from 'components/interfaces/Database/Replication/Destinations' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { useFlag } from 'hooks/ui/useFlag' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { Admonition } from 'ui-patterns' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' + +const DatabaseReplicationPage: NextPageWithLayout = () => { + const enablePgReplicate = useFlag('enablePgReplicate') + + return ( + <> + {enablePgReplicate ? ( + + + + ) : ( + + + +

Replication is not yet available for your project

+
+
+
+ )} + + ) +} + +DatabaseReplicationPage.getLayout = (page) => ( + + + + {page} + + + +) + +export default DatabaseReplicationPage From 48d4194c3f172f867ec64c2a98b1b8240ad3b349 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 23 Apr 2025 10:24:07 +0200 Subject: [PATCH 34/35] chore: Bump nextjs to v15.3 for studio (#35030) * Bump nextjs to v15+ in studio. * Fix imports in css. Fix including markdown files in turbo. * Add imports to make turbo work in dev. * Fix the lockfile. --------- Co-authored-by: Joshen Lim --- apps/studio/next-env.d.ts | 2 +- apps/studio/next.config.js | 9 + apps/studio/package.json | 9 +- apps/studio/styles/main.scss | 9 +- pnpm-lock.yaml | 535 +++++++++++++++++++++++++++++++---- 5 files changed, 495 insertions(+), 69 deletions(-) diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts index a4a7b3f5cfa..52e831b4342 100644 --- a/apps/studio/next-env.d.ts +++ b/apps/studio/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 94104ae469c..7a6aeb0bbaf 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -565,6 +565,15 @@ const nextConfig = { 'icons', 'libpg-query', ], + turbopack: { + rules: { + '*.md': { + loaders: ['raw-loader'], + as: '*.js', + }, + }, + }, + // Both configs for turbopack and webpack need to exist (and sync) because Nextjs still uses webpack for production building webpack(config) { config.module?.rules .find((rule) => rule.oneOf) diff --git a/apps/studio/package.json b/apps/studio/package.json index 5986b14ca65..8e51d3c8ec7 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "preinstall": "npx only-allow pnpm", - "dev": "next dev -p 8082", + "dev": "next dev --turbopack -p 8082", "dev:secrets:pull": "AWS_PROFILE=supabase-dev node ../../scripts/getSecrets.js -n local/studio", "build": "next build && ./../../scripts/upload-static-assets.sh", "start": "next start", @@ -37,7 +37,7 @@ "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.1.1", "@monaco-editor/react": "^4.6.0", - "@next/bundle-analyzer": "^14.2.3", + "@next/bundle-analyzer": "15.3.0", "@number-flow/react": "^0.3.2", "@sentry/nextjs": "^8.52.1", "@std/path": "npm:@jsr/std__path@^1.0.8", @@ -83,7 +83,7 @@ "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", "monaco-editor": "0.52.2", - "next": "catalog:", + "next": "~15.3.0", "next-themes": "^0.3.0", "nuqs": "^2.4.1", "openai": "^4.20.1", @@ -171,10 +171,13 @@ "eslint-config-supabase": "workspace:*", "eslint-plugin-barrel-files": "^2.0.7", "graphql-ws": "5.14.1", + "import-in-the-middle": "^1.13.1", "msw": "^2.3.0", "next-router-mock": "^0.9.13", "postcss": "^8.4.31", "prettier": "3.2.4", + "raw-loader": "^4.0.2", + "require-in-the-middle": "^7.5.2", "tailwindcss": "^3.4.1", "tsx": "^4.19.3", "typescript": "~5.5.0", diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index 96adf4fb959..a62e79474f9 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -2,10 +2,11 @@ @tailwind components; @tailwind utilities; -@import '~ui/build/css/source/global.css'; -@import '~ui/build/css/themes/dark.css'; -@import '~ui/build/css/themes/light.css'; -@import '~ui/build/css/themes/classic-dark.css'; +@import './../../../packages/ui/build/css/source/global.css'; +@import './../../../packages/ui/build/css/source/global.css'; +@import './../../../packages/ui/build/css/themes/dark.css'; +@import './../../../packages/ui/build/css/themes/light.css'; +@import './../../../packages/ui/build/css/themes/classic-dark.css'; :root { --chart-1: hsl(var(--brand-default)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13294f6b304..d2afa5c9f27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,14 +587,14 @@ importers: specifier: ^4.6.0 version: 4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@next/bundle-analyzer': - specifier: ^14.2.3 - version: 14.2.3 + specifier: 15.3.0 + version: 15.3.0 '@number-flow/react': specifier: ^0.3.2 version: 0.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^8.52.1 - version: 8.52.1(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 8.52.1(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -633,7 +633,7 @@ importers: version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vercel/flags': specifier: ^2.6.0 - version: 2.6.0(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.6.0(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(supports-color@8.1.1)(vite@6.2.6(@types/node@20.12.11)(jiti@2.4.2)(sass@1.72.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5)) @@ -725,14 +725,14 @@ importers: specifier: 0.52.2 version: 0.52.2 next: - specifier: 'catalog:' - version: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + specifier: ~15.3.0 + version: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.4.1 - version: 2.4.1(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.4.1(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.20.1 version: 4.71.1(encoding@0.1.13)(zod@3.23.8) @@ -977,18 +977,27 @@ importers: graphql-ws: specifier: 5.14.1 version: 5.14.1(graphql@16.8.1) + import-in-the-middle: + specifier: ^1.13.1 + version: 1.13.1 msw: specifier: ^2.3.0 version: 2.4.11(typescript@5.5.2) next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1) + version: 0.9.13(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1) postcss: specifier: ^8.4.31 version: 8.4.38 prettier: specifier: 3.2.4 version: 3.2.4 + raw-loader: + specifier: ^4.0.2 + version: 4.0.2(webpack@5.94.0) + require-in-the-middle: + specifier: ^7.5.2 + version: 7.5.2(supports-color@8.1.1) tailwindcss: specifier: ^3.4.1 version: 3.4.1(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2)) @@ -1555,7 +1564,7 @@ importers: version: 0.7.12 '@vercel/flags': specifier: ^2.6.0 - version: 2.6.0(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.6.0(next@14.2.26(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) api-types: specifier: workspace:* version: link:../api-types @@ -2114,7 +2123,7 @@ importers: version: link:../api-types next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1) + version: 0.9.13(next@14.2.26(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1) typescript: specifier: ~5.5.0 version: 5.5.2 @@ -2753,6 +2762,9 @@ packages: '@emnapi/runtime@0.43.1': resolution: {integrity: sha512-Q5sMc4Z4gsD4tlmlyFu+MpNAwpR7Gv2errDhVJ+SOhNjWcx8UTqy+hswb8L31RfC8jBvDgcnT87l3xI2w08rAg==} + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + '@emotion/is-prop-valid@0.8.8': resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} @@ -3225,6 +3237,116 @@ packages: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} deprecated: Use @eslint/object-schema instead + '@img/sharp-darwin-arm64@0.34.1': + resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.1': + resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.1': + resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.1': + resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.34.1': + resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.1': + resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.1': + resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.1': + resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.1': + resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.34.1': + resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.1': + resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/confirm@3.2.0': resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} engines: {node: '>=18'} @@ -3483,9 +3605,15 @@ packages: '@next/bundle-analyzer@14.2.3': resolution: {integrity: sha512-Z88hbbngMs7njZKI8kTJIlpdLKYfMSLwnsqYe54AP4aLmgL70/Ynx/J201DQ+q2Lr6FxFw1uCeLGImDrHOl2ZA==} + '@next/bundle-analyzer@15.3.0': + resolution: {integrity: sha512-t1dn32bTBJTfksh2xLfpaw6hNNNrw1rsr1GlVSkRbJ5626YgEMFBjuos5tHr6l9/b+jKz1HFYFlWNgrIHqCslw==} + '@next/env@14.2.26': resolution: {integrity: sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==} + '@next/env@15.3.1': + resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==} + '@next/eslint-plugin-next@14.2.4': resolution: {integrity: sha512-svSFxW9f3xDaZA3idQmlFw7SusOuWTpDTAeBlO3AEPDltrraV+lqs7mAc6A27YdnpQVVIA3sODqUAAHdWhVWsA==} @@ -3506,42 +3634,84 @@ packages: cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@15.3.1': + resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@14.2.26': resolution: {integrity: sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@15.3.1': + resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@14.2.26': resolution: {integrity: sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-gnu@15.3.1': + resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@14.2.26': resolution: {integrity: sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@15.3.1': + resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-x64-gnu@14.2.26': resolution: {integrity: sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-gnu@15.3.1': + resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@14.2.26': resolution: {integrity: sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@15.3.1': + resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-win32-arm64-msvc@14.2.26': resolution: {integrity: sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@15.3.1': + resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-ia32-msvc@14.2.26': resolution: {integrity: sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==} engines: {node: '>= 10'} @@ -3554,6 +3724,12 @@ packages: cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@15.3.1': + resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5885,6 +6061,9 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} @@ -6804,10 +6983,6 @@ packages: peerDependencies: acorn: '>=8.9.0' - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} - acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -6817,11 +6992,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.14.1: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} @@ -9447,8 +9617,8 @@ packages: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} - import-in-the-middle@1.12.0: - resolution: {integrity: sha512-yAgSE7GmtRcu4ZUSFX/4v69UGXwugFFSdIQJ14LHPOPPQrWv8Y7O9PHsw8Ovk7bKCLe4sjXMbZFqGFcLHpZ89w==} + import-in-the-middle@1.13.1: + resolution: {integrity: sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==} import-meta-resolve@4.1.0: resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} @@ -10122,6 +10292,10 @@ packages: resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==} engines: {node: '>=4.0.0'} + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + local-pkg@1.1.1: resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} engines: {node: '>=14'} @@ -11042,6 +11216,27 @@ packages: sass: optional: true + next@15.3.1: + resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -11990,6 +12185,12 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-loader@4.0.2: + resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -12535,8 +12736,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - require-in-the-middle@7.5.0: - resolution: {integrity: sha512-/Tvpny/RVVicqlYTKwt/GtpZRsPG1CmJNhxVKGz+Sy/4MONfXCVNK69MFgGKdUt0/324q3ClI2dICcPgISrC8g==} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} requireindex@1.2.0: @@ -12798,6 +12999,10 @@ packages: shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + sharp@0.34.1: + resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -13215,6 +13420,19 @@ packages: babel-plugin-macros: optional: true + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + stylis@4.3.1: resolution: {integrity: sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==} @@ -15588,6 +15806,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/is-prop-valid@0.8.8': dependencies: '@emotion/memoize': 0.7.4 @@ -15975,6 +16198,84 @@ snapshots: '@humanwhocodes/object-schema@2.0.2': {} + '@img/sharp-darwin-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + + '@img/sharp-linux-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + + '@img/sharp-linux-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + + '@img/sharp-wasm32@0.34.1': + dependencies: + '@emnapi/runtime': 1.4.3 + optional: true + + '@img/sharp-win32-ia32@0.34.1': + optional: true + + '@img/sharp-win32-x64@0.34.1': + optional: true + '@inquirer/confirm@3.2.0': dependencies: '@inquirer/core': 9.2.1 @@ -16383,8 +16684,17 @@ snapshots: - bufferutil - utf-8-validate + '@next/bundle-analyzer@15.3.0': + dependencies: + webpack-bundle-analyzer: 4.10.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@next/env@14.2.26': {} + '@next/env@15.3.1': {} + '@next/eslint-plugin-next@14.2.4': dependencies: glob: 10.3.10 @@ -16406,30 +16716,54 @@ snapshots: '@next/swc-darwin-arm64@14.2.26': optional: true + '@next/swc-darwin-arm64@15.3.1': + optional: true + '@next/swc-darwin-x64@14.2.26': optional: true + '@next/swc-darwin-x64@15.3.1': + optional: true + '@next/swc-linux-arm64-gnu@14.2.26': optional: true + '@next/swc-linux-arm64-gnu@15.3.1': + optional: true + '@next/swc-linux-arm64-musl@14.2.26': optional: true + '@next/swc-linux-arm64-musl@15.3.1': + optional: true + '@next/swc-linux-x64-gnu@14.2.26': optional: true + '@next/swc-linux-x64-gnu@15.3.1': + optional: true + '@next/swc-linux-x64-musl@14.2.26': optional: true + '@next/swc-linux-x64-musl@15.3.1': + optional: true + '@next/swc-win32-arm64-msvc@14.2.26': optional: true + '@next/swc-win32-arm64-msvc@15.3.1': + optional: true + '@next/swc-win32-ia32-msvc@14.2.26': optional: true '@next/swc-win32-x64-msvc@14.2.26': optional: true + '@next/swc-win32-x64-msvc@15.3.1': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -16897,8 +17231,8 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.53.0 '@types/shimmer': 1.2.0 - import-in-the-middle: 1.12.0 - require-in-the-middle: 7.5.0(supports-color@8.1.1) + import-in-the-middle: 1.13.1 + require-in-the-middle: 7.5.2(supports-color@8.1.1) semver: 7.7.1 shimmer: 1.2.1 transitivePeerDependencies: @@ -16909,8 +17243,8 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.56.0 '@types/shimmer': 1.2.0 - import-in-the-middle: 1.12.0 - require-in-the-middle: 7.5.0(supports-color@8.1.1) + import-in-the-middle: 1.13.1 + require-in-the-middle: 7.5.2(supports-color@8.1.1) semver: 7.7.1 shimmer: 1.2.1 transitivePeerDependencies: @@ -18674,7 +19008,7 @@ snapshots: '@sentry/core@8.52.1': {} - '@sentry/nextjs@8.52.1(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': + '@sentry/nextjs@8.52.1(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.28.0 @@ -18687,7 +19021,7 @@ snapshots: '@sentry/vercel-edge': 8.52.1 '@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0) chalk: 3.0.0 - next: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.10 @@ -18736,7 +19070,7 @@ snapshots: '@prisma/instrumentation': 5.22.0(supports-color@8.1.1) '@sentry/core': 8.52.1 '@sentry/opentelemetry': 8.52.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.28.0) - import-in-the-middle: 1.12.0 + import-in-the-middle: 1.13.1 transitivePeerDependencies: - supports-color @@ -19212,6 +19546,10 @@ snapshots: '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@swc/helpers@0.5.5': dependencies: '@swc/counter': 0.1.3 @@ -20411,7 +20749,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vercel/flags@2.6.0(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@vercel/flags@2.6.0(next@14.2.26(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: jose: 5.2.1 optionalDependencies: @@ -20419,6 +20757,14 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@vercel/flags@2.6.0(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + jose: 5.2.1 + optionalDependencies: + next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@vercel/nft@0.29.2(encoding@0.1.13)(rollup@4.38.0)(supports-color@8.1.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13)(supports-color@8.1.1) @@ -20745,10 +21091,6 @@ snapshots: acorn-walk: 8.3.4 optional: true - acorn-import-attributes@1.9.5(acorn@8.14.0): - dependencies: - acorn: 8.14.0 - acorn-import-attributes@1.9.5(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -20757,10 +21099,6 @@ snapshots: dependencies: acorn: 8.11.3 - acorn-jsx@5.3.2(acorn@8.14.0): - dependencies: - acorn: 8.14.0 - acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -20769,17 +21107,12 @@ snapshots: dependencies: acorn: 8.11.3 - acorn-walk@8.3.2: {} - acorn-walk@8.3.4: dependencies: acorn: 8.14.1 - optional: true acorn@8.11.3: {} - acorn@8.14.0: {} - acorn@8.14.1: {} agent-base@6.0.2(supports-color@8.1.1): @@ -23897,10 +24230,10 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@1.12.0: + import-in-the-middle@1.13.1: dependencies: - acorn: 8.14.0 - acorn-import-attributes: 1.9.5(acorn@8.14.0) + acorn: 8.14.1 + acorn-import-attributes: 1.9.5(acorn@8.14.1) cjs-module-lexer: 1.4.1 module-details-from-path: 1.0.3 @@ -24560,6 +24893,12 @@ snapshots: emojis-list: 3.0.0 json5: 1.0.2 + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + local-pkg@1.1.1: dependencies: mlly: 1.7.4 @@ -25436,8 +25775,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) micromark-extension-mdx-expression: 3.0.0 micromark-extension-mdx-jsx: 3.0.0 micromark-extension-mdx-md: 2.0.0 @@ -25845,7 +26184,7 @@ snapshots: mlly@1.7.4: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.5.4 @@ -26051,11 +26390,16 @@ snapshots: dependencies: js-yaml-loader: 1.2.2 - next-router-mock@0.9.13(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1): + next-router-mock@0.9.13(next@14.2.26(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1): dependencies: next: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) react: 18.3.1 + next-router-mock@0.9.13(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react@18.3.1): + dependencies: + next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + react: 18.3.1 + next-seo@6.5.0(next@14.2.26(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: next: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) @@ -26097,6 +26441,34 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0): + dependencies: + '@next/env': 15.3.1 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001695 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.26.10(supports-color@8.1.1))(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.3.1 + '@next/swc-darwin-x64': 15.3.1 + '@next/swc-linux-arm64-gnu': 15.3.1 + '@next/swc-linux-arm64-musl': 15.3.1 + '@next/swc-linux-x64-gnu': 15.3.1 + '@next/swc-linux-x64-musl': 15.3.1 + '@next/swc-win32-arm64-msvc': 15.3.1 + '@next/swc-win32-x64-msvc': 15.3.1 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.49.1 + sass: 1.72.0 + sharp: 0.34.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nice-try@1.0.5: {} nitropack@2.11.7(@electric-sql/pglite@0.2.15)(encoding@0.1.13)(supports-color@8.1.1)(typescript@5.5.2): @@ -26381,12 +26753,12 @@ snapshots: mitt: 3.0.1 next: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) - nuqs@2.4.1(next@14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.4.1(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0))(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: mitt: 3.0.1 react: 18.3.1 optionalDependencies: - next: 14.2.26(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) react-router: 7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nwsapi@2.2.20: @@ -27217,6 +27589,12 @@ snapshots: range-parser@1.2.1: {} + raw-loader@4.0.2(webpack@5.94.0): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.94.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -27996,7 +28374,7 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@7.5.0(supports-color@8.1.1): + require-in-the-middle@7.5.2(supports-color@8.1.1): dependencies: debug: 4.4.0(supports-color@8.1.1) module-details-from-path: 1.0.3 @@ -28323,6 +28701,34 @@ snapshots: shallowequal@1.1.0: {} + sharp@0.34.1: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.1 + '@img/sharp-darwin-x64': 0.34.1 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.1 + '@img/sharp-linux-arm64': 0.34.1 + '@img/sharp-linux-s390x': 0.34.1 + '@img/sharp-linux-x64': 0.34.1 + '@img/sharp-linuxmusl-arm64': 0.34.1 + '@img/sharp-linuxmusl-x64': 0.34.1 + '@img/sharp-wasm32': 0.34.1 + '@img/sharp-win32-ia32': 0.34.1 + '@img/sharp-win32-x64': 0.34.1 + optional: true + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -28787,6 +29193,13 @@ snapshots: optionalDependencies: '@babel/core': 7.26.10(supports-color@8.1.1) + styled-jsx@5.1.6(@babel/core@7.26.10(supports-color@8.1.1))(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + optionalDependencies: + '@babel/core': 7.26.10(supports-color@8.1.1) + stylis@4.3.1: {} sucrase@3.34.0: @@ -29003,7 +29416,7 @@ snapshots: terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.14.1 commander: 2.20.3 source-map-support: 0.5.21 @@ -29360,7 +29773,7 @@ snapshots: unctx@2.4.1: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 estree-walker: 3.0.3 magic-string: 0.30.17 unplugin: 2.2.2 @@ -29416,7 +29829,7 @@ snapshots: unimport@4.1.2: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 local-pkg: 1.1.1 @@ -29551,14 +29964,14 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 unplugin@1.16.1: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 webpack-virtual-modules: 0.6.2 unplugin@2.2.2: @@ -30131,8 +30544,8 @@ snapshots: webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.11.3 - acorn-walk: 8.3.2 + acorn: 8.14.1 + acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 escape-string-regexp: 4.0.0 From 2ac7d651414d8d45b7159aee3477c6360b29c192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Wed, 23 Apr 2025 19:16:41 +0800 Subject: [PATCH 35/35] fix: dont display confirmation modal on free plan project creation (#35221) --- apps/studio/pages/new/[slug].tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index b12e1324ce2..620ebe00026 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -417,8 +417,9 @@ const Wizard: NextPageWithLayout = () => { const availableComputeCredits = organizationProjects.length === 0 ? 10 : 0 - const additionalMonthlySpend = - instanceSizeSpecs[instanceSize as DesiredInstanceSize]!.priceMonthly - availableComputeCredits + const additionalMonthlySpend = isFreePlan + ? 0 + : instanceSizeSpecs[instanceSize as DesiredInstanceSize]!.priceMonthly - availableComputeCredits return (