We have provided you with a Service Key to get started. Soon you will be able to add as
many keys as you like. You can find the service_role in the{' '}
- API Settings{' '}
- page.
+ API Settings page.
From 5bfad6bf1c6249cb3b5dcfa6baca955bc9b4e1c0 Mon Sep 17 00:00:00 2001
From: Alaister Young
- - Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your - organization and used in subsequent billing cycles. - -
- ) - : !subscription?.billing_via_partner && ( -- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedCustomDomain?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -
- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( -+ There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +
)} {isFreePlan && ( diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index ab14efb9640..252cabdfd02 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -187,72 +187,30 @@ const IPv4SidePanel = () => { {hasChanges && ( <> - {selectedOption === 'ipv4_none' || - (selectedIPv4?.price ?? 0) < (subscriptionIpV4Option?.variant.price ?? 0) ? ( - subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -- Upon clicking confirm, the add-on is removed immediately and any unused time in - the current billing cycle is added as prorated credits to your organization and - used in subsequent billing cycles. -
- ) - ) : ( - <> -- By default, this is only applied to the Primary database for your project. If{' '} - - Read replicas - {' '} - are used, each replica also gets its own IPv4 address, with a corresponding{' '} - {formatCurrency(selectedIPv4?.price)}{' '} - charge. -
- {!subscription?.billing_via_partner && ( -- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the respective amount will be added to your monthly - invoice. The addon is prepaid per month and in case of a downgrade, you - get credits for the remaining time. For the current billing cycle you're - immediately charged a prorated amount for the remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -
- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( -+ By default, this is only applied to the Primary database for your project. If{' '} + + Read replicas + {' '} + are used, each replica also gets its own IPv4 address, with a corresponding{' '} + {formatCurrency(selectedIPv4?.price)}{' '} + charge. +
+ )} + {!subscription?.billing_via_partner && ( ++ There are no immediate charges. The addon is billed at the end of your billing + cycle based on your usage and prorated to the hour. +
)} > )} diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 8169b1ec33f..62b21a77f8f 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -346,58 +346,11 @@ const PITRSidePanel = () => {- Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your organization - and used in subsequent billing cycles. -
- ) - : !subscription?.billing_via_partner && ( -- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedPitr?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -
- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( -+ There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +
)} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index a1e5ab6d240..7a6bcf26f45 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -98,10 +98,7 @@ const DeployNewReplicaPanel = ({ // @ts-ignore const { size_gb, type, throughput_mbps, iops } = diskConfiguration?.attributes ?? {} - const showNewDiskManagementUI = - diskManagementV2 && - subscription?.usage_based_billing_project_addons && - project?.cloud_provider === 'AWS' + const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' const readReplicaDiskSizes = (size_gb ?? 0) * 1.25 const additionalCostDiskSize = readReplicaDiskSizes * DISK_PRICING[type as DiskType]?.storage ?? 0 const additionalCostIOPS = calculateIOPSPrice({ diff --git a/apps/studio/components/ui/Charts/AreaChart.tsx b/apps/studio/components/ui/Charts/AreaChart.tsx index 933999a0cf1..c5820d5b7e9 100644 --- a/apps/studio/components/ui/Charts/AreaChart.tsx +++ b/apps/studio/components/ui/Charts/AreaChart.tsx @@ -48,7 +48,7 @@ const AreaChart = ({ if (data.length === 0) { return (+Request Metadata
+{ -Response Metadata
-++ Response Metadata +
+)}-Request Metadata
-+Request Metadata
+-) } - -export default CLSPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a77d22b887d..84aa5f6c7fe 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -3,6 +3,33 @@ import { PropsWithChildren, createContext, useContext, useEffect, useState } fro import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' +import { APISidePanelPreview } from './APISidePanelPreview' +import { CLSPreview } from './CLSPreview' +import { FunctionsAssistantPreview } from './FunctionsAssistantPreview' + +export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT, + name: 'Database Functions Assistant', + content:Response Metadata
-++ Response Metadata +
+{ return ( -diff --git a/apps/studio/components/interfaces/Docs/RpcContent.tsx b/apps/studio/components/interfaces/Docs/RpcContent.tsx index b7ba1558eb2..c749f6658cd 100644 --- a/apps/studio/components/interfaces/Docs/RpcContent.tsx +++ b/apps/studio/components/interfaces/Docs/RpcContent.tsx @@ -1,38 +1,53 @@ +import { useParams } from 'common' import CodeSnippet from 'components/interfaces/Docs/CodeSnippet' import Description from 'components/interfaces/Docs/Description' import Param from 'components/interfaces/Docs/Param' import Snippets from 'components/interfaces/Docs/Snippets' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { ProjectJsonSchemaPaths } from 'data/docs/project-json-schema-query' /** * TODO: need to support rpc with the same name and different params type */ + +interface RpcContentProps { + rpcId: string + rpcs: { [key: string]: any } + paths?: ProjectJsonSchemaPaths + selectedLang: 'bash' | 'js' + showApiKey: string + refreshDocs: () => void +} + const RpcContent = ({ - autoApiService, rpcId, rpcs, paths, selectedLang, - refreshDocs, showApiKey, -}: any) => { + refreshDocs, +}: RpcContentProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + const meta = rpcs[rpcId] const pathKey = `/rpc/${rpcId}` const path = paths && pathKey in paths ? paths[pathKey] : undefined const keyToShow = !!showApiKey ? showApiKey : 'SUPABASE_KEY' - if (!path) return null - - const { - post: { parameters, summary }, - } = path + const { post } = path! + const { parameters, summary } = post || {} const rpcParamsObject = - parameters && parameters[0] && parameters[0].schema && parameters[0].schema.properties - ? parameters[0].schema.properties + parameters && parameters[0] && parameters[0].schema && (parameters[0].schema as any).properties + ? (parameters[0].schema as any).properties : {} const rpcParams = Object.entries(rpcParamsObject) .map(([k, v]: any) => ({ name: k, ...v })) .filter((x) => !!x.name) + if (!path) return null + return ( <>+@@ -300,7 +299,7 @@ export default function UserManagement({{label} {valueRender ?? value}From b319ea91a45c86ac186e87604547c277d962adf3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 21 Oct 2024 21:14:46 +0900 Subject: [PATCH 11/80] docs: Update auth self-host docs to the latest info (#29994) * docs: Update auth self-host docs to the latest info * update description of URI_ALLOW_LIST * update the variable name * Add more description about how to configure auth on self-hosted environment * minor styling fix --- .../self-hosting/analytics/config/page.tsx | 2 +- .../self-hosting/auth/config/page.tsx | 2 +- .../self-hosting/realtime/config/page.tsx | 2 +- .../self-hosting/storage/config/page.tsx | 2 +- .../content/guides/self-hosting/docker.mdx | 2 +- apps/docs/spec/gotrue_v1_config.yaml | 60 +++++++++++-------- 6 files changed, 41 insertions(+), 29 deletions(-) diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx index d397c0003f0..03a88cc171f 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx @@ -21,7 +21,7 @@ const AnalyticsConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx index d8a33d04694..9d6ff028b51 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx @@ -21,7 +21,7 @@ const AuthConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx index 01180ba859c..d28ca1077a7 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx @@ -21,7 +21,7 @@ const RealtimeConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx index 766bc4d190a..1201543f943 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx @@ -21,7 +21,7 @@ const StorageConfigPage = async () => { diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index d45e1886573..4139801d85b 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -265,7 +265,7 @@ Each system has a number of configuration options which can be found in the rele - [Postgres](https://hub.docker.com/_/postgres/) - [PostgREST](https://postgrest.org/en/stable/configuration.html) - [Realtime](https://github.com/supabase/realtime#server) -- [GoTrue](https://github.com/supabase/gotrue) +- [Auth](https://github.com/supabase/auth) - [Storage](https://github.com/supabase/storage-api) - [Kong](https://docs.konghq.com/gateway/latest/install/docker/) - [Supavisor](https://supabase.github.io/supavisor/development/docs/) diff --git a/apps/docs/spec/gotrue_v1_config.yaml b/apps/docs/spec/gotrue_v1_config.yaml index 31827802154..ff4a418c9ff 100644 --- a/apps/docs/spec/gotrue_v1_config.yaml +++ b/apps/docs/spec/gotrue_v1_config.yaml @@ -11,9 +11,11 @@ info: title: 'GoTrue' # {string} A readable name. source: 'https://github.com/supabase/gotrue' # {string} Where developers can find the source code. bugs: 'https://github.com/supabase/gotrue/issues' # {string} Where developers can file bugs. - spec: 'https://github.com/supabase/supabase/blob/master/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). + spec: 'https://github.com/supabase/supabase/blob/master/docs/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). description: | - A `config.toml` file is generated after running `supabase init`. This file is located in the `supabase` folder under `supabase/config.toml`. + In a self-hosted environment, you do not have access to the Auth configuration such as third party OAuth provider settings through the Supabase dashboard. Instead, you configure them through the `docker-compose.yml` file. You can read more about the configuration in the [Self-hosting guide](/docs/guides/self-hosting/docker#configuring-services). + + You can find the complete list of available configuration parameters on the README of the [Auth repository](https://github.com/supabase/auth?tab=readme-ov-file#configuration). tags: - id: general title: General @@ -21,58 +23,68 @@ info: # This section is an array of public functions which a user might need to execute. parameters: - - id: 'project_id' # {string} A unique identifier for this param. - title: 'project_id' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - A string used to distinguish different Supabase projects on the same host. Defaults to the working directory name when running `supabase init`. - - id: 'GOTRUE_EXTERNAL_GITHUB' # {string} A unique identifier for this param. - title: 'auth.external.github' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - Describes whether the Github provider is enabled or not. - id: 'GOTRUE_SITE_URL' # {string} A unique identifier for this param. - title: 'auth.site_url' # {string} Any name. + title: 'GOTRUE_SITE_URL' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails. + - id: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + Whether the external provider, GitHub in this case, is enabled or not. + - id: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client ID registered with the external provider. + - id: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client Secret provided by the external provider when you registered. + - id: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The URI a OAuth2 provider will redirect to with the `code` and `state` values. - id: 'GOTRUE_URI_ALLOW_LIST' # {string} A unique identifier for this param. - title: 'auth.additional_redirect_urls' # {string} Any name. + title: 'GOTRUE_URI_ALLOW_LIST' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | - A list of *exact* URLs that auth providers are permitted to redirect to post authentication. + A comma separated list of URIs (e.g. `"https://foo.example.com,https://*.foo.example.com,https://bar.example.com"`) which are permitted as valid `redirect_to` destinations. - id: 'GOTRUE_JWT_EXP' # {string} A unique identifier for this param. - title: 'auth.jwt_expiry' # {string} Any name. + title: 'GOTRUE_JWT_EXP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week). - id: 'GOTRUE_DISABLE_SIGNUP' # {string} A unique identifier for this param. - title: 'auth.enable_signup' # {string} Any name. + title: 'GOTRUE_DISABLE_SIGNUP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups to your project. - id: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.enable_signup' # {string} Any name. + title: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups via email to your project. - id: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.double_confirm_changes' # {string} Any name. + title: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | If enabled, a user will be required to confirm any email change on both the old, and new email addresses. If disabled, only the new email is required to confirm. - id: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} A unique identifier for this param. - title: 'auth.email.enable_confirmations' # {string} Any name. + title: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | From 262c4393457893bbacd8bd6ce2b6789c7ff08749 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Mon, 21 Oct 2024 10:22:02 -0230 Subject: [PATCH 12/80] Pass search term to entity query (#29986) --- .../Database/Indexes/CreateIndexSidePanel.tsx | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index dd527130d09..9faecac311e 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronsUpDown } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -50,7 +50,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = const [selectedIndexType, setSelectedIndexType] = useState (INDEX_TYPES[0].value) const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false) const [tableDropdownOpen, setTableDropdownOpen] = useState(false) - + const [searchTerm, setSearchTerm] = useState('') const { refetch: refetchIndexes } = useIndexesQuery({ schema: selectedSchema, projectRef: project?.ref, @@ -60,10 +60,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data: entities, isLoading } = useEntityTypesQuery({ + const { data: entities, isLoading: isLoadingEntities } = useEntityTypesQuery({ schema: selectedSchema, sort: 'alphabetical', - search: undefined, + search: searchTerm, projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -94,6 +94,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = [entities?.pages] ) + function handleSearchChange(value: string) { + setSearchTerm(value) + } + const columns = tableColumns?.result[0]?.columns ?? [] const columnOptions: MultiSelectOption[] = columns .filter((column): column is NonNullable => column !== null) @@ -181,7 +185,11 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp sameWidthAsTrigger > - + No schemas found @@ -218,7 +226,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp name="select-table" description={ isSelectEntityDisabled && - !isLoading && + !isLoadingEntities && 'Create a table in this schema via the Table or SQL editor first' } isReactForm={false} @@ -228,28 +236,27 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp open={tableDropdownOpen} onOpenChange={setTableDropdownOpen} > - - {isLoading ? ( - - ) : ( - - )} + + - - + {/* [Terry] shouldFilter context: + https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */} + + - No tables found ++ {isLoadingEntities ? ( + ++ ) : ( + 'No tables found' + )} ++ Loading... + 7 ? 'h-[210px]' : ''}> {(entityTypes ?? []).map((entity) => ( From 2e159931e1fccd6f0ecbd050c461d6f7aa8a0c76 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 22 Oct 2024 00:21:24 +0200 Subject: [PATCH 13/80] fix crash in settings page (#30018) * fix * fix possible crash on authorize page * Fix formatting * fix type err --------- Co-authored-by: Terry Sutton --- .../DiskManagementPanelForm.tsx | 22 +++++++++++++------ apps/studio/pages/authorize.tsx | 10 ++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 98221dbad77..5e58cd95843 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -123,7 +123,7 @@ export function DiskManagementPanelForm() { const { data: addons } = useProjectAddonsQuery({ projectRef }) const currentCompute = (addons?.selected_addons ?? []).find( - (x) => x.type === 'compute_instance' + (x: { type: string }) => x.type === 'compute_instance' )?.variant const maxIopsBasedOnCompute = COMPUTE_SIZE_MAX_IOPS[(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_SIZE_MAX_IOPS] @@ -135,7 +135,7 @@ export function DiskManagementPanelForm() { const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug, }) - const planId = subscription?.plan.id ?? '' + const planId = subscription?.plan.id ?? 'free' const isPlanUpgradeRequired = subscription?.plan.id === 'pro' && !subscription.usage_billing_enabled @@ -156,7 +156,7 @@ export function DiskManagementPanelForm() { }) const defaultValues = { - storageType: type, + storageType: type || DiskType.GP3, provisionedIOPS: iops, throughput: throughput_mbps, totalSize: size_gb, @@ -180,8 +180,7 @@ export function DiskManagementPanelForm() { isWithinCooldownWindow || !canUpdateDiskConfiguration - const { includedDiskGB: includedDiskGBMeta } = - PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} + const { includedDiskGB: includedDiskGBMeta } = PLAN_DETAILS[planId] const includedDiskGB = includedDiskGBMeta[watchedStorageType] const minIOPS = IOPS_RANGE[watchedStorageType]?.min ?? 0 @@ -201,12 +200,21 @@ export function DiskManagementPanelForm() { updateDiskConfigurationRQ({ ref: projectRef, ...data }) } + // i hate typescript enums + // gotta clean this up but it allows me to remove type casts for now + function getEnumFromFormValue(value: string) { + if (value === 'io2') return DiskType.IO2 + if (value === 'gp3') return DiskType.GP3 + return DiskType.IO2 + } + + const newStorageType = form.getValues('storageType') const diskSizePrice = calculateDiskSizePrice({ planId, oldSize: form.formState.defaultValues?.totalSize || 0, - oldStorageType: form.formState.defaultValues?.storageType as DiskType, + oldStorageType: getEnumFromFormValue(form.formState.defaultValues?.storageType || 'io2'), newSize: form.getValues('totalSize'), - newStorageType: form.getValues('storageType') as DiskType, + newStorageType: getEnumFromFormValue(newStorageType), }) const iopsPrice = calculateIOPSPrice({ diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 2c23397a595..f79e8d7a2d3 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -44,7 +44,7 @@ const APIAuthorizationPage: NextPageWithLayout = () => { useEffect(() => { if (!isLoadingOrganizations) { - setSelectedOrgSlug(organizations?.[0].slug ?? undefined) + setSelectedOrgSlug(organizations?.[0]?.slug ?? undefined) } }, [isLoadingOrganizations]) @@ -113,7 +113,7 @@ const APIAuthorizationPage: NextPageWithLayout = () => { if (isApproved) { const approvedOrganization = organizations?.find( - (org) => org.slug === requester.approved_organization_slug + (org) => org?.slug === requester.approved_organization_slug ) return ( @@ -184,9 +184,9 @@ const APIAuthorizationPage: NextPageWithLayout = () => { > {(organizations ?? []).map((organization) => ( {organization.name} From e3e6f7dcf75fa90dae9c7c51d3e5c4943fd05f78 Mon Sep 17 00:00:00 2001 From: Mark BurggrafDate: Mon, 21 Oct 2024 17:22:43 -0700 Subject: [PATCH 14/80] =?UTF-8?q?added=20vercel-postgres=20migration=20gui?= =?UTF-8?q?de=20'Migrate=20from=20Vercel=20Postgres=20t=E2=80=A6=20(#29983?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added vercel-postgres migration guide 'Migrate from Vercel Postgres to Supabase' * fix: icons and build Update apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- apps/docs/app/page.tsx | 63 +----------- .../NavigationMenu.constants.ts | 65 +++++++++++-- .../guides/platform/migrating-to-supabase.mdx | 8 +- .../migrating-to-supabase/vercel-postgres.mdx | 97 +++++++++++++++++++ .../public/img/icons/vercel-icon-light.svg | 3 + apps/docs/public/img/icons/vercel-icon.svg | 3 + 6 files changed, 170 insertions(+), 69 deletions(-) create mode 100644 apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx create mode 100644 apps/docs/public/img/icons/vercel-icon-light.svg create mode 100644 apps/docs/public/img/icons/vercel-icon.svg diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index ad7a21f787b..b01885c6b6d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -4,6 +4,7 @@ import { IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' +import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker' import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker' import HomeLayout from '~/layouts/HomeLayout' @@ -70,60 +71,6 @@ const products = [ }, ] -const migrationGuides = [ - { - title: 'Auth0', - icon: '/docs/img/icons/auth0-icon', - href: '/guides/resources/migrating-to-supabase/auth0', - hasLightIcon: true, - }, - { - title: 'Firebase Auth', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-auth', - }, - { - title: 'Firestore Data', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firestore-data', - }, - { - title: 'Firebase Storage', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-storage', - }, - { - title: 'Heroku', - icon: '/docs/img/icons/heroku-icon', - href: '/guides/resources/migrating-to-supabase/heroku', - }, - { - title: 'Render', - icon: '/docs/img/icons/render-icon', - href: '/guides/resources/migrating-to-supabase/render', - }, - { - title: 'Amazon RDS', - icon: '/docs/img/icons/aws-rds-icon', - href: '/guides/resources/migrating-to-supabase/amazon-rds', - }, - { - title: 'Postgres', - icon: '/docs/img/icons/postgres-icon', - href: '/guides/resources/migrating-to-supabase/postgres', - }, - { - title: 'MySQL', - icon: '/docs/img/icons/mysql-icon', - href: '/guides/resources/migrating-to-supabase/mysql', - }, - { - title: 'MSSQL', - icon: '/docs/img/icons/mssql-icon', - href: '/guides/resources/migrating-to-supabase/mssql', - }, -] - const selfHostingOptions = [ { title: 'Auth', @@ -268,11 +215,11 @@ const HomePage = () => ( - {migrationGuides.map((guide) => { + {MIGRATION_PAGES.map((guide) => { return ( -
- - -
+ - + +
) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index ac355889d2d..6aa8637e1ff 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1,3 +1,7 @@ +import type { ComponentProps } from 'react' + +import type { IconPanel } from 'ui-patterns/IconPanel' + import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Navigation.types' export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ @@ -1809,23 +1813,64 @@ export const local_development: NavMenuConstant = { ], } -export const MIGRATION_PAGES: Partial[] = [ - { name: 'Amazon RDS', url: '/guides/platform/migrating-to-supabase/amazon-rds' }, - { name: 'Auth0', url: '/guides/platform/migrating-to-supabase/auth0' }, - { name: 'Firebase Auth', url: '/guides/platform/migrating-to-supabase/firebase-auth' }, +export const MIGRATION_PAGES: Partial >[] = [ { - name: 'Firebase Firestore', + name: 'Auth0', + icon: '/docs/img/icons/auth0-icon', + url: '/guides/platform/migrating-to-supabase/auth0', + hasLightIcon: true, + }, + { + name: 'Firebase Auth', + icon: '/docs/img/icons/firebase-icon', + url: '/guides/platform/migrating-to-supabase/firebase-auth', + }, + { + name: 'Firestore Data', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firestore-data', }, { name: 'Firebase Storage', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firebase-storage', }, - { name: 'Heroku Postgres', url: '/guides/platform/migrating-to-supabase/heroku' }, - { name: 'MySQL', url: '/guides/platform/migrating-to-supabase/mysql' }, - { name: 'MSSQL', url: '/guides/platform/migrating-to-supabase/mssql' }, - { name: 'Postgres', url: '/guides/platform/migrating-to-supabase/postgres' }, - { name: 'Render', url: '/guides/platform/migrating-to-supabase/render' }, + { + name: 'Heroku', + icon: '/docs/img/icons/heroku-icon', + url: '/guides/platform/migrating-to-supabase/heroku', + }, + { + name: 'Render', + icon: '/docs/img/icons/render-icon', + url: '/guides/platform/migrating-to-supabase/render', + }, + { + name: 'Amazon RDS', + icon: '/docs/img/icons/aws-rds-icon', + url: '/guides/platform/migrating-to-supabase/amazon-rds', + }, + { + name: 'Postgres', + icon: '/docs/img/icons/postgres-icon', + url: '/guides/platform/migrating-to-supabase/postgres', + }, + { + name: 'Vercel Postgres', + icon: '/docs/img/icons/vercel-icon', + url: '/guides/platform/migrating-to-supabase/vercel-postgres', + hasLightIcon: true, + }, + { + name: 'MySQL', + icon: '/docs/img/icons/mysql-icon', + url: '/guides/platform/migrating-to-supabase/mysql', + }, + { + name: 'MSSQL', + icon: '/docs/img/icons/mssql-icon', + url: '/guides/platform/migrating-to-supabase/mssql', + }, ] export const platform: NavMenuConstant = { diff --git a/apps/docs/content/guides/platform/migrating-to-supabase.mdx b/apps/docs/content/guides/platform/migrating-to-supabase.mdx index 494520af8af..f4715f672cf 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase.mdx @@ -11,7 +11,13 @@ Learn how to migrate to Supabase from another database service. {migrationPages.map((page) => ( -diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx new file mode 100644 index 00000000000..a94bfaf697f --- /dev/null +++ b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx @@ -0,0 +1,97 @@ +--- +title: 'Migrate from Vercel Postgres to Supabase' +description: 'Migrate your existing Vercel Postgres database to Supabase.' +subtitle: 'Migrate your existing Vercel Postgres database to Supabase.' +--- + +This guide demonstrates how to migrate your Vercel Postgres database to Supabase to get the most out of Postgres while gaining access to all the features you need to build a project. + +## Retrieve your Vercel Postgres database credentials [#retrieve-credentials] + +1. Log in to your Vercel Dashboard [https://vercel.com/login](https://vercel.com/login). +1. Click on the **Storage** tab. +1. Click on your Postgres Database. +1. Under the **Quickstart** section, select **psql** then click **Show Secret** to reveal your database password. +1. Copy the string after `psql ` to the clipboard. + +Example: + +```bash +psql "postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +Copy this part to your clipboard: + +```bash +"postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Set your OLD_DB_URL environment variable + +Set the **OLD_DB_URL** environment variable at the command line using your Vercel Postgres Database credentials. + +Example: + +```bash +export OLD_DB_URL="postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] + +1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set. +1. Click the **Copy** button to the right of your connection string to copy it to the clipboard. + +## Set your NEW_DB_URL environment variable + +Set the **NEW_DB_URL** environment variable at the command line using your Supabase connection string. You will need to replace `[YOUR-PASSWORD]` with your actual database password. + +Example: + +```bash +export NEW_DB_URL="postgresql://postgres.xxxxxxxxxxxxxxxxxxxx:[YOUR-PASSWORD]@aws-0-us-west-1.pooler.supabase.com:5432/postgres" +``` + +## Migrate the database + +You will need the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tools, which are included in a full [PostgreSQL installation](https://www.postgresql.org/download). + +1. Export your database to a file in console + + Use `pg_dump` with your Postgres credentials to export your database to a file (e.g., `dump.sql`). + +```bash +pg_dump "$OLD_DB_URL" \ + --clean \ + --if-exists \ + --quote-all-identifiers \ + --no-owner \ + --no-privileges \ + > dump.sql +``` + +2. Import the database to your Supabase project + + Use `psql` to import the Postgres database file to your Supabase project. + + ```bash + psql -d "$NEW_DB_URL" -f dump.sql + ``` + +Additional options + +- To only migrate a single database schema, add the `--schema=PATTERN` parameter to your `pg_dump` command. +- To exclude a schema: `--exclude-schema=PATTERN`. +- To only migrate a single table: `--table=PATTERN`. +- To exclude a table: `--exclude-table=PATTERN`. + +Run `pg_dump --help` for a full list of options. + ++ ))} + +## Enterprise + +[Contact us](https://forms.supabase.com/enterprise) if you need more help migrating your project. diff --git a/apps/docs/public/img/icons/vercel-icon-light.svg b/apps/docs/public/img/icons/vercel-icon-light.svg new file mode 100644 index 00000000000..72948d01a7d --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon-light.svg @@ -0,0 +1,3 @@ + diff --git a/apps/docs/public/img/icons/vercel-icon.svg b/apps/docs/public/img/icons/vercel-icon.svg new file mode 100644 index 00000000000..4e3b0e93358 --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon.svg @@ -0,0 +1,3 @@ + From 8271fa3c51c172c296e69f58db1ab112f2113344 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:03:10 +0300 Subject: [PATCH 15/80] Docs: add --no-restart flag to Postgres Config doc (#30019) Update custom-postgres-config.mdx --- apps/docs/content/guides/database/custom-postgres-config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index ba8093c1c4e..1fb50e31c2e 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -160,6 +160,6 @@ alter role " " set " " to default; ### Considerations -1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. +1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. However, you can use the --no-restart flag to bypass the restart and keep the connections intact. Keep in mind that this depends on the specific configuration changes you’re making. if the change requires a restart, using the --no-restart flag will prevent the restart but you won’t see those changes take effect until a restart is manually triggered. 1. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute. 1. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io). From 1dcc16ea41c6e764041ab63e4807e2c3b4f4f61e Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:03:36 +0200 Subject: [PATCH 16/80] chore: update cli reference doc (#29740) --- apps/docs/spec/cli_v1_commands.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index cda35983ec8..d207ef08c80 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: 1.200.3 + version: 1.207.8 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -1128,6 +1128,10 @@ commands: name: --config description: Config overrides specified as a 'key=value' pair default_value: '[]' + - id: no-restart + name: --no-restart + description: Do not restart the database after updating config. + default_value: 'false' - id: replace-existing-overrides name: --replace-existing-overrides description: | @@ -2878,7 +2882,7 @@ commands: Requires the local development stack to be started by running `supabase start`. - Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql` by default, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. + Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. Note that since Postgres roles are cluster level entities, those changes will persist between resets. In order to reset custom roles, you need to restart the local development stack. examples: @@ -2976,7 +2980,7 @@ commands: default_value: 'false' - id: include-seed name: --include-seed - description: Include seed data from supabase/seed.sql. + description: Include seed data from your config. default_value: 'false' - id: linked name: --linked From d586346e275b20da133f2439c883ddff73996b89 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Oct 2024 15:21:06 +0800 Subject: [PATCH 17/80] Chore/sentry fixes 211024 (#30003) * Resolve https://supabase.sentry.io/issues/5808689343 * Fix SecurityStatus map key prop issue * Fix FileExplorerHeader to use ButtonTooltip * Fix https://supabase.sentry.io/issues/5998687405 * Fix https://supabase.sentry.io/issues/5414129652 * Fix https://supabase.sentry.io/issues/5414129652 * Fix map key issue in QueryPerformanceGrid * Add whitelist error for sign up mutation * Fix map key in DiskUsage * Fix prop name in info-tooltip --- .../grid/components/editor/TextEditor.tsx | 8 +- .../interfaces/Auth/Users/Users.utils.tsx | 7 +- .../interfaces/Home/SecurityStatus.tsx | 11 +-- .../Usage/UsageSection/DiskUsage.tsx | 5 +- .../QueryPerformance/QueryPerformanceGrid.tsx | 1 + .../StorageExplorer/ColumnContextMenu.tsx | 16 ++-- .../StorageExplorer/FileExplorerHeader.tsx | 88 ++++++------------- .../retrieve-index-from-select-query.ts | 1 + apps/studio/data/misc/signup-mutation.ts | 1 + apps/studio/pages/authorize.tsx | 13 ++- packages/ui-patterns/info-tooltip.tsx | 4 +- 11 files changed, 68 insertions(+), 87 deletions(-) diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 7496f78c727..14c111adea1 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -15,6 +15,7 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, + cn, } from 'ui' import { useTrackedState } from '../../store/Store' import { BlockKeys } from '../common/BlockKeys' @@ -188,9 +189,10 @@ export const TextEditor = ({ } > setIsPopoverOpen(!isPopoverOpen)} > {value === null ?@@ -279,7 +278,7 @@ export default function UserManagement({: value === '' ? : value} diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index 54ace3d0262..4bb4eaf5024 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -210,9 +210,12 @@ export function getAvatarUrl(user: User): string | undefined { profile_url || profileImageUrl || profileImageURL || - profile_image_url) as string | undefined + profile_image_url || + '') as string | undefined - return SUPPORTED_CSP_AVATAR_URLS.some((x) => (url ?? '').startsWith(x)) ? url : undefined + if (url === undefined) return undefined + const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x)) + return isSupported ? url : undefined } export const formatUserColumns = ({ diff --git a/apps/studio/components/interfaces/Home/SecurityStatus.tsx b/apps/studio/components/interfaces/Home/SecurityStatus.tsx index eeba9e89523..be4dc9b4425 100644 --- a/apps/studio/components/interfaces/Home/SecurityStatus.tsx +++ b/apps/studio/components/interfaces/Home/SecurityStatus.tsx @@ -1,6 +1,6 @@ import { CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { Fragment, useState } from 'react' import { useParams } from 'common' import { useProjectLintsQuery } from 'data/lint/lint-query' @@ -81,12 +81,9 @@ export const SecurityStatus = () => { const { label, descriptionShort } = LINT_TABS.find((tab) => tab.id === level) ?? {} return ( lints.length > 0 && ( - <> + - +@@ -263,7 +262,7 @@ export default function UserManagement({@@ -247,7 +246,7 @@ export default function UserManagement({@@ -231,7 +230,7 @@ export default function UserManagement({@@ -109,7 +106,7 @@ export const SecurityStatus = () => { - > + ) ) })} diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index 7c449363541..c682d1e1e53 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -169,7 +169,10 @@ const DiskUsage = ({ const totalDiskUsage = primaryDiskUsage + replicaDiskUsage return ( - +@@ -218,7 +217,7 @@ export default function UserManagement({diff --git a/apps/studio/data/database/retrieve-index-from-select-query.ts b/apps/studio/data/database/retrieve-index-from-select-query.ts index 06d80dddebc..8ef31cde994 100644 --- a/apps/studio/data/database/retrieve-index-from-select-query.ts +++ b/apps/studio/data/database/retrieve-index-from-select-query.ts @@ -112,6 +112,7 @@ export const useGetIndexesFromSelectQuery ={project.name} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 8b69078bb51..06d0bd6eefe 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -154,6 +154,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance return (-{ if (typeof idx === 'number' && idx >= 0) { setSelectedRow(idx) diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx index 8adbf34019c..3cfb338bee8 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx @@ -60,15 +60,13 @@ const ColumnContextMenu = ({ id = '' }: ColumnContextMenuProps) => { return (
- -- } - type="text" - disabled={!canUpdateStorage || breadcrumbs.length === 0} - onClick={onSelectUpload} - > - Upload files - - - {!canUpdateStorage && ( -- - )} -- -- - - You need additional permissions to upload files - --- +- } - type="text" - disabled={!canUpdateStorage || breadcrumbs.length === 0} - onClick={() => addNewFolderPlaceholder(-1)} - > - Create folder - - - {!canUpdateStorage && ( -- - )} -- -- - - You need additional permissions to create folders - --} + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to upload files', + }, + }} + > + Upload files + + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={() => addNewFolderPlaceholder(-1)} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to create folders', + }, + }} + > + Create folder + { const { auth_id } = useParams() const [selectedOrgSlug, setSelectedOrgSlug] = useState () - const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery() + const { + data: organizations, + isSuccess: isSuccessOrganizations, + isLoading: isLoadingOrganizations, + } = useOrganizationsQuery() const { data: requester, isLoading, isError, error } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null const isExpired = dayjs().isAfter(dayjs(requester?.expires_at)) @@ -43,10 +47,11 @@ const APIAuthorizationPage: NextPageWithLayout = () => { const isSubmitting = isApproving || isDeclining useEffect(() => { - if (!isLoadingOrganizations) { - setSelectedOrgSlug(organizations?.[0]?.slug ?? undefined) + if (isSuccessOrganizations && organizations.length > 0) { + setSelectedOrgSlug(organizations[0].slug) } - }, [isLoadingOrganizations]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessOrganizations]) const onApproveRequest = async () => { if (!auth_id) { diff --git a/packages/ui-patterns/info-tooltip.tsx b/packages/ui-patterns/info-tooltip.tsx index ae2f2004fdf..e3de1744883 100644 --- a/packages/ui-patterns/info-tooltip.tsx +++ b/packages/ui-patterns/info-tooltip.tsx @@ -10,9 +10,9 @@ const SVG = forwardRef >((props, ref {...props} > )) From f9016e9128d04ae25cad465db348c63fa6222bba Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 22 Oct 2024 06:12:57 -0230 Subject: [PATCH 18/80] Chore/auth logs timestamps (#30013) * co/auth-logs-timestamps * Opt to use TimestampInfo instead * Revert test data --------- Co-authored-by: Joshen Lim --- apps/studio/components/interfaces/Auth/Users/UserLogs.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index 6e5636ca8a8..391f65f922d 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -9,7 +9,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { User } from 'data/auth/users-query' import useLogsPreview from 'hooks/analytics/useLogsPreview' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition, TimestampInfo } from 'ui-patterns' import { UserHeader } from './UserHeader' import { PANEL_PADDING } from './UserPanel' @@ -103,7 +103,6 @@ export const UserLogs = ({ user }: UserLogsProps) => { {authLogs.map((log) => { const status = ((log.status ?? '-') as any).toString() - const formattedTime = dayjs(log.timestamp).format('DD MMM HH:mm:ss') const is400 = status.startsWith('4') const is500 = status.startsWith('5') @@ -112,8 +111,8 @@ export const UserLogs = ({ user }: UserLogsProps) => { key={log.id} className="flex items-center transition font-mono px-2 py-1.5 bg-surface-100 divide-x" > -@@ -151,7 +150,7 @@ export default function UserManagement({- {formattedTime} +
+
@@ -131,7 +130,7 @@ export default function UserManagement({Date: Tue, 22 Oct 2024 09:39:36 -0230 Subject: [PATCH 19/80] new Integrations page (#29671) * Start new integrations page * Move cronjobs into integrations * Rearrange * Remove duplicate * Move around menu items --- .../Wrappers/WrappersDisabledState.tsx | 2 +- .../CronJobs/CreateCronJobSheet.tsx | 3 +- .../CronJobs/CronJobCard.tsx | 0 .../CronJobs/CronJobScheduleSection.tsx | 0 .../CronJobs/CronJobs.constants.tsx | 0 .../CronJobs/CronJobs.utils.test.ts | 0 .../CronJobs/CronJobs.utils.ts | 0 .../CronJobs/CronJobsDisabledState.tsx | 3 +- .../CronJobs/CronJobsListing.tsx | 3 +- .../CronJobs/DeleteCronJob.tsx | 0 .../CronJobs/EdgeFunctionSection.tsx | 0 .../CronJobs/HttpHeaderFieldsSection.tsx | 0 .../CronJobs/HttpParameterFieldsSection.tsx | 0 .../CronJobs/HttpRequestSection.tsx | 0 .../CronJobs/SqlFunctionSection.tsx | 0 .../CronJobs/SqlSnippetSection.tsx | 0 .../Settings/Vault/VaultNavTabs.tsx | 6 +- .../DatabaseLayout/DatabaseMenu.utils.tsx | 16 +-- .../layouts/DocsLayout/DocsLayout.utils.tsx | 5 +- .../ProjectIntegrations.Commands.tsx | 107 ++++++++++++++++++ .../ProjectIntegrationsLayout.tsx | 59 ++++++++++ .../ProjectIntegrationsMenu.utils.tsx | 66 +++++++++++ .../NavigationBar/NavigationBar.utils.tsx | 8 +- ...gsMenu.utils.ts => SettingsMenu.utils.tsx} | 4 +- apps/studio/next.config.js | 30 +++++ .../{database => integrations}/cron-jobs.tsx | 8 +- .../[ref]/{api => integrations}/graphiql.tsx | 6 +- .../{database => integrations}/hooks.tsx | 5 +- .../{settings => integrations}/vault/keys.tsx | 5 +- .../vault/secrets.tsx | 7 +- .../wrappers/[id].tsx | 6 +- .../wrappers/index.tsx | 5 +- .../wrappers/new.tsx | 0 33 files changed, 320 insertions(+), 34 deletions(-) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CreateCronJobSheet.tsx (99%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobCard.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobScheduleSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.constants.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.utils.test.ts (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.utils.ts (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobsDisabledState.tsx (97%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobsListing.tsx (98%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/DeleteCronJob.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/EdgeFunctionSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpHeaderFieldsSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpParameterFieldsSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpRequestSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/SqlFunctionSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/SqlSnippetSection.tsx (100%) create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx rename apps/studio/components/layouts/ProjectSettingsLayout/{SettingsMenu.utils.ts => SettingsMenu.utils.tsx} (96%) rename apps/studio/pages/project/[ref]/{database => integrations}/cron-jobs.tsx (71%) rename apps/studio/pages/project/[ref]/{api => integrations}/graphiql.tsx (94%) rename apps/studio/pages/project/[ref]/{database => integrations}/hooks.tsx (95%) rename apps/studio/pages/project/[ref]/{settings => integrations}/vault/keys.tsx (89%) rename apps/studio/pages/project/[ref]/{settings => integrations}/vault/secrets.tsx (89%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/[id].tsx (75%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/index.tsx (93%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/new.tsx (100%) diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 920e6a3577d..61933f017b7 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -75,7 +75,7 @@ const WrappersDisabledState = () => { return () @@ -169,7 +169,7 @@ const EditWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - +diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobCard.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobCard.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobScheduleSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.constants.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.constants.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.test.ts rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.ts rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx similarity index 97% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx index c6003b569d9..4668f703848 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx @@ -8,8 +8,9 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button } from 'ui' -import EnableExtensionModal from '../Extensions/EnableExtensionModal' + import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' export const CronJobsDisabledState = () => { const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx index 916dd0a0612..24a6b8958ba 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx @@ -10,11 +10,12 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { Button, Sheet, SheetContent } from 'ui' -import EnableExtensionModal from '../Extensions/EnableExtensionModal' + import { CreateCronJobSheet } from './CreateCronJobSheet' import { CronJobCard } from './CronJobCard' import { CronJobsDisabledState } from './CronJobsDisabledState' import DeleteCronJob from './DeleteCronJob' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' export const CronJobsListing = () => { const { project } = useProjectContext() diff --git a/apps/studio/components/interfaces/Database/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/DeleteCronJob.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpHeaderFieldsSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpParameterFieldsSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpParameterFieldsSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpRequestSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpRequestSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/SqlFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/SqlFunctionSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/SqlSnippetSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/SqlSnippetSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx index a79cb9fe45c..27d3f4b16c7 100644 --- a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx @@ -11,10 +11,12 @@ const VaultNavTabs = (props: PropsWithChildren) => { return ( ) diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index 02f7c059f0f..bf527528b39 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -101,7 +101,8 @@ export const generateDatabaseMenu = ( { name: 'Wrappers', key: 'wrappers', - url: `/project/${ref}/database/wrappers`, + url: `/project/${ref}/integrations/wrappers`, + rightIcon: - Secrets Management + + Secrets Management + - Encryption Keys + Encryption Keys , items: [], }, { @@ -115,17 +116,8 @@ export const generateDatabaseMenu = ( { name: 'Webhooks', key: 'hooks', - url: `/project/${ref}/database/hooks`, - items: [], - }, - ] - : []), - ...(!!cronUiEnabled - ? [ - { - name: 'Cron Jobs', - key: 'cron-jobs', - url: `/project/${ref}/database/cron-jobs`, + url: `/project/${ref}/integrations/hooks`, + rightIcon: , items: [], }, ] diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index f6f69f2c2bf..04aa4edd1e5 100644 --- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -1,6 +1,6 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import { BASE_PATH } from 'lib/constants' -import { Book, BookOpen } from 'lucide-react' +import { ArrowUpRight, Book, BookOpen } from 'lucide-react' import SVG from 'react-inlinesvg' export const generateDocsMenu = ( @@ -71,7 +71,7 @@ export const generateDocsMenu = ( { name: 'GraphiQL', key: 'graphiql', - url: `/project/${ref}/api/graphiql`, + url: `/project/${ref}/integrations/graphiql`, icon: ( ), items: [], + rightIcon: , }, ], }, diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx new file mode 100644 index 00000000000..891f526f234 --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx @@ -0,0 +1,107 @@ +import { Code } from 'lucide-react' + +import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/CommandMenu.utils' +import type { CommandOptions } from 'ui-patterns/CommandMenu' +import { useRegisterCommands } from 'ui-patterns/CommandMenu' +import { useParams } from 'common' +import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' + +export function useDatabaseGotoCommands(options?: CommandOptions) { + let { ref } = useParams() + ref ||= '_' + + useRegisterCommands( + COMMAND_MENU_SECTIONS.QUERY, + [ + { + id: 'run-sql', + name: 'Run SQL', + route: `/project/${ref}/sql/new`, + icon: () => , + }, + ], + { + ...options, + deps: [ref], + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 2 }, + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + [ + { + id: 'nav-database-tables', + name: 'Tables', + value: 'Database: Tables', + route: `/project/${ref}/database/tables`, + defaultHidden: true, + }, + { + id: 'nav-database-triggers', + name: 'Triggers', + value: 'Database: Triggers', + route: `/project/${ref}/database/triggers`, + defaultHidden: true, + }, + { + id: 'nav-database-functions', + name: 'Functions', + value: 'Database: Functions', + route: `/project/${ref}/database/functions`, + defaultHidden: true, + }, + { + id: 'nav-database-extensions', + name: 'Extensions', + value: 'Database: Extensions', + route: `/project/${ref}/database/extensions`, + defaultHidden: true, + }, + { + id: 'nav-database-roles', + name: 'Roles', + value: 'Database: Roles', + route: `/project/${ref}/database/roles`, + defaultHidden: true, + }, + { + id: 'nav-database-replication', + name: 'Replication', + value: 'Database: Replication', + route: `/project/${ref}/database/replication`, + defaultHidden: true, + }, + { + id: 'nav-database-hooks', + name: 'Webhooks', + value: 'Database: Webhooks', + route: `/project/${ref}/database/hooks`, + defaultHidden: true, + }, + { + id: 'nav-database-backups', + name: 'Backups', + value: 'Database: Backups', + route: `/project/${ref}/database/backups/scheduled`, + defaultHidden: true, + }, + { + id: 'nav-database-wrappers', + name: 'Wrappers', + value: 'Database: Wrappers', + route: `/project/${ref}/database/wrappers`, + defaultHidden: true, + }, + { + id: 'nav-database-migrations', + name: 'Migrations', + value: 'Database: Migrations', + route: `/project/${ref}/database/migrations`, + defaultHidden: true, + }, + ], + { ...options, deps: [ref] } + ) +} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx new file mode 100644 index 00000000000..a7a08d5f617 --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' + +import { ProductMenu } from 'components/ui/ProductMenu' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { withAuth } from 'hooks/misc/withAuth' +import ProjectLayout from '../ProjectLayout/ProjectLayout' +import { generateProjectIntegrationsMenu } from './ProjectIntegrationsMenu.utils' +import { useFlag } from 'hooks/ui/useFlag' + +export interface ProjectIntegrationsLayoutProps { + title?: string +} + +const ProjectIntegrationsMenu = () => { + const cronUiEnabled = useFlag('cronUi') + const project = useSelectedProject() + + const router = useRouter() + const page = router.pathname.split('/')[4] + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + console.log({ data }) + const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined + const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined + + return ( + <> ++ > + ) +} + +const ProjectIntegrationsLayout = ({ + children, +}: PropsWithChildren ) => { + return ( + } + isBlocking={false} + > + {children} + + ) +} + +export default withAuth(ProjectIntegrationsLayout) diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx new file mode 100644 index 00000000000..78f2f91eb6a --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx @@ -0,0 +1,66 @@ +import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import type { Project } from 'data/projects/project-detail-query' + +export const generateProjectIntegrationsMenu = ( + project?: Project, + flags?: { + pgNetExtensionExists: boolean + cronUiEnabled: boolean + graphqlExtensionExists: boolean + } +): ProductMenuGroup[] => { + const ref = project?.ref ?? 'default' + const { pgNetExtensionExists, cronUiEnabled, graphqlExtensionExists } = flags || {} + + return [ + { + title: 'Manage', + items: [ + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/integrations/wrappers`, + items: [], + }, + ...(!!pgNetExtensionExists + ? [ + { + name: 'Webhooks', + key: 'hooks', + url: `/project/${ref}/integrations/hooks`, + items: [], + }, + ] + : []), + + ...(!!cronUiEnabled + ? [ + { + name: 'Cron Jobs', + key: 'cron-jobs', + url: `/project/${ref}/integrations/cron-jobs`, + items: [], + }, + ] + : []), + ...(!!graphqlExtensionExists + ? [ + { + name: 'GraphiQL', + key: 'graphiql', + url: `/project/${ref}/integrations/graphiql`, + items: [], + }, + ] + : []), + { + name: 'Vault', + key: 'vault', + url: `/project/${ref}/integrations/vault/secrets`, + items: [], + label: 'BETA', + }, + ], + }, + ] +} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 3073b168764..159f5bb7dae 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -12,7 +12,7 @@ import { TableEditor, } from 'icons' import { ICON_SIZE, ICON_STROKE_WIDTH } from './NavigationBar' -import { Settings, FileText, List, Lightbulb } from 'lucide-react' +import { Settings, FileText, List, Lightbulb, Blocks } from 'lucide-react' export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP @@ -138,6 +138,12 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/api`), }, + { + key: 'integrations', + label: 'Integrations', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`), + }, ] } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx similarity index 96% rename from apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts rename to apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 534af51b477..768c38a20c1 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -2,6 +2,7 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.typ import type { Project } from 'data/projects/project-detail-query' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import type { Organization } from 'types' +import { ArrowUpRight } from 'lucide-react' export const generateSettingsMenu = ( ref?: string, @@ -62,8 +63,9 @@ export const generateSettingsMenu = ( { name: 'Vault', key: 'vault', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/vault/secrets`, + url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/secrets`, items: [], + rightIcon: , label: 'BETA', }, ], diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 5b25632526d..6ea759427e0 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -394,6 +394,36 @@ const nextConfig = { source: '/project/:ref/database/performance-advisor', destination: '/project/:ref/advisors/performance', }, + { + permanent: true, + source: '/project/:ref/database/webhooks', + destination: '/project/:ref/integrations/webhooks', + }, + { + permanent: true, + source: '/project/:ref/database/wrappers', + destination: '/project/:ref/integrations/wrappers', + }, + { + permanent: true, + source: '/project/:ref/.....', + destination: '/project/:ref/integrations/cron-jobs', + }, + { + permanent: true, + source: '/project/:ref/api/graphiql', + destination: '/project/:ref/integrations/graphiql', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/secrets', + destination: '/project/:ref/integrations/vault/secrets', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/keys', + destination: '/project/:ref/integrations/vault/keys', + }, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ { diff --git a/apps/studio/pages/project/[ref]/database/cron-jobs.tsx b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx similarity index 71% rename from apps/studio/pages/project/[ref]/database/cron-jobs.tsx rename to apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx index f0fdf1c88a9..cb183f11488 100644 --- a/apps/studio/pages/project/[ref]/database/cron-jobs.tsx +++ b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx @@ -1,7 +1,7 @@ -import { CronJobsListing } from 'components/interfaces/Database/CronJobs/CronJobsListing' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import { CronJobsListing } from 'components/interfaces/Integrations/CronJobs/CronJobsListing' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const CronJobsPage: NextPageWithLayout = () => { // TODO: Change this to the correct permissions @@ -23,6 +23,8 @@ const CronJobsPage: NextPageWithLayout = () => { ) } -CronJobsPage.getLayout = (page) => {page} +CronJobsPage.getLayout = (page) => ( +{page} +) export default CronJobsPage diff --git a/apps/studio/pages/project/[ref]/api/graphiql.tsx b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx similarity index 94% rename from apps/studio/pages/project/[ref]/api/graphiql.tsx rename to apps/studio/pages/project/[ref]/integrations/graphiql.tsx index 0d7e0bfb9d8..3d34d908c1b 100644 --- a/apps/studio/pages/project/[ref]/api/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx @@ -7,7 +7,6 @@ import { toast } from 'sonner' import { useParams } from 'common' import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' -import DocsLayout from 'components/layouts/DocsLayout/DocsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { Loading } from 'components/ui/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' @@ -18,6 +17,7 @@ import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const GraphiQLPage: NextPageWithLayout = () => { const { resolvedTheme } = useTheme() @@ -111,6 +111,8 @@ const GraphiQLPage: NextPageWithLayout = () => { return} -GraphiQLPage.getLayout = (page) => {page} +GraphiQLPage.getLayout = (page) => ( +{page} +) export default GraphiQLPage diff --git a/apps/studio/pages/project/[ref]/database/hooks.tsx b/apps/studio/pages/project/[ref]/integrations/hooks.tsx similarity index 95% rename from apps/studio/pages/project/[ref]/database/hooks.tsx rename to apps/studio/pages/project/[ref]/integrations/hooks.tsx index 3bc6f1ad659..31474bccbda 100644 --- a/apps/studio/pages/project/[ref]/database/hooks.tsx +++ b/apps/studio/pages/project/[ref]/integrations/hooks.tsx @@ -16,6 +16,7 @@ import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const HooksPage: NextPageWithLayout = () => { const { project } = useProjectContext() @@ -124,6 +125,8 @@ const HooksPage: NextPageWithLayout = () => { ) } -HooksPage.getLayout = (page) =>{page} +HooksPage.getLayout = (page) => ( +{page} +) export default HooksPage diff --git a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx similarity index 89% rename from apps/studio/pages/project/[ref]/settings/vault/keys.tsx rename to apps/studio/pages/project/[ref]/integrations/vault/keys.tsx index 068b46426ed..63e5fd1b4cb 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx +++ b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx @@ -2,6 +2,7 @@ import { useParams } from 'common' import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { @@ -54,5 +55,7 @@ const VaultSettingsSecrets: NextPageWithLayout = () => { ) } -VaultSettingsSecrets.getLayout = (page) =>{page} +VaultSettingsSecrets.getLayout = (page) => ( +{page} +) export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx similarity index 89% rename from apps/studio/pages/project/[ref]/settings/vault/secrets.tsx rename to apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx index 5adf498d416..bd3c1c7eade 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx +++ b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx @@ -1,8 +1,9 @@ import { useParams } from 'common/hooks' import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' + import { ScaffoldContainer, ScaffoldDescription, @@ -55,5 +56,7 @@ const VaultSettingsSecrets: NextPageWithLayout = () => { ) } -VaultSettingsSecrets.getLayout = (page) =>{page} +VaultSettingsSecrets.getLayout = (page) => ( +{page} +) export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/database/wrappers/[id].tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx similarity index 75% rename from apps/studio/pages/project/[ref]/database/wrappers/[id].tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx index d73f76480aa..d9110015d27 100644 --- a/apps/studio/pages/project/[ref]/database/wrappers/[id].tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { EditWrapper } from 'components/interfaces/Database' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' @@ -17,6 +17,8 @@ const DatabaseWrappersNew: NextPageWithLayout = () => { return} -DatabaseWrappersNew.getLayout = (page) => {page} +DatabaseWrappersNew.getLayout = (page) => ( +{page} +) export default DatabaseWrappersNew diff --git a/apps/studio/pages/project/[ref]/database/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx similarity index 93% rename from apps/studio/pages/project/[ref]/database/wrappers/index.tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx index 93e3bf73f4a..6c0d2ea7566 100644 --- a/apps/studio/pages/project/[ref]/database/wrappers/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx @@ -19,6 +19,7 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const DatabaseWrappers: NextPageWithLayout = () => { const { project } = useProjectContext() @@ -80,6 +81,8 @@ const DatabaseWrappers: NextPageWithLayout = () => { ) } -DatabaseWrappers.getLayout = (page) =>{page} +DatabaseWrappers.getLayout = (page) => ( +{page} +) export default DatabaseWrappers diff --git a/apps/studio/pages/project/[ref]/database/wrappers/new.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx similarity index 100% rename from apps/studio/pages/project/[ref]/database/wrappers/new.tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx From d47048dcd6fe81eede090147f01adfaef2664583 Mon Sep 17 00:00:00 2001 From: Joshen LimDate: Tue, 22 Oct 2024 20:49:20 +0800 Subject: [PATCH 20/80] Update auth hooks to allow selection of fns that return void (#30022) * Update auth hooks to allow selection of fns that return void * Update for only send email hook --- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 34 ++++++++++++------- .../studio/components/ui/FunctionSelector.tsx | 6 ++-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index ca32ae51599..cfc81587e92 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -123,6 +123,11 @@ export const CreateHookSheet = ({ [title] ) + const supportedReturnTypes = + definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ['json', 'jsonb', 'void'] + : ['json', 'jsonb'] + const hook: Hook = useMemo(() => { return { ...definition, @@ -288,20 +293,21 @@ export const CreateHookSheet = ({ - + diff --git a/apps/studio/components/interfaces/Docs/GeneralContent.tsx b/apps/studio/components/interfaces/Docs/GeneralContent.tsx index cb336120919..4be9860fdcb 100644 --- a/apps/studio/components/interfaces/Docs/GeneralContent.tsx +++ b/apps/studio/components/interfaces/Docs/GeneralContent.tsx @@ -4,26 +4,19 @@ import TablesIntroduction from 'components/interfaces/Docs/Pages/Tables/Introduc import UserManagement from 'components/interfaces/Docs/Pages/UserManagement' import RpcIntroduction from 'components/interfaces/Docs/Pages/Rpc/Introduction' -const GeneralContent = ({ autoApiService, selectedLang, page, showApiKey }: any) => { +interface GeneralContentProps { + page?: string + selectedLang: 'bash' | 'js' + showApiKey: string +} + +const GeneralContent = ({ selectedLang, page, showApiKey }: GeneralContentProps) => { let selected = page?.toLowerCase() - if (selected == 'intro' || selected == null) - returnBack
diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx index 9bb472c3298..8d8a262ae00 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx @@ -86,7 +86,7 @@ const WrapperDropdown = ({ buttonText = 'Add wrapper', align = 'end' }: WrapperD onSelect={() => { setOpen(false) router.push( - `/project/${ref}/database/wrappers/new?type=${wrapper.name.toLowerCase()}` + `/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}` ) }} className="gap-2 cursor-pointer" diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx index 39bc4db5957..3196e7c2cb9 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx @@ -19,7 +19,7 @@ const WrappersEmptyState = () => {{WRAPPERS.map((wrapper) => (-diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx index 891f526f234..0fdc8a11075 100644 --- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx @@ -91,7 +91,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-wrappers', name: 'Wrappers', value: 'Database: Wrappers', - route: `/project/${ref}/database/wrappers`, + route: `/project/${ref}/integrations/wrappers`, defaultHidden: true, }, { diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx index 891f526f234..0fdc8a11075 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx @@ -91,7 +91,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-wrappers', name: 'Wrappers', value: 'Database: Wrappers', - route: `/project/${ref}/database/wrappers`, + route: `/project/${ref}/integrations/wrappers`, defaultHidden: true, }, { From 38d994da678976c0dd095c72c86fa863aa079137 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Tue, 22 Oct 2024 19:16:44 +0300 Subject: [PATCH 22/80] Chore: Clarify Disk Terms in the doc (#29955) * Chore: Clarify Disk Metrics in the doc * Add note about Disk size * consolidate terms to disk and database size * fix vale comments * Update database-size.mdx * Update database-size.mdx --------- Co-authored-by: Inian --- .../content/guides/platform/database-size.mdx | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index a455371e9ad..3415a649820 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -1,19 +1,18 @@ --- -id: 'database-size' -title: 'Database size' +id: 'disk-metrics' +title: 'Understanding Database and Disk Size' description: 'Understanding how database size applies to your subscription.' --- -Database size refers to the _monthly average storage usage_, as reported by Postgres. This metric is reported in your organization's [billing usage](https://supabase.com/dashboard/org/_/usage) and is updated daily. As you read this document we will refer to "database size" and "disk size": +Disk metrics refer to the storage usage reported by Postgres. These metrics are updated daily. As you read through this document, we will refer to "database size" and "disk size": -- "Database size" is the total size of used storage from your database. -- "Disk Storage size" describes the size of the underlying available storage. +- _Database size_: Displays the actual size of the data within your Postgres database. This can be found on the [Database Reports page](https://supabase.com/dashboard/project/_/reports/database). -## Database space management +- _Disk size_: Shows the overall disk space usage, which includes both the database size and additional files required for Postgres to function like the Write Ahead Log (WAL) and other system log files. You can view this on the [Database Settings page](https://supabase.com/dashboard/project/_/settings/database). -### Database size +## Database size -This SQL query will show the current size of your Postgres database: +This SQL query will show the size of all databases in your Postgres cluster: ```sql select @@ -23,7 +22,7 @@ from pg_database; This value is reported in the [database report page](https://supabase.com/dashboard/project/_/reports/database). -Database Space is consumed primarily by your data, indexes, and materialized views. You can reduce your disk size by removing any of these and running a Vacuum operation. +Database size is consumed primarily by your data, indexes, and materialized views. You can reduce your database size by removing any of these and running a Vacuum operation. @@ -69,39 +68,37 @@ Running a manual vacuum after deleting large amounts of data from your DB could New Supabase projects have a database size of ~40-60mb. This space includes pre-installed extensions, schemas, and default Postgres data. Additional database size is used when installing extensions, even if those extensions are inactive. -## Disk management +## Disk size -Supabase uses network-attached storage to balance performance with scalability. The behavior of your disk depends on your billing plan. +Supabase uses network-attached storage to balance performance with scalability. The disk scaling behavior depends on your billing plan. ### Paid plan behavior -Projects on the Pro Plan and above have auto-scaling Disk Storage. +Projects on the Pro Plan and higher have auto-scaling disks. -Disk Storage expands automatically when the database reaches 90% of the disk size. The disk is expanded to be 50% larger (e.g., 8GB -> 12GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. +Disk size expands automatically when the database reaches 90% of the allocated disk size. The disk is expanded to be 50% larger (for example, 8 GB -> 12 GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. -The automatic resize operation will add an additional 50% capped to a maximum of 200GB. If 50% of your current usage is more than 200GB then only 200GB will be added to your disk (e.g. a size of 1500GB will resize to 1700GB). +The automatic resize operation will add an additional 50% capped to a maximum of 200 GB. If 50% of your current usage is more than 200 GB then only 200 GB will be added to your disk (for example a size of 1500 GB will resize to 1700 GB). -The Disk Storage Size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum default you can expand the Disk Storage to is 200GB. If you wish to manually expand it to any more than 200GB, please [contact us](https://supabase.com/dashboard/support/new) to discuss expanding the 200GB limit. +Disk size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum disk size for the Pro/Team Plan is 60 TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan.-You may want to import a lot of data into your database which requires multiple disk expansions; for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the Disk Storage Size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). +You may want to import a lot of data into your database which requires multiple disk expansions. for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the disk size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). -Due to provider restrictions, disk expansions can occur only once every six hours. During the six-hour window, the disk cannot be resized again. +Due to restrictions on the underlying cloud provider, disk expansions can occur only once every six hours. During the six hour cool down window, the disk cannot be resized again. -The maximum Disk Storage Size for the Pro/Team Plan is 60TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan. - ### Free Plan behavior -Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500MB limit. Once in read-only mode, you have several options: +Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500 MB limit. Once in read-only mode, you have these options: -- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale and expand beyond the 8GB database size limit. +- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8 GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale beyond the 8 GB disk size limit. - [Disable read-only mode](#disabling-read-only-mode) and reduce your database size. ### Read-only mode @@ -134,6 +131,4 @@ set default_transaction_read_only = 'off'; ### Reducing disk size -Disks don't automatically downsize during normal operation. Once you have [reduced your database space usage](/docs/guides/platform/database-size#database-space-management), they _will_ automatically "right-size" when a Supabase (or Postgres) update is applied. Their final size after the update is 1.2x the size of the database (min 8 GB). For example, if your database is 100GB in size, and you have a 200GB disk, the size after a Supabase update will be 120GB. - -Learn more about [upgrading your project](/docs/guides/platform/migrating-and-upgrading-projects#upgrade-your-project). +Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB. From b2cd52aac858821cec884e7449ac93ba148aec55 Mon Sep 17 00:00:00 2001 From: Terry SuttonDate: Tue, 22 Oct 2024 13:48:55 -0230 Subject: [PATCH 23/80] Chore/disk size helper (#29952) * Add disk size note * Capitalize * Include db size --- .../DiskManagement/DiskSpaceBar.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx index 1f3ff172e64..4c588d05af4 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx @@ -9,6 +9,10 @@ import { TooltipTrigger_Shadcn_, } from 'ui' import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants' +import { Info } from 'lucide-react' +import { formatBytes } from 'lib/helpers' +import { useDatabaseSizeQuery } from 'data/database/database-size-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' interface DiskSpaceBarProps { showNewBar: boolean @@ -29,6 +33,13 @@ export default function DiskSpaceBar({ const newUsedPercentage = (usedSize / newTotalSize) * 100 const newResizePercentage = AUTOSCALING_THRESHOLD * 100 + const { project } = useProjectContext() + const { data } = useDatabaseSizeQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const databaseSizeBytes = data?.result[0].db_size ?? 0 return ( ) } From 09757fcac45c477df965b1ffae1411ab9573730c Mon Sep 17 00:00:00 2001 From: Kang Ming@@ -154,8 +165,8 @@ export default function DiskSpaceBar({ >+- - Autoscaling ++ Autoscaling@@ -186,6 +197,12 @@ export default function DiskSpaceBar({ Available space + Note: Disk Size refers to the total space your + project occupies on disk, including the database itself (currently{' '} + {formatBytes(databaseSizeBytes, 2, 'GB')}), additional files like the + write-ahead log (WAL), and other internal resources. +
Date: Wed, 23 Oct 2024 02:33:26 +0800 Subject: [PATCH 24/80] fix: allow sms rate limit update even if autoconfirm is enabled (#30039) --- .../studio/components/interfaces/Auth/RateLimits/RateLimits.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index 5c8c22d1a85..bbb3210480f 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -53,7 +53,7 @@ const RateLimits = () => { }) const canUpdateEmailLimit = authConfig?.EXTERNAL_EMAIL_ENABLED && isSmtpEnabled(authConfig) - const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED && !authConfig?.SMS_AUTOCONFIRM + const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED const canUpdateAnonymousUsersRateLimit = authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED const FormSchema = z.object({ From 4d185109ab5f6bce15b80b5e63249eb835896130 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 23 Oct 2024 11:17:18 +0800 Subject: [PATCH 25/80] fix: allow developer role to update buckets (#30024) * fix: allow developer role to update buckets * Fix create bucket perms --------- Co-authored-by: Joshen Lim --- apps/studio/components/layouts/StorageLayout/BucketRow.tsx | 2 +- apps/studio/components/layouts/StorageLayout/StorageMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx index b44f233ebb7..1474325de01 100644 --- a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx +++ b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx @@ -34,7 +34,7 @@ const BucketRow = ({ onSelectDeleteBucket = noop, onSelectEditBucket = noop, }: BucketRowProps) => { - const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') return ( { const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState () const [selectedBucketToDelete, setSelectedBucketToDelete] = useState () - const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const [sort, setSort] = useLocalStorage<'alphabetical' | 'created-at'>( 'storage-explorer-sort', From c9458d93cf38531e7dcd8baab3800a8111310c77 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 15:42:06 +0800 Subject: [PATCH 26/80] Chore/fix broken links wrappers (#30046) * Fix broken links wrappers * Refactor --- .../Database/Wrappers/CreateWrapper.tsx | 2 +- .../Database/Wrappers/WrapperRow.tsx | 107 +++++++----------- apps/studio/components/ui/ButtonTooltip.tsx | 2 +- .../[ref]/integrations/wrappers/index.tsx | 3 +- 4 files changed, 41 insertions(+), 73 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx index 685e05b56a2..98056249503 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx @@ -142,7 +142,7 @@ const CreateWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - + Back
diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 21c9fce17f9..ffdb8ae54d9 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -1,14 +1,14 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' +import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' import Image from 'next/legacy/image' import Link from 'next/link' -import { Button, Collapsible } from 'ui' -import { useParams } from 'common/hooks' +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' +import { Collapsible } from 'ui' import type { WrapperMeta } from './Wrappers.types' interface WrapperRowProps { @@ -131,72 +131,41 @@ const WrapperRow = ({- {canManageWrappers ? ( - - } - className="px-1.5" - /> +) diff --git a/apps/studio/components/ui/ButtonTooltip.tsx b/apps/studio/components/ui/ButtonTooltip.tsx index f1ff45d08e8..23372130b80 100644 --- a/apps/studio/components/ui/ButtonTooltip.tsx +++ b/apps/studio/components/ui/ButtonTooltip.tsx @@ -18,7 +18,7 @@ export const ButtonTooltip = forwardRef< {props.children} - {props.disabled && props.tooltip.content.text !== undefined && ( + {props.tooltip.content.text !== undefined && (+} + className="px-1.5 space-x-0" + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to edit wrappers' + : 'Edit wrapper', + }, + }} + > + + Edit - ) : ( - - - )} -- } - className="px-1.5" - /> - - {!canManageWrappers && ( -- - )} -- -- - - You need additional permissions to edit wrappers - --- + +- } - className="px-1.5" - onClick={() => onSelectDelete(wrapper)} - /> - - {!canManageWrappers && ( -- - )} -- -- - - You need additional permissions to add wrappers - --} + className="px-1.5" + onClick={() => onSelectDelete(wrapper)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to delete wrappers' + : 'Delete wrapper', + }, + }} + /> {props.tooltip.content.text} diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx index 6c0d2ea7566..6360dd51395 100644 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx @@ -3,7 +3,7 @@ import { ExternalLink } from 'lucide-react' import { Wrappers } from 'components/interfaces/Database' import WrappersDropdown from 'components/interfaces/Database/Wrappers/WrappersDropdown' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, @@ -19,7 +19,6 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const DatabaseWrappers: NextPageWithLayout = () => { const { project } = useProjectContext() From cb5ae761f343c9b309c25f9b806b39c3cdff3e5e Mon Sep 17 00:00:00 2001 From: Joshen LimDate: Wed, 23 Oct 2024 16:36:09 +0800 Subject: [PATCH 27/80] Chore/add perms check in projects api query (#30025) * Add perms check in projects API query * Start deprecating use of projects-api-query, move to projects-settings-v2-query * PRETTY * Address feedback * Address feedback --- .../interfaces/App/CommandMenu/ApiKeys.tsx | 15 +++-- .../interfaces/App/CommandMenu/ApiUrl.tsx | 10 +-- .../interfaces/Auth/Users/BanUserModal.tsx | 29 +++++---- .../interfaces/Auth/Users/CreateUserModal.tsx | 21 +++++-- .../interfaces/Docs/Authentication.tsx | 28 +++++---- .../interfaces/Docs/GeneralContent.tsx | 27 +++----- .../interfaces/Docs/Introduction.tsx | 9 ++- .../interfaces/Docs/LangSelector.tsx | 26 ++++---- .../interfaces/Docs/Pages/UserManagement.tsx | 39 ++++++------ .../components/interfaces/Docs/RpcContent.tsx | 39 ++++++++---- .../interfaces/Home/Connect/Connect.tsx | 37 +++-------- .../Home/NewProjectPanel/APIKeys.tsx | 19 +++--- .../ProjectAPIDocs/Content/Introduction.tsx | 29 ++++----- .../layouts/StorageLayout/StorageLayout.tsx | 25 ++++---- .../Storage/StorageSettings/S3Connection.tsx | 7 ++- .../StorageSettings/StorageSettings.utils.ts | 8 +-- apps/studio/data/config/keys.ts | 1 + apps/studio/data/config/project-api-query.ts | 11 ++-- .../data/config/project-settings-query.ts | 1 + .../data/config/project-settings-v2-query.ts | 62 +++++++++++++++++++ apps/studio/pages/project/[ref]/api/index.tsx | 28 +++------ .../project/[ref]/integrations/graphiql.tsx | 11 ++-- 22 files changed, 272 insertions(+), 210 deletions(-) create mode 100644 apps/studio/data/config/project-settings-v2-query.ts diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index a3c3f2909a8..c86051bba3f 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,7 +1,7 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' @@ -23,14 +23,13 @@ export function useApiKeysCommands() { const setPage = useSetPage() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const ref = project?.ref || '_' + + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const ref = project?.ref || '_' - - const anonKey = settings?.autoApiService?.defaultApiKey ?? undefined - const serviceKey = settings?.autoApiService?.serviceApiKey ?? undefined + const { anonKey, serviceKey } = getAPIKeys(settings) const commands = useMemo( () => @@ -40,7 +39,7 @@ export function useApiKeysCommands() { id: 'anon-key', name: `Copy anonymous API key`, action: () => { - copyToClipboard(anonKey ?? '') + copyToClipboard(anonKey.api_key ?? '') setIsOpen(false) }, badge: () => ( @@ -56,7 +55,7 @@ export function useApiKeysCommands() { id: 'service-key', name: `Copy service API key`, action: () => { - copyToClipboard(serviceKey ?? '') + copyToClipboard(serviceKey.api_key ?? '') setIsOpen(false) }, badge: () => ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx index 28b36682981..1205a8f3333 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx @@ -1,10 +1,10 @@ import { Link } from 'lucide-react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { orderCommandSectionsByPriority } from './ordering' @@ -12,13 +12,13 @@ export function useApiUrlCommand() { const setIsOpen = useSetCommandMenuOpen() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const apiUrl = settings?.autoApiService.endpoint - ? `${settings.autoApiService.protocol ?? 'https'}://${settings.autoApiService.endpoint}` + const apiUrl = settings?.app_config?.endpoint + ? `https://${settings.app_config.endpoint}` : undefined useRegisterCommands( diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index 1ae878d8855..d246cd04cd5 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -1,10 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import dayjs from 'dayjs' -import { useForm } from 'react-hook-form' -import * as z from 'zod' -import { toast } from 'sonner' import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' +import { useParams } from 'common' +import { useUserUpdateMutation } from 'data/auth/user-update-mutation' +import { User } from 'data/auth/users-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -20,10 +24,6 @@ import { Separator, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { useUserUpdateMutation } from 'data/auth/user-update-mutation' -import { User } from 'data/auth/users-query' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useParams } from 'common' interface BanUserModalProps { visible: boolean @@ -34,7 +34,7 @@ interface BanUserModalProps { export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const { ref: projectRef } = useParams() - const { data: apiData } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({ onSuccess: (_, vars) => { const bannedUntil = dayjs() @@ -62,17 +62,24 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)') const onSubmit = (data: FormType) => { - if (!apiData) { + if (!settings) { return toast.error(`Failed to ban user: Error loading project config`) } else if (user.id === undefined) { return toast.error(`Failed to ban user: User ID not found`) } const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24 - const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + const endpoint = settings.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (x) => x.tags === 'service_role' + )?.api_key + + if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`) + if (!serviceApiKey) return toast.error(`Failed to ban user: Unable to retrieve API key`) + updateUser({ projectRef, - protocol, + protocol: 'https', endpoint, serviceApiKey, userId: user.id, diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 90d0e02b97e..3f5cdb41567 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -1,13 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { useParams } from 'common' import { useUserCreateMutation } from 'data/auth/user-create-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, @@ -40,7 +40,11 @@ const CreateUserFormSchema = z.object({ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const { ref: projectRef } = useParams() - const { data, isLoading, isSuccess } = useProjectApiQuery({ projectRef }, { enabled: visible }) + const { + data: settings, + isLoading, + isSuccess, + } = useProjectSettingsV2Query({ projectRef }, { enabled: visible }) const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') @@ -56,8 +60,15 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { if (!isSuccess) { return toast.error(`Failed to create user: Error loading project config`) } - const { protocol, endpoint, serviceApiKey } = data.autoApiService - createUser({ projectRef, endpoint, protocol, serviceApiKey, user: values }) + const endpoint = settings?.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (key) => key.tags === 'service_role' + )?.api_key + + if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`) + if (!serviceApiKey) return toast.error(`Failed to create user: Unable to retrieve API key`) + + createUser({ projectRef, endpoint, protocol: 'https', serviceApiKey, user: values }) } const form = useForm >({ diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx index d3a44692039..37d6840a7b0 100644 --- a/apps/studio/components/interfaces/Docs/Authentication.tsx +++ b/apps/studio/components/interfaces/Docs/Authentication.tsx @@ -1,24 +1,30 @@ import Link from 'next/link' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import Snippets from './Snippets' interface AuthenticationProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -const Authentication = ({ autoApiService, selectedLang, showApiKey }: AuthenticationProps) => { +const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + + const { anonKey, serviceKey } = getAPIKeys(settings) + const endpoint = settings?.app_config?.endpoint ?? '' + // [Joshen] ShowApiKey should really be a boolean, its confusing const defaultApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.defaultApiKey ?? 'SUPABASE_CLIENT_API_KEY' + ? anonKey?.api_key ?? 'SUPABASE_CLIENT_API_KEY' : 'SUPABASE_CLIENT_API_KEY' const serviceApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.serviceApiKey ?? 'SUPABASE_SERVICE_KEY' + ? serviceKey?.api_key ?? 'SUPABASE_SERVICE_KEY' : 'SUPABASE_SERVICE_KEY' return ( @@ -53,8 +59,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica We have provided you a Client Key to get started. You will soon be able to add as many keys as you like. You can find the
anonkey in the{' '} - API Settings{' '} - page. + API Settings page.@@ -64,7 +69,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica /> @@ -85,8 +90,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica We have provided you with a Service Key to get started. Soon you will be able to add as many keys as you like. You can find the
service_rolein the{' '} - API Settings{' '} - page. + API Settings page.@@ -96,9 +100,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica /> + if (selected == 'intro' || selected == null) return if (selected == 'auth') - return ( - - ) + return if (selected == 'users') - return ( - - ) + return if (selected == 'tables-intro') return if (selected == 'rpc-intro') return else diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index 6c4c68c167e..b274bc6f388 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -5,17 +5,20 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co import CodeSnippet from './CodeSnippet' import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' interface Props { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' } -export default function Introduction({ autoApiService, selectedLang }: Props) { +export default function Introduction({ selectedLang }: Props) { const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + const isPublicSchemaEnabled = config?.db_schema .split(',') .map((name) => name.trim()) @@ -24,7 +27,7 @@ export default function Introduction({ autoApiService, selectedLang }: Props) { return ( @@ -111,7 +110,7 @@ export default function UserManagement({- diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index 53376c063c9..b523ded9d53 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,8 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - -import type { showApiKey } from 'components/interfaces/Docs/Docs.types' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Key } from 'lucide-react' + +import { useParams } from 'common' +import type { showApiKey } from 'components/interfaces/Docs/Docs.types' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -15,26 +17,26 @@ const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } interface LangSelectorProps { selectedLang: string - setSelectedLang: (selectedLang: string) => void showApiKey: showApiKey + setSelectedLang: (selectedLang: string) => void setShowApiKey: (showApiKey: showApiKey) => void - apiKey: string | undefined - autoApiService: any } const LangSelector = ({ selectedLang, - setSelectedLang, showApiKey, + setSelectedLang, setShowApiKey, - apiKey, - autoApiService, }: LangSelectorProps) => { + const { ref: projectRef } = useParams() const canReadServiceKey = useCheckPermissions( PermissionAction.READ, 'service_api_keys.service_role_key' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { anonKey: anonApiKey, serviceKey: serviceApiKey } = getAPIKeys(settings) + return (+ {!isPublicSchemaEnabled && } @@ -87,7 +86,7 @@ export default function UserManagement({@@ -75,12 +77,12 @@ const LangSelector = ({@@ -70,7 +69,7 @@ export default function UserManagement({setShowApiKey(DEFAULT_KEY)}> hide - {apiKey && ( + {anonApiKey && (setShowApiKey({ - key: apiKey, + key: anonApiKey.api_key ?? '-', name: 'anon (public)', }) } @@ -93,7 +95,7 @@ const LangSelector = ({ key="service" onClick={() => setShowApiKey({ - key: autoApiService.serviceApiKey, + key: serviceApiKey?.api_key ?? '-', name: 'service_role (secret)', }) } diff --git a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx index 9cbf5f4ac2d..c0296107f1b 100644 --- a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx +++ b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { makeRandomString } from 'lib/helpers' import CodeSnippet from '../CodeSnippet' import Snippets from '../Snippets' @@ -9,20 +10,18 @@ import Snippets from '../Snippets' const randomPassword = makeRandomString(20) interface UserManagementProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -export default function UserManagement({ - autoApiService, - selectedLang, - showApiKey, -}: UserManagementProps) { +export default function UserManagement({ selectedLang, showApiKey }: UserManagementProps) { const router = useRouter() - + const { ref: projectRef } = useParams() const keyToShow = showApiKey ? showApiKey : 'SUPABASE_KEY' + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + return ( <> User Management
@@ -53,7 +52,7 @@ export default function UserManagement({@@ -42,7 +57,7 @@ const RpcContent = ({
) } - -export default APISidePanelPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index 56293f7b9b0..a88c1eddc96 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -5,7 +5,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui' -const CLSPreview = () => { +export const CLSPreview = () => { const { ref } = useParams() return ( @@ -53,5 +53,3 @@ const CLSPreview = () => {- + diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.tsx b/apps/studio/components/interfaces/Home/Connect/Connect.tsx index d00330491b2..2478ade775a 100644 --- a/apps/studio/components/interfaces/Home/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Home/Connect/Connect.tsx @@ -5,10 +5,9 @@ import { useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import Panel from 'components/ui/Panel' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import { Button, DIALOG_PADDING_X, @@ -30,7 +29,6 @@ import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './C import { getContentFilePath } from './Connect.utils' import ConnectDropdown from './ConnectDropdown' import ConnectTabContent from './ConnectTabContent' -import Panel from 'components/ui/Panel' const Connect = () => { const { ref: projectRef } = useParams() @@ -46,6 +44,9 @@ const Connect = () => { ?.children.find((child) => child.key === selectedChild)?.children[0]?.key || '' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + const handleParentChange = (value: string) => { setSelectedParent(value) @@ -127,32 +128,12 @@ const Connect = () => { return [] } - const { data: projectSettings } = useProjectSettingsQuery({ projectRef }) - - const { data: apiSettings } = useProjectApiQuery({ - projectRef, - }) - - // Get the API service - const apiService = (projectSettings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - - const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - - // Get the API service - const apiHost = canReadAPIKeys - ? `${apiSettings?.autoApiService?.protocol ?? 'https'}://${ - apiSettings?.autoApiService?.endpoint ?? '-' - }` - : '' + const endpoint = settings?.app_config?.endpoint + const apiHost = canReadAPIKeys ? `https://${endpoint ?? '-'}` : '' const apiUrl = canReadAPIKeys ? apiHost : null - const anonKey = canReadAPIKeys - ? apiService?.service_api_keys.find((key) => key.tags === 'anon')?.api_key ?? null - : null - - const projectKeys = { apiUrl, anonKey } + const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } + const projectKeys = { apiUrl, anonKey: anonKey?.api_key ?? null } const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 93af608f4e7..afccbce6923 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -3,14 +3,14 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import Link from 'next/link' import { useState } from 'react' -import { useParams } from 'common/hooks' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' +import { useParams } from 'common/hooks' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Input } from 'ui' import { AlertCircle, Loader } from 'lucide-react' +import { Input } from 'ui' const generateInitSnippet = (endpoint: string) => ({ js: ` @@ -42,7 +42,7 @@ const APIKeys = () => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectApiQuery({ + } = useProjectSettingsV2Query({ projectRef, }) @@ -55,17 +55,16 @@ const APIKeys = () => { const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - // Get the API service - const apiService = settings?.autoApiService - const apiKeys = apiService?.service_api_keys ?? [] + const apiKeys = settings?.service_api_keys ?? [] // API keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` - const anonKey = apiKeys.find((key) => key.tags === 'anon') + const endpoint = settings?.app_config?.endpoint + const apiUrl = `https://${endpoint ?? '-'}` + const { anonKey } = getAPIKeys(settings) const clientInitSnippet: any = generateInitSnippet(apiUrl) const selectedLanguageSnippet = clientInitSnippet[selectedLanguage.key] ?? 'No snippet available' @@ -146,7 +145,7 @@ const APIKeys = () => { ? 'JWT secret update failed, new API key may have issues' : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating ? 'Updating JWT secret...' - : apiService?.defaultApiKey + : anonKey?.api_key } onChange={() => {}} descriptionText={ diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 4c50ce99911..10edf7179ef 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -1,17 +1,17 @@ import { useParams } from 'common' import { Button, Input } from 'ui' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { copyToClipboard } from 'lib/helpers' +import { Copy } from 'lucide-react' import { useEffect, useState } from 'react' import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -import { Copy } from 'lucide-react' const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() - const { data } = useProjectApiQuery({ projectRef: ref }) + const { data } = useProjectSettingsV2Query({ projectRef: ref }) const [copied, setCopied] = useState<'anon' | 'service'>() @@ -19,9 +19,10 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => if (copied !== undefined) setTimeout(() => setCopied(undefined), 2000) }, [copied]) - const serviceKey = showKeys - ? data?.autoApiService.serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' - : 'SUPABASE_CLIENT_SERVICE_KEY' + const anonApiKey = (data?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key + const serviceApiKey = + (data?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? + 'SUPABASE_CLIENT_SERVICE_KEY' return ( <> @@ -52,9 +53,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => icon={ } onClick={() => { setCopied('anon') - copyToClipboard( - data?.autoApiService.defaultApiKey ?? 'SUPABASE_CLIENT_ANON_KEY' - ) + copyToClipboard(anonApiKey ?? 'SUPABASE_CLIENT_ANON_KEY') }} > {copied === 'anon' ? 'Copied' : 'Copy'} @@ -68,7 +67,11 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => disabled readOnly size="small" - value={showKeys ? serviceKey : 'Reveal API keys via dropdown in the header'} + value={ + showKeys + ? serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' + : 'Reveal API keys via dropdown in the header' + } className="w-full" descriptionText={ @@ -83,9 +86,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => icon={
} onClick={() => { setCopied('service') - copyToClipboard( - data?.autoApiService.serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' - ) + copyToClipboard(serviceApiKey) }} > {copied === 'service' ? 'Copied' : 'Copy'} @@ -105,7 +106,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => diff --git a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx index aa97b8fa2b8..0df1670f64f 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { ReactNode, useEffect } from 'react' import { toast } from 'sonner' -import { AutoApiService, useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { PROJECT_STATUS } from 'lib/constants' @@ -20,25 +20,24 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { const project = useSelectedProject() const storageExplorerStore = useStorageStore() - const { data: settings, isLoading } = useProjectApiQuery({ projectRef }) - const apiService = settings?.autoApiService + const { data: settings, isLoading } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (key) => key.tags === 'service_role' + )?.api_key const isPaused = project?.status === PROJECT_STATUS.INACTIVE useEffect(() => { - if (!isLoading && apiService) initializeStorageStore(apiService) - }, [isLoading, projectRef]) + if (!isLoading && endpoint && serviceApiKey) initializeStorageStore(endpoint, serviceApiKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, projectRef, endpoint, serviceApiKey]) - const initializeStorageStore = async (apiService: AutoApiService) => { + const initializeStorageStore = async (endpoint: string, serviceApiKey: string) => { if (isPaused) return - if (apiService.endpoint) { - storageExplorerStore.initStore( - projectRef!, - apiService.endpoint, - apiService.serviceApiKey, - apiService.protocol - ) + if (endpoint) { + storageExplorerStore.initStore(projectRef!, endpoint, serviceApiKey, 'https') } else { toast.error( 'Failed to fetch project configuration. Try refreshing your browser, or reach out to us at support@supabase.io' diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx index 25a5c517e49..1e58b14c992 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx @@ -12,7 +12,7 @@ import Table from 'components/to-be-cleaned/Table' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' import Panel from 'components/ui/Panel' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, WarningIcon } from 'ui' @@ -35,14 +35,15 @@ export const S3Connection = () => { const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const { data: projectAPI } = useProjectApiQuery({ projectRef: projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery( { projectRef }, { enabled: canReadS3Credentials } ) + const endpoint = settings?.app_config?.endpoint const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0 - const s3connectionUrl = getConnectionURL(projectRef ?? '', projectAPI) + const s3connectionUrl = getConnectionURL(projectRef ?? '', endpoint) return ( <> diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts index 44cf98726af..3dab5cc0cd4 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts @@ -26,12 +26,8 @@ export const convertToBytes = (size: number, unit: StorageSizeUnits = StorageSiz return size * Math.pow(k, i) } -export function getConnectionURL(projectRef: string, projectAPI?: ProjectApiResponse) { - if (projectAPI === undefined) return '' - - const projUrl = projectAPI - ? `${projectAPI.autoApiService.protocol}://${projectAPI.autoApiService.endpoint}` - : `https://${projectRef}.supabase.co` +export function getConnectionURL(projectRef: string, endpoint?: string) { + const projUrl = endpoint ? `https://${endpoint}` : `https://${projectRef}.supabase.co` const url = new URL(projUrl) url.pathname = '/storage/v1/s3' diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index 6f4b61da217..a2aab7748f1 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -2,6 +2,7 @@ export const configKeys = { pgBouncerStatus: (projectRef: string | undefined) => ['projects', projectRef, 'pgbouncer'] as const, settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const, + settingsV2: (projectRef: string | undefined) => ['projects', projectRef, 'settings-v2'] as const, api: (projectRef: string | undefined) => ['projects', projectRef, 'settings', 'api'] as const, postgrest: (projectRef: string | undefined) => ['projects', projectRef, 'postgrest'] as const, jwtSecretUpdatingStatus: (projectRef: string | undefined) => diff --git a/apps/studio/data/config/project-api-query.ts b/apps/studio/data/config/project-api-query.ts index 8baaee61d0d..3826300f630 100644 --- a/apps/studio/data/config/project-api-query.ts +++ b/apps/studio/data/config/project-api-query.ts @@ -6,6 +6,8 @@ import { useProjectByRef } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import type { ResponseError } from 'types' import { configKeys } from './keys' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { PermissionAction } from '@supabase/shared-types/out/constants' export type ProjectApiVariables = { projectRef?: string @@ -32,24 +34,25 @@ export async function getProjectApi({ projectRef }: ProjectApiVariables, signal? export type ProjectApiData = Awaited > export type ProjectApiError = ResponseError +/** @deprecated */ export const useProjectApiQuery = ( { projectRef }: ProjectApiVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => { + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + return useQuery ( configKeys.api(projectRef), ({ signal }) => getProjectApi({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', refetchInterval(data) { - if (!data) { - return false - } + if (!data) return false const { autoApiService } = data as unknown as ProjectApiData const apiKeys = autoApiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 + const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 return interval }, diff --git a/apps/studio/data/config/project-settings-query.ts b/apps/studio/data/config/project-settings-query.ts index 96a3a82385d..7d9d642c132 100644 --- a/apps/studio/data/config/project-settings-query.ts +++ b/apps/studio/data/config/project-settings-query.ts @@ -42,6 +42,7 @@ export async function getProjectSettings( export type ProjectSettingsData = Awaited > export type ProjectSettingsError = ResponseError +/** @deprecated */ export const useProjectSettingsQuery = ( { projectRef }: ProjectSettingsVariables, { diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts new file mode 100644 index 00000000000..129d756c0bd --- /dev/null +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -0,0 +1,62 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { components } from 'data/api' +import { get, handleError } from 'data/fetchers' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import type { ResponseError } from 'types' +import { configKeys } from './keys' + +export type ProjectSettingsVariables = { projectRef?: string } +export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] + +export async function getProjectSettings( + { projectRef }: ProjectSettingsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/projects/{ref}/settings', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + return data as unknown as ProjectSettings +} + +type ProjectSettingsData = Awaited > +type ProjectSettingsError = ResponseError + +export const useProjectSettingsV2Query = ( + { projectRef }: ProjectSettingsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + // [Joshen] Sync with API perms checking here - shouldReturnApiKeys + // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/platform/projects/ref/settings.controller.ts#L92 + const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') + + return useQuery ( + configKeys.settingsV2(projectRef), + ({ signal }) => getProjectSettings({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data) { + const apiKeys = (data as ProjectSettings)?.service_api_keys ?? [] + const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 + return interval + }, + ...options, + } + ) +} + +export const getAPIKeys = (settings?: ProjectSettings) => { + const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') + const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') + + return { anonKey, serviceKey } +} diff --git a/apps/studio/pages/project/[ref]/api/index.tsx b/apps/studio/pages/project/[ref]/api/index.tsx index d195fd6da37..a693135da06 100644 --- a/apps/studio/pages/project/[ref]/api/index.tsx +++ b/apps/studio/pages/project/[ref]/api/index.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { GeneralContent, ResourceContent, RpcContent } from 'components/interfaces/Docs' import LangSelector from 'components/interfaces/Docs/LangSelector' import DocsLayout from 'components/layouts/DocsLayout/DocsLayout' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' import { snakeToCamel } from 'lib/helpers' @@ -26,10 +26,7 @@ const DocView = () => { const [selectedLang, setSelectedLang] = useState ('js') const [showApiKey, setShowApiKey] = useState (DEFAULT_KEY) - const { data, error } = useProjectApiQuery({ projectRef }) - const apiService = data?.autoApiService - const anonKey = apiService?.defaultApiKey ?? undefined - + const { data: settings, error: settingsError } = useProjectSettingsV2Query({ projectRef }) const { data: jsonSchema, error: jsonSchemaError, @@ -43,11 +40,10 @@ const DocView = () => { const endpoint = customDomainData?.customDomain?.status === 'active' ? `https://${customDomainData.customDomain?.hostname}` - : `${data?.autoApiService.protocol ?? 'https'}://${data?.autoApiService.endpoint ?? '-'}` + : `https://${settings?.app_config?.endpoint ?? '-'}` const { paths } = jsonSchema || {} const PAGE_KEY: any = resource || rpc || page || 'index' - const autoApiService = { ...(data?.autoApiService ?? {}), endpoint } const { resources, rpcs } = Object.entries(paths || {}).reduce( (a, [name]) => { @@ -84,18 +80,18 @@ const DocView = () => { { resources: {}, rpcs: {} } ) - if (error || jsonSchemaError) { + if (settingsError || jsonSchemaError) { return ( ) } - if (isLoading || !data || !jsonSchema) { + if (isLoading || !settings || !jsonSchema) { return (
Error connecting to API
-{`${error || jsonSchemaError}`}
+{`${settingsError || jsonSchemaError}`}
diff --git a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx index 3d34d908c1b..496ba9cb95c 100644 --- a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx @@ -10,8 +10,8 @@ import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { Loading } from 'components/ui/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' -import { useProjectApiQuery } from 'data/config/project-api-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -32,12 +32,11 @@ const GraphiQLPage: NextPageWithLayout = () => { const pgGraphqlExtension = (data ?? []).find((ext) => ext.name === 'pg_graphql') const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) - const { data: settings, isFetched } = useProjectApiQuery({ projectRef }) + const { data: settings, isFetched } = useProjectSettingsV2Query({ projectRef }) - const apiService = settings?.autoApiService - const serviceRoleKey = apiService?.service_api_keys.find((x) => x.name === 'service_role key') - ? apiService.serviceApiKey - : undefined + const serviceRoleKey = (settings?.service_api_keys ?? []).find( + (x) => x.name === 'service_role key' + )?.api_key const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const jwtSecret = config?.jwt_secret From f521109ce276901d14322a027b6a6b3c8069d943 Mon Sep 17 00:00:00 2001 From: Joshen LimBuilding docs ...
@@ -109,11 +105,9 @@ const DocView = () => { @@ -128,7 +122,6 @@ const DocView = () => { /> ) : rpc ? ({ refreshDocs={refreshDocs} /> ) : ( - + )} Date: Wed, 23 Oct 2024 16:43:38 +0800 Subject: [PATCH 28/80] Chore/database functions v2 (#29969) * Init AiAssistantPanel * Temp * Temp * iterating on assistant ux * More spiking * Tweaking... * Moar tweaking.. * Even more tweaking * Tweek * More tweaks * Final tweaking... * TWEAK * Spelling * Fix * Assistant error handling and small bug fix in context for message * Add feature flag * Fix * Address all feedback * Minor --- .../FeaturePreview/APISidePanelPreview.tsx | 4 +- .../App/FeaturePreview/CLSPreview.tsx | 4 +- .../FeaturePreview/FeaturePreviewContext.tsx | 55 +- .../FeaturePreview/FeaturePreviewModal.tsx | 46 +- .../FunctionsAssistantPreview.tsx | 54 ++ .../AIPolicyEditorPanel/RLSCodeEditor.tsx | 3 - .../PolicyTableRow/PolicyTableRowHeader.tsx | 2 +- .../Functions/FunctionsList/FunctionsList.tsx | 65 +- .../Database/Indexes/CreateIndexSidePanel.tsx | 2 +- .../RealtimeFilterPopover/TableSelector.tsx | 2 +- .../AiAssistantPanel/AiMessagePre.tsx | 2 +- .../SQLEditor/AiAssistantPanel/Message.tsx | 2 +- .../interfaces/TableGridEditor/EmptyState.tsx | 2 +- .../TableEditorLayout/TableEditorMenu.tsx | 2 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 696 ++++++++++++++++++ .../ui/AIAssistantPanel/AIAssistant.types.ts | 2 + .../ui/AIAssistantPanel/AIAssistant.utils.ts | 125 ++++ .../ui/AIAssistantPanel/AIAssistantPanel.tsx | 439 +++++++++++ .../AIAssistantPanel/AiAssistant.constants.ts | 10 + .../ui/AIAssistantPanel/ContextBadge.tsx | 33 + .../AIAssistantPanel/DropdownScrollArea.tsx | 36 + .../AIAssistantPanel/EntitiesDropdownMenu.tsx | 110 +++ .../ui/AIAssistantPanel/Message.tsx | 146 ++++ .../ui/AIAssistantPanel/MessagePre.tsx | 113 +++ .../AIAssistantPanel/SchemasDropdownMenu.tsx | 61 ++ .../components/ui/CodeEditor/CodeEditor.tsx | 215 ++++-- apps/studio/components/ui/SchemaComboBox.tsx | 3 +- .../data/database/entity-definitions-query.ts | 10 +- .../entity-types-infinite-query.ts | 21 +- apps/studio/data/entity-types/keys.ts | 2 +- apps/studio/lib/constants/index.ts | 3 + apps/studio/pages/_app.tsx | 2 + apps/studio/pages/api/ai/sql/generate-v2.ts | 6 +- .../project/[ref]/logs/explorer/index.tsx | 4 +- .../img/previews/functions-assistant.png | Bin 0 -> 275225 bytes apps/studio/state/app-state.ts | 12 + packages/ai-commands/src/sql/chat.ts | 44 +- .../AssistantChat/AssistantChatForm.tsx | 7 +- packages/ui-patterns/admonition.tsx | 4 +- 39 files changed, 2182 insertions(+), 167 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/Message.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx create mode 100644 apps/studio/public/img/previews/functions-assistant.png diff --git a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx index 5e96abfb824..9a4bde83334 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx @@ -3,7 +3,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import Image from 'next/image' -const APISidePanelPreview = () => { +export const APISidePanelPreview = () => { const { ref } = useParams() return ( @@ -38,5 +38,3 @@ const APISidePanelPreview = () => { , + discussionsUrl: undefined, + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, + name: 'Project API documentation', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', + isNew: false, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, + name: 'Column-level privileges', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', + isNew: false, + }, +] type FeaturePreviewContextType = { flags: { [key: string]: boolean } @@ -17,22 +44,19 @@ const FeaturePreviewContext = createContext ({ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [flags, setFlags] = useState({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, - }) + const [flags, setFlags] = useState(() => + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: false } + }, {}) + ) useEffect(() => { if (typeof window !== 'undefined') { - setFlags({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', - }) + setFlags( + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' } + }, {}) + ) } }, []) @@ -61,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsDatabaseFunctionsAssistantEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index de6979679e8..01cbee09063 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -3,38 +3,23 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { Button, Modal, ScrollArea, cn } from 'ui' -import APISidePanelPreview from './APISidePanelPreview' -import CLSPreview from './CLSPreview' -import { useFeaturePreviewContext } from './FeaturePreviewContext' +import { Badge, Button, Modal, ScrollArea, cn } from 'ui' +import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext' +import { useFlag } from 'hooks/ui/useFlag' const FeaturePreviewModal = () => { - // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. - const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, - name: 'Project API documentation', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', - }, - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, - name: 'Column-level privileges', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', - }, - ] - const snap = useAppStateSnapshot() const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() + const enableFunctionsAssistant = useFlag('functionsAssistantV2') const selectedFeaturePreview = snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview const [selectedFeatureKey, setSelectedFeatureKey] = useState (selectedFeaturePreview) + const isNotReleased = + selectedFeatureKey === 'supabase-ui-functions-assistant' && !enableFunctionsAssistant // this modal can be triggered on other pages // Update local state when valtio state changes @@ -103,8 +88,11 @@ const FeaturePreviewModal = () => { {isLocked &&-- {!isLocked && ( -{selectedFeature?.name}
-+{selectedFeature?.content} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx new file mode 100644 index 00000000000..3b48059d6bd --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx @@ -0,0 +1,54 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import { detectOS } from 'lib/helpers' +import Image from 'next/image' + +export const FunctionsAssistantPreview = () => { + const os = detectOS() + const { ref } = useParams() + + return ( +++{selectedFeature?.name}
+ {selectedFeature?.isNew &&New } +{selectedFeature?.discussionsUrl !== undefined && ( }> @@ -112,9 +100,15 @@ const FeaturePreviewModal = () => { )} - + {isNotReleased ? ( + + ) : ( + + )}++ ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx index 1323ec5c305..e27f8699a4d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx @@ -68,9 +68,6 @@ const RLSCodeEditor = ({ lineNumbersMinChars: 4, folding: undefined, scrollBeyondLastLine: false, - padding: { - top: editView === 'conversation' ? 20 : 0, - }, } const onMount: OnMount = async (editor, monaco) => { diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 87b385513f5..fafc704b6ea 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -111,7 +111,7 @@ const PolicyTableRowHeader = ({+ We're providing an additional alternative UX to creating database functions through the + dashboard with the integration of our AI Assistant that you might have seen in the Auth + Policies section. +
++ + This preview also shares an improved Assistant interface where you may provide the Assistant + with contexts in hopes to generate more relevant and higher quality outputs. Contexts that + you may provide include specific schemas, and / or specific tables from your database. +
++ We'd hope to use this as a consistent pattern throughout the dashboard eventually if this + feature preview proves itself to benefit most of our users, so as usual please do feel free + to let us know what you think if the attached GitHub discussion above! +
+++Enabling this preview will:
++
+- +
++ - +
++ - + {!canToggleRLS ? 'You need additional permissions to create RLS policies' : 'Create with Supabase Assistant'} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 759a4dac823..65f4bf35f2c 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -17,9 +17,18 @@ import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import { Input } from 'ui' +import { + AiIconAnimation, + Button, + Input, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' import ProtectedSchemaWarning from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' +import { useAppStateSnapshot } from 'state/app-state' +import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' interface FunctionsListProps { createFunction: () => void @@ -32,10 +41,13 @@ const FunctionsList = ({ editFunction = noop, deleteFunction = noop, }: FunctionsListProps) => { - const { project } = useProjectContext() const router = useRouter() const { search } = useParams() + const { project } = useProjectContext() + const { setAiAssistantPanel } = useAppStateSnapshot() + const enableFunctionsAssistant = useIsDatabaseFunctionsAssistantEnabled() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() + const filterString = search ?? '' const setFilterString = (str: string) => { @@ -122,20 +134,41 @@ const FunctionsList = ({ /> createFunction()} - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to create functions', - }, - }} - > - Create a new function - - )} ++ {!isLocked && ( +createFunction()} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to create functions', + }, + }} + > + Create a new function + + )} + {enableFunctionsAssistant && ( ++ + )} ++ + } + onClick={() => setAiAssistantPanel({ open: true, editor: 'functions' })} + /> + ++ {!canCreateFunctions + ? 'You need additional permissions to create functions' + : 'Create with Supabase Assistant'} + +} diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index 9faecac311e..983bb4f4283 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -61,7 +61,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = connectionString: project?.connectionString, }) const { data: entities, isLoading: isLoadingEntities } = useEntityTypesQuery({ - schema: selectedSchema, + schemas: [selectedSchema], sort: 'alphabetical', search: searchTerm, projectRef: project?.ref, diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx index 921598b6d12..4161433b7b8 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx @@ -47,7 +47,7 @@ const TableSelector = ({ projectRef: project?.ref, search: searchInput, connectionString: project?.connectionString, - schema: selectedSchemaName, + schemas: [selectedSchemaName], }) useEffect(() => { if (!initiallyLoaded && isSuccess) { diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx index fa82c7a654f..8b2003a699a 100644 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx @@ -54,7 +54,7 @@ export const AiMessagePre = ({ onDiff, children, className }: AiMessagePreProps) '!bg-transparent !py-3 !px-3.5 prose dark:prose-dark', // change the look of the code block. The flex hack is so that the code is wrapping since // every word is a separate span - '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap' + '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block' )} hideCopy hideLineNumbers diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx index edd29cd1987..e7bbd9dd31c 100644 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx @@ -30,7 +30,7 @@ const Message = memo(function Message({ isSelected = false, onDiff = noop, children, - action = <>>, + action = null, }: PropsWithChildren ) { const { profile } = useProfile() diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index bd8f0a90362..07a77e633d8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -27,7 +27,7 @@ const EmptyState = ({}: EmptyStateProps) => { const { data } = useEntityTypesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], sort, }) diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 1e985ecf34d..c859e571e41 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -67,7 +67,7 @@ const TableEditorMenu = () => { { projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], search: searchText.trim() || undefined, sort, filterTypes: visibleTypes, diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx new file mode 100644 index 00000000000..90a008b41b0 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -0,0 +1,696 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { AnimatePresence, motion } from 'framer-motion' +import { last } from 'lodash' +import { ExternalLink, FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +import type { Message as MessageType } from 'ai/react' +import { useChat } from 'ai/react' +import { useParams } from 'common' +import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' +import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' +import { + AiIconAnimation, + Alert_Shadcn_, + AlertDescription_Shadcn_, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + SheetHeader, + SheetSection, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition, AssistantChatForm } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' +import { generatePrompt, retrieveDocsUrl } from './AIAssistant.utils' +import { ContextBadge } from './ContextBadge' +import { EntitiesDropdownMenu } from './EntitiesDropdownMenu' +import { Message } from './Message' +import { SchemasDropdownMenu } from './SchemasDropdownMenu' + +const ANIMATION_DURATION = 0.3 + +interface AIAssistantProps { + id: string + className?: string + debugThread: MessageWithDebug[] + onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void + onResetConversation: () => void +} + +// [Joshen] For some reason I'm having issues working with dropdown menu and scroll area + +export const AIAssistant = ({ + id, + className, + debugThread, + onDiff, + onResetConversation, +}: AIAssistantProps) => { + const { ref } = useParams() + const project = useSelectedProject() + const isOptedInToAI = useOrgOptedIntoAi() + const selectedOrganization = useSelectedOrganization() + const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + + const { aiAssistantPanel } = useAppStateSnapshot() + const { editor } = aiAssistantPanel + + const inputRef = useRef (null) + const bottomRef = useRef (null) + + const [value, setValue] = useState ('') + const [selectedDatabaseEntity, setSelectedDatabaseEntity] = useState< + SupportedAssistantEntities | '' + >('') + const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) + const [selectedTables, setSelectedTables] = useState<{ schema: string; name: string }[]>([]) + const [contextHistory, setContextHistory] = useState<{ + [key: string]: { entity: string; schemas: string[]; tables: string[] } + }>({}) + // [Joshen] Mainly for error handling on useChat - cause last sent messages will be voided + const [assistantError, setAssistantError] = useState () + const [lastSentMessage, setLastSentMessage] = useState () + const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) + + const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities) + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity) + const noContextAdded = + selectedDatabaseEntity.length === 0 && + selectedSchemas.length === 0 && + selectedTables.length === 0 + + const { data } = useEntityDefinitionsQuery( + { + schemas: selectedSchemas, + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: includeSchemaMetadata } + ) + + const entityDefinitions = includeSchemaMetadata + ? selectedTables.length === 0 + ? data?.map((def) => def.sql.trim()) + : data + ?.filter((def) => { + return selectedTables.some((table) => { + return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`) + }) + }) + .map((def) => def.sql.trim()) + : undefined + + const { + messages: chatMessages, + isLoading: isChatLoading, + append, + } = useChat({ + id, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + body: { entityDefinitions, context: selectedDatabaseEntity }, + onError: (error) => setAssistantError(JSON.parse(error.message).error), + }) + + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const { isLoading: isDebugSqlLoading } = useSqlDebugMutation() + const isLoading = isChatLoading || isDebugSqlLoading + + const messages = useMemo(() => { + const merged = [ + ...debugThread, + ...chatMessages.map((m) => ({ ...m, isDebug: false })), + ...(assistantError !== undefined && lastSentMessage !== undefined ? [lastSentMessage] : []), + ] + + return merged.sort( + (a, b) => + (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || + a.role.localeCompare(b.role) + ) + }, [chatMessages, debugThread, assistantError, lastSentMessage]) + + const hasMessages = messages.length > 0 + const lastMessage = last(messages) + const pendingChatReply = isLoading && lastMessage?.role === 'user' + const pendingDebugReply = + (lastMessage as MessageWithDebug)?.isDebug && + lastMessage?.role === 'assistant' && + lastMessage.content === 'Thinking...' + const pendingReply = pendingChatReply || pendingDebugReply + + const sendMessageToAssistant = (content: string) => { + const payload = { role: 'user', createdAt: new Date(), content } as MessageType + append(payload) + setAssistantError(undefined) + setLastSentMessage(payload) + } + + const toggleSchema = (schema: string) => { + if (selectedSchemas.includes(schema)) { + setSelectedSchemas(selectedSchemas.filter((s) => s !== schema)) + } else { + const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b)) + setSelectedSchemas(newSelectedSchemas) + } + } + + const toggleEntity = ({ schema, name }: { schema: string; name: string }) => { + const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name) + if (isExisting) { + setSelectedTables(selectedTables.filter((x) => !(x.schema === schema && x.name === name))) + } else { + const newselectedTables = [...selectedTables, { schema, name }].sort( + (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) + ) + setSelectedTables(newselectedTables) + } + } + + const onClickQuickPrompt = (type: SupportedAssistantQuickPromptTypes) => { + const prompt = generatePrompt({ + type, + context: selectedDatabaseEntity as any, + schemas: selectedSchemas, + tables: selectedTables, + }) + if (prompt) { + setValue(prompt) + sendMessageToAssistant(prompt) + } + } + + const confirmOptInToShareSchemaData = async () => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + + if (!selectedOrganization?.slug) return console.error('Organization slug is required') + + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + + const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) + ? existingOptInTags + : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] + + updateOrganization( + { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + toast.success('Successfully opted-in') + setIsConfirmOptInModalOpen(false) + }, + } + ) + } + + useEffect(() => { + if (editor) { + const mode = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + if (mode) setSelectedDatabaseEntity(mode.id) + } + }, [editor]) + + useEffect(() => { + if (!isLoading) { + setValue('') + if (inputRef.current) inputRef.current.focus() + } + + setTimeout( + () => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, + isLoading ? 100 : 500 + ) + }, [isLoading]) + + useEffect(() => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + useEffect(() => { + const lastMessage = last(messages) + if (lastMessage?.role === 'user') { + const entity = entityContext?.label ?? selectedDatabaseEntity + setContextHistory({ + ...contextHistory, + [lastMessage.id]: { + entity, + schemas: selectedSchemas, + tables: selectedTables.map((x) => + selectedSchemas.length > 1 ? `${x.schema}.${x.name}` : x.name + ), + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + return ( + <> + +++ + ++++ Assistant
++ {hasMessages && ( + ++ + + )} ++ {hasMessages && ( + ++ {messages.map((m, index) => { + const isFirstUserMessage = + m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') + + return ( + + )} + +onDiff({ id: m.id, diffType, sql })} + > + {isFirstUserMessage && !includeSchemaMetadata && ( + + ) + })} + {assistantError !== undefined && ( ++ + + )} + {isFirstUserMessage && + includeSchemaMetadata && + selectedSchemas.length === 0 && ( ++ )} + + + + )} + {!isLoading && !pendingReply && assistantError === undefined && ( ++ Please verify all responses as the Assistant can make mistakes +
+ )} + {pendingChatReply && ( ++ )} + + +++ {!hasMessages && ( + ++ + )} ++ How can I help you + {!!entityContext ? ( + <> + {' '} + with{' '} + + {entityContext.id === 'rls-policies' + ? entityContext.label + : `Database ${entityContext.label}`} + + > + ) : ( + ' today' + )} + ? +
+++++++ ++ + + {!!entityContext && ( ++ ++ ++ } + className={noContextAdded ? '' : 'px-1.5 !space-x-0'} + > + Add context + + ++ Add context for the assistant + ++ ++ Improve the output quality of the assistant by giving it context about what + you need help with + ++ {editor === null && ( + + + )} + {includeSchemaMetadata && ( + <> ++ +++Database Entity
++ Inform about what you're working with +
++ ++ setSelectedDatabaseEntity(value as SupportedAssistantEntities) + } + > + {ASSISTANT_SUPPORT_ENTITIES.map((x) => ( + ++ {x.label} + + ))} ++ + {selectedSchemas.length > 0 && ( ++ +++Schemas
++ Share table definitions in the selected schemas +
++ ++ + + )} + > + )} ++ +++Tables
++ Select specific tables to share definitions for +
++ ++ setSelectedDatabaseEntity('') : undefined} + /> + )} + + {selectedSchemas.length > 0 && ( + 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => { + setSelectedSchemas([]) + setSelectedTables([]) + }} + tooltip={ + selectedSchemas.length > 2 ? ( + <> + + {selectedSchemas.length} schemas selected: +
++ {selectedSchemas.map((x) => ( +
+ > + ) : undefined + } + /> + )} + + {selectedTables.length > 0 && ( +- {x}
+ ))} +x.name) + .join( + ', ' + )}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => setSelectedTables([])} + tooltip={ + selectedTables.length > 2 ? ( + <> + + {selectedTables.length} tables selected: +
++ {selectedTables.map((x) => ( +
+ > + ) : undefined + } + /> + )} +- + {x.schema}.{x.name} +
+ ))} +textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0' + )} + loading={isLoading} + disabled={isLoading} + placeholder={ + hasMessages ? 'Reply to the assistant...' : 'How can we help you today?' + } + value={value} + onValueChange={(e) => setValue(e.target.value)} + onSubmit={(event) => { + event.preventDefault() + sendMessageToAssistant(value) + }} + /> + + {!hasMessages && ( + ++ + )} ++++ ++ } + onClick={() => onClickQuickPrompt('suggest')} + > + Suggest + + ++ Suggest some{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + ++ ++ } + onClick={() => onClickQuickPrompt('examples')} + > + Examples + + ++ Provide some examples of{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + ++ + {docsUrl !== undefined && ( + }> + + Documentation + + + )} ++ } + onClick={() => onClickQuickPrompt('ask')} + > + Ask + + ++ What are{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + ? + +setIsConfirmOptInModalOpen(false)} + onConfirm={confirmOptInToShareSchemaData} + loading={isUpdating} + > + + > + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts new file mode 100644 index 00000000000..38fb580b306 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -0,0 +1,2 @@ +export type SupportedAssistantEntities = 'rls-policies' | 'functions' +export type SupportedAssistantQuickPromptTypes = 'suggest' | 'examples' | 'ask' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts new file mode 100644 index 00000000000..b0b28d12550 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -0,0 +1,125 @@ +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' + +const PLACEHOLDER_PREFIX = `-- Press tab to use this code +\n \n` + +const PLACEHOLDER_LIMIT = `Just three examples will do.` + +export const generateTitle = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'Create a new function' + case 'rls-policies': + return 'Create a new RLS policy' + default: + return 'SQL Scratch Pad' + } +} + +export const generateCTA = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'Save function' + case 'rls-policies': + return 'Save policy' + default: + return 'Run query' + } +} + +export const generatePlaceholder = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return `${PLACEHOLDER_PREFIX} +CREATE FUNCTION *schema*.*function_name*(*param1 type*, *param2 type*)\n + RETURNS *return_type*\n + LANGUAGE *plpgsql*\n + SECURITY DEFINER\n + SET *search_path = ''*\n +AS $$\n +DECLARE\n + *-- Variable declarations*\n +BEGIN\n + *-- Function logic*\n +END;\n +$$; +` + case 'rls-policies': + return `${PLACEHOLDER_PREFIX} +CREATE POLICY *name* ON *table_name*\n +AS PERMISSIVE -- PERMISSIVE | RESTRICTIVE\n +FOR ALL -- ALL | SELECT | INSERT | UPDATE | DELETE\n +TO *role_name* -- Default: public\n +USING ( *using_expression* )\n +WITH CHECK ( *check_expression* ); +` + default: + return undefined + } +} + +export const retrieveDocsUrl = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'https://supabase.com/docs/guides/database/functions' + case 'rls-policies': + return 'https://supabase.com/docs/guides/database/postgres/row-level-security' + default: + return undefined + } +} + +// [Joshen] This is just very basic validation, but possible can extend perhaps +export const validateQuery = (editor: SupportedAssistantEntities | null, query: string) => { + const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') + + switch (editor) { + case 'functions': + return ( + formattedQuery.includes('create function') || + formattedQuery.includes('create or replace function') + ) + case 'rls-policies': + return formattedQuery.includes('create policy') + default: + return true + } +} + +export const generatePrompt = ({ + type, + context, + schemas, + tables, +}: { + type: SupportedAssistantQuickPromptTypes + context: SupportedAssistantEntities + schemas: string[] + tables: { schema: string; name: string }[] +}) => { + if (type === 'examples') { + return `What are some common examples of user-defined database ${context}? ${PLACEHOLDER_LIMIT}` + } else if (type === 'ask') { + return `Could you explain to me what are used-defined database ${context}?` + } else if (type === 'suggest') { + const output = + context === 'functions' + ? 'user-defined database functions' + : context === 'rls-policies' + ? 'RLS policies' + : '' + + const suffix = + context === 'functions' ? 'Let me know for which tables each function will be useful' : '' + + const basePrompt = `Suggest some ${output} that might be useful` + + if (tables.length > 0 && schemas.length > 0) { + return `${basePrompt} for the following tables within this database: ${tables.map((x) => `${x.schema}.${x.name}`)}. ${PLACEHOLDER_LIMIT} ${suffix}`.trim() + } else if (schemas.length > 0) { + return `${basePrompt} for the tables in the following schemas within this database: ${schemas.join(', ')}. ${suffix}`.trim() + } + + return basePrompt + } +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx new file mode 100644 index 00000000000..c2d0379154a --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -0,0 +1,439 @@ +import { useQueryClient } from '@tanstack/react-query' +import { uniqBy } from 'lodash' +import { Command, CornerDownLeft, Loader2, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { useChat } from 'ai/react' +import { useParams } from 'common' +import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants' +import { DiffType, IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { sqlKeys } from 'data/sql/keys' +import { usePrevious } from 'hooks/deprecated' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { SqlEditor } from 'icons' +import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants' +import { detectOS, uuidv4 } from 'lib/helpers' +import { useAppStateSnapshot } from 'state/app-state' +import { + Button, + cn, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import CodeEditor from '../CodeEditor/CodeEditor' +import { AIAssistant } from './AIAssistant' +import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' + +export const AiAssistantPanel = () => { + const os = detectOS() + const router = useRouter() + const { ref } = useParams() + const project = useSelectedProject() + const queryClient = useQueryClient() + const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + + const { open, editor, content } = aiAssistantPanel + const previousEditor = usePrevious(editor) + + const [isAcknowledged, setIsAcknowledged] = useLocalStorage( + LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, + false + ) + + const [chatId, setChatId] = useState(() => uuidv4()) + const [error, setError] = useState+ By opting into sending anonymous data, Supabase AI can improve the answers it shows you. + This is an organization-wide setting, and affects all projects in your organization. +
+ ++ () + const [results, setResults] = useState (undefined) + const [showResults, setShowResults] = useState(false) + const [showWarning, setShowWarning] = useState (false) + const [debugThread, setDebugThread] = useState ([]) + + // [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able + // I don't really see any negatives of keeping it open (or benefits from hiding) tbh + // const [showAssistant, setShowAssistant] = useState(true) + const title = generateTitle(editor) + const placeholder = generatePlaceholder(editor) + const ctaText = generateCTA(editor) + const editorRef = useRef () + const editorModel = editorRef.current?.getModel() + + const numResults = (results ?? []).length + const [errorHeader, ...errorContent] = + (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + + const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async (res) => { + // [Joshen] If in a specific editor context mode, assume that intent was to create/update + // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we + // show the results. Currently though it assumes we're "creating", thinking need to support "updating" too + if (editor !== null) { + switch (editor) { + case 'functions': + await queryClient.invalidateQueries(sqlKeys.query(ref, ['functions-list'])) + break + case 'rls-policies': + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + break + } + + toast.success(`Successfully created ${entityContext?.name}!`) + setAiAssistantPanel({ open: false }) + } else { + setShowResults(true) + setResults(res.result) + } + }, + onError: (error) => { + setError(error) + setResults([]) + }, + }) + + const { append } = useChat({ + id: chatId, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + // [Joshen] Don't need entity definitions if calling here cause support action + // via AI here is just to explain code segment, no need for context + body: {}, + }) + + const { mutateAsync: debugSql } = useSqlDebugMutation({ onError: () => {} }) + + const onExecuteSql = (skipValidation = false) => { + setError(undefined) + setShowWarning(false) + + const query = editorRef.current?.getValue() ?? '' + if (query.length === 0) return + + if (editor !== undefined && !skipValidation) { + // [Joshen] Some basic validation logic + const validated = validateQuery(editor, query) + if (!validated) { + return setShowWarning(true) + } + } + + executeSql({ + sql: suffixWithLimit(query, 100), + projectRef: project?.ref, + connectionString: project?.connectionString, + handleError: (error) => { + throw error + }, + }) + } + + const onFixWithAssistant = async () => { + if (!error) return + + const messageId = uuidv4() + const query = editorRef.current?.getValue() ?? '' + + const assistantMessageBefore = generateThreadMessage({ + id: messageId, + content: 'Thinking...', + isDebug: true, + }) + + setDebugThread([...debugThread, assistantMessageBefore]) + + try { + const { solution, sql } = await debugSql({ + sql: query, + errorMessage: error.message, + }) + + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } catch (error) { + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `Hmm, Sorry but I'm unable to find a solution for the error that you're facing`, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } + } + + const onExplainSql = (value: string) => { + append({ + role: 'user', + createdAt: new Date(), + content: ` + Can you explain this section to me in more detail?\n + ${value} + `.trim(), + }) + } + + const updateEditorWithCheckForDiff = useCallback( + ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => { + if (!editorModel) return + + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + // if the editor is empty, just copy over the code + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + const currentSql = editorRef.current?.getValue() + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${currentSql}\n\n${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } + }, + [editorModel] + ) + + useEffect(() => { + if (open) { + // [Joshen] Only reset the assistant if the editor changed + if (previousEditor !== editor) { + setChatId(uuidv4()) + setError(undefined) + setShowWarning(false) + setDebugThread([]) + setResults(undefined) + setShowResults(false) + } else { + // [Joshen] Repopulate the code editor with the content from where the user left off + // setTimeout is just to give time for the editorRef to get associated + setTimeout(() => { + const editorModel = editorRef.current?.getModel() + if (content && editorModel) { + editorRef.current?.executeEdits('apply-ai-message', [ + { text: content, range: editorModel.getFullModelRange() }, + ]) + } + }, 100) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // [Joshen] Just to test the concept of a universal assistant of sorts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.code === 'KeyI') { + setAiAssistantPanel({ open: true, editor: null }) + } + } + if (project !== undefined) window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project]) + + // [Joshen] Whenever the deps change recalculate the height of the editor + useEffect(() => { + editorRef.current?.layout({ width: 0, height: 0 }) + window.requestAnimationFrame(() => editorRef.current?.layout()) + }, [error, showWarning, isExecuting, numResults, showResults]) + + return ( + { + if (open) { + // [Joshen] Save the code content when closing - should allow users to continue from + // where they left off IF they are opening the assistant again in the same "editor" context + const existingValue = editorRef.current?.getValue() ?? '' + setAiAssistantPanel({ open: false, content: existingValue }) + } else { + setAiAssistantPanel({ open: true }) + } + }} + > + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts new file mode 100644 index 00000000000..e9bc5bdeb55 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts @@ -0,0 +1,10 @@ +import { SupportedAssistantEntities } from './AIAssistant.types' + +export const ASSISTANT_SUPPORT_ENTITIES: { + id: SupportedAssistantEntities + label: string + name: string +}[] = [ + { id: 'rls-policies', label: 'RLS Policies', name: 'RLS policy' }, + { id: 'functions', label: 'Functions', name: 'database function' }, +] diff --git a/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx new file mode 100644 index 00000000000..ba205b176e0 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx @@ -0,0 +1,33 @@ +import { X } from 'lucide-react' +import { ReactNode } from 'react' +import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' + +interface ContextBadgeProps { + label: string + value: string + tooltip?: ReactNode + onRemove?: () => void +} + +export const ContextBadge = ({ label, value, tooltip, onRemove }: ContextBadgeProps) => { + return ( ++ {/* Assistant */} + +setChatId(uuidv4())} + /> + + {/* Editor */} + +++ {title} + + {editor === null && !isAcknowledged && ( ++ ++ } + className="px-1" + onClick={() => { + const content = editorRef.current?.getValue() ?? '' + router.push(`/project/${ref}/sql/new?content=${encodeURIComponent(content)}`) + setAiAssistantPanel({ open: false }) + }} + /> + +Open in SQL Editor ++ + Queries written here will not be saved and results are limited up to only 100 rows + + } + className="px-1.5 absolute top-2 right-2" + onClick={() => setIsAcknowledged(true)} + /> + + )} +++++onExecuteSql() }, + explainCode: { enabled: true, callback: onExplainSql }, + }} + /> + + {error !== undefined && ( +++ + {errorContent.length > 0 ? ( + errorContent.map((errorText: string, i: number) => ( ++ + > + } + /> + )} + {showWarning && ( ++ {errorText} ++ )) + ) : ( +{error.error}
+ )} ++ Are you sure you want to run this query?
+ + > + } + /> + )} + {results !== undefined && results.length > 0 && ( + <> ++++ ++ > + )} + {results !== undefined && results.length === 0 && ( ++ {results.length} rows + {results.length >= 100 && ` (Limited to only 100 rows)`} +
+ +++ )} +Success. No rows returned.
++ + + ++ + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx new file mode 100644 index 00000000000..1bbe09c0190 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren, useEffect } from 'react' +import { ScrollArea } from 'ui' + +interface DropdownScrollAreaProps { + id?: string + className?: string +} + +// [Joshen] This is a temporary solution to a really odd behavior i've observed with the components in Shadcn +// Specifically if we're using a ScrollArea (or even just anything with overflow) within a DropdownMenu, within a Sheet +// The mouse wheel event to scroll in the ScrollArea is somehow getting ignored in this specific context, hence the fix here + +export const DropdownScrollArea = ({ + id, + className, + children, +}: PropsWithChildren+ + {tooltip !== undefined &&+++ {label} + {value} ++ {onRemove !== undefined && ( +onRemove()} + /> + )} + {tooltip} } +) => { + useEffect(() => { + const scrollFix = (e: WheelEvent) => e.stopPropagation() + + if (id) { + const el = document.getElementById(id) + if (el) { + el.addEventListener('wheel', scrollFix, { passive: false }) + return () => el.removeEventListener('wheel', scrollFix) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx new file mode 100644 index 00000000000..d301fb5814e --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx @@ -0,0 +1,110 @@ +import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { debounce } from 'lodash' +import { Check, Loader2, Search } from 'lucide-react' +import { useCallback, useState } from 'react' +import { + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + ScrollArea, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Admonition } from 'ui-patterns' + +interface EntitiesDropdownMenuProps { + selectedSchemas: string[] + selectedEntities: { schema: string; name: string }[] + onToggleEntity: (value: { schema: string; name: string }) => void +} + +export const EntitiesDropdownMenu = ({ + selectedSchemas, + selectedEntities, + onToggleEntity, +}: EntitiesDropdownMenuProps) => { + const project = useSelectedProject() + const [search, setSearch] = useState('') + + // [Joshen] Not gonna do infinite loading here, but rather just try to load sufficient tables + // I reckon users will opt to just search if there's many tables here given the small real estate + const { + data: entitiesData, + error, + isLoading, + isFetching, + isError, + } = useEntityTypesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: selectedSchemas, + limit: 100, + search: search.length === 0 ? undefined : search, + }) + const entities = ( + entitiesData?.pages[0].data.entities ? [...entitiesData?.pages[0].data.entities] : [] + ).sort((a, b) => (a.name > b.name ? 0 : -1)) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const searchInputHandler = useCallback(debounce(setSearch, 1000), []) + + if (isError) { + return ( ++ ) + } + + return ( + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx new file mode 100644 index 00000000000..3d20ff3c6c6 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -0,0 +1,146 @@ +import dayjs from 'dayjs' +import { noop } from 'lodash' +import Image from 'next/image' +import { PropsWithChildren, memo, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { AiIconAnimation, Badge, cn, markdownComponents, WarningIcon } from 'ui' + +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useProfile } from 'lib/profile' +import { MessagePre } from './MessagePre' + +interface MessageProps { + name?: string + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' + content?: string + createdAt?: number + isDebug?: boolean + isSelected?: boolean + action?: React.ReactNode + context?: { entity: string; schemas: string[]; tables: string[] } + onDiff?: (type: DiffType, s: string) => void + variant?: 'default' | 'warning' +} + +export const Message = memo(function Message({ + name, + role, + content, + createdAt, + isDebug, + isSelected = false, + context, + children, + action = null, + variant = 'default', + onDiff = noop, +}: PropsWithChildren+ {/* Separate input for async searching */} + } + className="text-xs outline-none rounded-b-none border-0 border-b pl-9 pr-7" + onChange={(e) => searchInputHandler(e.target.value)} + actions={ + (isLoading || isFetching) && ( + ++ ) + } + /> + No tables found ++ +7 ? 'h-[210px]' : ''} + > + {entities?.map((entity) => ( + +onToggleEntity({ schema: entity.schema, name: entity.name })} + onClick={() => onToggleEntity({ schema: entity.schema, name: entity.name })} + > + + {entity.schema} + {entity.name} + + {selectedEntities.find( + (x) => x.name === entity.name && x.schema === entity.schema + ) && + ))} +} + ) { + const { profile } = useProfile() + const isUser = role === 'user' + + const icon = useMemo(() => { + return role === 'assistant' ? ( + + ) : ( + ++ ) + }, [content, profile?.username, role]) + + const formattedContext = + context !== undefined + ? Object.entries(context) + .filter(([_, value]) => value.length > 0) + .map(([key, value]) => { + return `${key.charAt(0).toUpperCase() + key.slice(1)}: ${Array.isArray(value) ? value.join(', ') : value}` + }) + .join(' • ') + : undefined + + if (!content) return null + + return ( ++ ++ ) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx new file mode 100644 index 00000000000..414b5fc0917 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx @@ -0,0 +1,113 @@ +import { InsertCode, ReplaceCode } from 'icons' +import { Check, Copy } from 'lucide-react' +import { useEffect, useState } from 'react' +import { format } from 'sql-formatter' +import { + Button, + CodeBlock, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, + cn, +} from 'ui' + +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' + +interface MessagePreProps { + onDiff: (type: DiffType, s: string) => void + children: string[] + className?: string +} + +export const MessagePre = ({ onDiff, children, className }: MessagePreProps) => { + const [copied, setCopied] = useState(false) + + const { mutate: sendEvent } = useSendEventMutation() + + useEffect(() => { + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + }, [copied]) + + let formatted = (children || [''])[0] + try { + formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) + } catch {} + + if (formatted.length === 0) { + return null + } + + function handleCopy(formatted: string) { + navigator.clipboard.writeText(formatted).then() + setCopied(true) + } + + return ( +++ ++ {variant === 'warning' ?{' '} + {action} +: icon} + + ++ + {isDebug &&+ {!isUser ? 'Assistant' : name ? name : 'You'} + {createdAt && ( + + {dayjs(createdAt).fromNow()} + + )} ++ {role === 'user' && context !== undefined && ( + {formattedContext} + )} +Debug request } +{ + return ( + + {children} +div>pre]:max-w-full', + isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' + )} + > + {props.children[0].props.children} + + ) + }, + ol: (props: any) => { + return{props.children}
+ }, + li: (props: any) => { + return{props.children} + }, + h3: (props: any) => { + return{props.children}
+ }, + code: (props: any) => { + return{props.children}+ }, + }} + > + {content} +++ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx new file mode 100644 index 00000000000..6db2472077c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx @@ -0,0 +1,61 @@ +import { + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, +} from 'ui' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Check } from 'lucide-react' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' + +interface SchemasDropdownMenuProps { + selectedSchemas: string[] + onToggleSchema: (schema: string) => void +} + +export const SchemasDropdownMenu = ({ + selectedSchemas, + onToggleSchema, +}: SchemasDropdownMenuProps) => { + const project = useSelectedProject() + + const { data: schemaDatas } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const schemas = (schemaDatas || []).sort((a, b) => a.name.localeCompare(b.name)) + + return ( +code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block' + )} + hideCopy + hideLineNumbers + /> + +++ + ++ + ++ Insert code + ++ ++ + ++ Copy code + ++ + ) +} diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index 24eeb0d677d..62c08d73b6b 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -1,13 +1,23 @@ -import Editor, { EditorProps, Monaco, OnChange, OnMount } from '@monaco-editor/react' +import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react' import { merge, noop } from 'lodash' import { editor } from 'monaco-editor' -import { useEffect, useRef, useState } from 'react' +import { MutableRefObject, useEffect, useRef, useState } from 'react' import { Markdown } from 'components/interfaces/Markdown' import { timeout } from 'lib/helpers' import { cn } from 'ui' import { Loading } from '../Loading' import { alignEditor } from './CodeEditor.utils' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { formatQuery } from 'data/sql/format-sql-query' + +type CodeEditorActions = { enabled: boolean; callback: (value: any) => void } +const DEFAULT_ACTIONS = { + runQuery: { enabled: false, callback: noop }, + explainCode: { enabled: false, callback: noop }, + formatDocument: { enabled: true, callback: noop }, + placeholderFill: { enabled: true }, +} interface CodeEditorProps { id: string @@ -15,15 +25,21 @@ interface CodeEditorProps { autofocus?: boolean defaultValue?: string isReadOnly?: boolean - onInputChange?: (value?: string) => void - onInputRun?: (value: string) => void hideLineNumbers?: boolean className?: string loading?: boolean options?: EditorProps['options'] value?: string placeholder?: string - disableTabToUsePlaceholder?: boolean + /* Determines what actions to add for code editor context menu */ + actions?: Partial<{ + runQuery: CodeEditorActions + formatDocument: CodeEditorActions + placeholderFill: Omit+ + +No schemas found ++ +7 ? 'h-[210px]' : '')} + > + {schemas?.map((schema) => ( + +onToggleSchema(schema.name)} + onClick={() => onToggleSchema(schema.name)} + > + {schema.name} + {selectedSchemas.includes(schema.name) && ( + + ))} ++ )} + + explainCode: CodeEditorActions + }> + editorRef?: MutableRefObject + onInputChange?: (value?: string) => void } const CodeEditor = ({ @@ -33,83 +49,31 @@ const CodeEditor = ({ autofocus = true, isReadOnly = false, hideLineNumbers = false, - onInputChange = noop, - onInputRun = noop, className, loading, options, value, placeholder, - disableTabToUsePlaceholder = false, + actions = DEFAULT_ACTIONS, + editorRef: editorRefProps, + onInputChange = noop, }: CodeEditorProps) => { - const hasValue = useRef () + const monaco = useMonaco() + const project = useSelectedProject() - const editorRef = useRef () + const hasValue = useRef () + const ref = useRef () + const editorRef = editorRefProps || ref const monacoRef = useRef () + const { runQuery, placeholderFill, formatDocument, explainCode } = { + ...DEFAULT_ACTIONS, + ...actions, + } + const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0 const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault) - const onMount: OnMount = async (editor, monaco) => { - editorRef.current = editor - monacoRef.current = monaco - alignEditor(editor) - - hasValue.current = editor.createContextKey('hasValue', false) - hasValue.current.set(value !== undefined && value.trim().length > 0) - setShowPlaceholder(showPlaceholderDefault) - - if (!disableTabToUsePlaceholder) { - editor.addCommand( - monaco.KeyCode.Tab, - () => { - editor.executeEdits('source', [ - { - // @ts-ignore - identifier: 'add-placeholder', - range: new monaco.Range(1, 1, 1, 1), - text: (placeholder ?? '') - .split('\n\n') - .join('\n') - .replaceAll('*', '') - .replaceAll(' ', ''), - }, - ]) - }, - '!hasValue' - ) - } - - editor.addAction({ - id: 'supabase', - label: 'Run Query', - keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], - contextMenuGroupId: 'operation', - contextMenuOrder: 0, - run: async () => { - const selectedValue = (editorRef?.current as any) - .getModel() - .getValueInRange((editorRef?.current as any)?.getSelection()) - onInputRun(selectedValue || (editorRef?.current as any)?.getValue()) - }, - }) - - const model = editor.getModel() - if (model) { - const position = model.getPositionAt((value ?? '').length) - editor.setPosition(position) - } - - await timeout(500) - if (autofocus) editor?.focus() - } - - const onChangeContent: OnChange = (value) => { - hasValue.current.set((value ?? '').length > 0) - setShowPlaceholder(!value) - onInputChange(value) - } - const optionsMerged = merge( { tabSize: 2, @@ -128,13 +92,105 @@ const CodeEditor = ({ options ) + const formatPgsql = async (value: string) => { + try { + if (!project) throw new Error('No project') + const formatted = await formatQuery({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: value, + }) + return formatted.result + } catch (error) { + console.error('formatPgsql error:', error) + return value + } + } + + const onMount: OnMount = async (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco + alignEditor(editor) + + hasValue.current = editor.createContextKey('hasValue', false) + hasValue.current.set(value !== undefined && value.trim().length > 0) + setShowPlaceholder(showPlaceholderDefault) + + if (placeholderFill.enabled) { + editor.addCommand( + monaco.KeyCode.Tab, + () => { + editor.executeEdits('source', [ + { + // @ts-ignore + identifier: 'add-placeholder', + range: new monaco.Range(1, 1, 1, 1), + text: (placeholder ?? '') + .split('\n\n') + .join('\n') + .replaceAll('*', '') + .replaceAll(' ', ' '), + }, + ]) + }, + '!hasValue' + ) + } + + if (runQuery.enabled) { + editor.addAction({ + id: 'run-query', + label: 'Run Query', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + contextMenuGroupId: 'operation', + contextMenuOrder: 0, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + runQuery.callback(selectedValue || (editorRef?.current as any)?.getValue()) + }, + }) + } + + if (explainCode.enabled) { + editor.addAction({ + id: 'explain-code', + label: 'Explain Code', + contextMenuGroupId: 'operation', + contextMenuOrder: 1, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + explainCode.callback(selectedValue) + }, + }) + } + + const model = editor.getModel() + if (model) { + const position = model.getPositionAt((value ?? '').length) + editor.setPosition(position) + } + + await timeout(500) + if (autofocus) editor?.focus() + } + + const onChangeContent: OnChange = (value) => { + hasValue.current.set((value ?? '').length > 0) + setShowPlaceholder(!value) + onInputChange(value) + } + useEffect(() => { setShowPlaceholder(showPlaceholderDefault) }, [showPlaceholderDefault]) useEffect(() => { if ( - !disableTabToUsePlaceholder && + placeholderFill.enabled && editorRef.current !== undefined && monacoRef.current !== undefined ) { @@ -160,7 +216,22 @@ const CodeEditor = ({ '!hasValue' ) } - }, [placeholder, disableTabToUsePlaceholder]) + }, [placeholder, placeholderFill.enabled]) + + useEffect(() => { + if (monaco && project && formatDocument.enabled) { + const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { + async provideDocumentFormattingEdits(model: any) { + const value = model.getValue() + const formatted = await formatPgsql(value) + formatDocument.callback(formatted) + return [{ range: model.getFullModelRange(), text: formatted }] + }, + }) + return () => formatProvider.dispose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [monaco, project, formatDocument.enabled]) return ( <> diff --git a/apps/studio/components/ui/SchemaComboBox.tsx b/apps/studio/components/ui/SchemaComboBox.tsx index dc93562dc9c..f4810f6df7c 100644 --- a/apps/studio/components/ui/SchemaComboBox.tsx +++ b/apps/studio/components/ui/SchemaComboBox.tsx @@ -19,6 +19,7 @@ import { Popover_Shadcn_, ScrollArea, } from 'ui' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' interface SchemaComboBoxProps { className?: string @@ -44,7 +45,7 @@ export const SchemaComboBox = ({ }: SchemaComboBoxProps) => { const [open, setOpen] = useState(false) - const { project } = useProjectContext() + const project = useSelectedProject() const { data, isLoading: isSchemasLoading, diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index 0e820869fcf..e288f81fa0e 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -3,7 +3,13 @@ import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/exec import { CREATE_PG_GET_TABLEDEF_SQL } from './database-query-constants' import { databaseKeys } from './keys' -export const getEntityDefinitionsQuery = (schemas: string[], limit = 100) => { +export const getEntityDefinitionsQuery = ({ + schemas, + limit = 100, +}: { + schemas: string[] + limit?: number +}) => { const sql = /* SQL */ ` ${CREATE_PG_GET_TABLEDEF_SQL} @@ -99,7 +105,7 @@ export const useEntityDefinitionsQuery = < { projectRef, connectionString, - sql: getEntityDefinitionsQuery(schemas, limit), + sql: getEntityDefinitionsQuery({ schemas, limit }), queryKey: databaseKeys.entityDefinitions(projectRef, schemas), }, { diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index 470bd56933b..3931fb892e9 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -5,7 +5,7 @@ import { entityTypeKeys } from './keys' export type EntityTypesVariables = { projectRef?: string - schema?: string + schemas?: string[] search?: string limit?: number page?: number @@ -24,7 +24,7 @@ export async function getEntityTypes( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, page = 0, @@ -67,7 +67,7 @@ export async function getEntityTypes( ) or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') ) - and nc.nspname = '${schema}' + and nc.nspname in (${schemas.map((x) => `'${x}'`)}) ${search ? `and c.relname ilike '%${search}%'` : ''} order by ${innerOrderBy} limit ${limit} @@ -111,7 +111,7 @@ export const useEntityTypesQuery = ( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, sort, @@ -123,10 +123,19 @@ export const useEntityTypesQuery = ( }: UseInfiniteQueryOptions = {} ) => useInfiniteQuery ( - entityTypeKeys.list(projectRef, { schema, search, sort, limit, filterTypes }), + entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), ({ signal, pageParam }) => getEntityTypes( - { projectRef, connectionString, schema, search, limit, page: pageParam, sort, filterTypes }, + { + projectRef, + connectionString, + schemas, + search, + limit, + page: pageParam, + sort, + filterTypes, + }, signal ), { diff --git a/apps/studio/data/entity-types/keys.ts b/apps/studio/data/entity-types/keys.ts index c04fc7dbd12..7e3505fdffc 100644 --- a/apps/studio/data/entity-types/keys.ts +++ b/apps/studio/data/entity-types/keys.ts @@ -2,7 +2,7 @@ export const entityTypeKeys = { list: ( projectRef: string | undefined, params?: { - schema?: string + schemas?: string[] search?: string sort?: 'alphabetical' | 'grouped-alphabetical' limit?: number diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index da484ef951d..c1e359bda6f 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -36,8 +36,11 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', + UI_PREVIEW_FUNCTIONS_ASSISTANT: 'supabase-ui-functions-assistant', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', + SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', + DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, SQL_EDITOR_INTELLISENSE: 'supabase_sql-editor-intellisense-enabled', diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 6c7899c806b..63fa4d1a825 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -41,6 +41,7 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' +import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' import FlagProvider from 'components/ui/Flag/FlagProvider' import PageTelemetry from 'components/ui/PageTelemetry' @@ -147,6 +148,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts index d529568da0b..d21b384a479 100644 --- a/apps/studio/pages/api/ai/sql/generate-v2.ts +++ b/apps/studio/pages/api/ai/sql/generate-v2.ts @@ -1,5 +1,6 @@ import { StreamingTextResponse } from 'ai' import { chatSql } from 'ai-commands/edge' +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' import { NextRequest } from 'next/server' import OpenAI from 'openai' @@ -70,12 +71,13 @@ async function handlePost(request: NextRequest) { messages: { content: string; role: 'user' | 'assistant' }[] existingSql?: string entityDefinitions: string[] + context?: SupportedAssistantEntities }>) - const { messages, existingSql, entityDefinitions } = body + const { messages, existingSql, entityDefinitions, context } = body try { - const stream = await chatSql(openai, messages, existingSql, entityDefinitions) + const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context) return new StreamingTextResponse(stream) } catch (error) { if (error instanceof Error) { diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index cb349d96b56..f2640fc356a 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -366,7 +366,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { language="pgsql" // its bq sql but monaco doesn't have a language for it defaultValue={warehouseEditorValue} onInputChange={(v) => setWarehouseEditorValue(v || '')} - onInputRun={handleRun} + actions={{ runQuery: { enabled: true, callback: handleRun } }} /> ) : ( { language="pgsql" defaultValue={editorValue} onInputChange={(v) => setEditorValue(v || '')} - onInputRun={handleRun} + actions={{ runQuery: { enabled: true, callback: handleRun } }} /> )} diff --git a/apps/studio/public/img/previews/functions-assistant.png b/apps/studio/public/img/previews/functions-assistant.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc3f94c8f7dcd783f5d1d3908815b3626f576e9 GIT binary patch literal 275225 zcmV(&K;gfMP) a$x00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP ^NF@BfMiJ~)73+^N%5p>>fm|F)@_-hWkQWP~nDONz)_{^hTKsf>t< zpFY@|fBakDU%pL$|KyW@?5{b(zv8Ib{*UAHkACoX^t1YZ?c0^iN9QeL{ND-XgLNP1 z%Da8^`^WFfDT*IA`#o)Qw0_E7{hhYRKT*W?AD^ZM{cQht|2E>E{`awU >er_Bx7{{-2w#(l-qMpY#ma#qA z=Z-V
yc+czVIJj*5V qU}>-X#RB}HY8NBkoWpIqzlABOS6_m^Q}2IMP$ZXJmCD;7x@ zj&sFlS!`|guE9woKfZ8JKtAV`s1-(L@Q1$*7$Kd8F5Ta?a>c;DLP)Y_4NTTcJ+ L0_)mdta1f=Le0`|g^3?`^==;facOf_GV#d7J=JK9K!)ygNrq z+|SRCTo<`*ig8&@SCqd|LmV&rfp6mdxNup*^=PaA@%0)VqEX~F0}cKjs828#wTuxQ z-sAtx2Vl>9IntA9<_dz{QM`2?76b47R-hVs@6Y?trgPZt*Avg$R*vzHo09SNa*!os zC-%(UY95!fsMtHMzyJHW3I4Nwk%|u_(&DNM4zpDu`Gl*UIxaf6yPyWBxt+TttLGa2 z4!8+L&rKz2I44bhNp;99$1!QP8pV*cLEmbN1k;+f?p8K%_b2c#Cyzkkl-*uOiL8x` zVPm&r7>RQyNZd#a8}O&UUtPGdm>aQYzHy!-ypq0VJIq1`QLm4UADPzPZI+Rbq$GTP zadQ1SMH>6PVun<{@I?+=RKzUQyU43Hc&1H4VF>u=JLDDCT|hV#P#eo`I+yGEqaVkP zNFGV(W~`jaR6G)PaJ(=bl~}&I{F}eyp?68YFn^ |n{NhV`B(9H&fuIe5n=X{vAi~zM z*VVtB-W8)Ows@QtWgso+?W^OJu_sQ%ZiruhivLW{3W{g&HE{fm3D}$>+Sn+JM*g=V z^0zFV2OX!Z){uU_t*7-Edi58?1H?%REmgp32n_rZ^Ch+6%4hr?LsA1CfHW^5RB z7l_4WO6Pj=bS?gmGW?)WQo)4oDyKYK4g{>vave|4Pg!x_3lN3xXOOhwW3IdyxtjoG zNrU54x@02 r0yYlI&O%!>hIrY?5BV|@O*e~ tWowu-dYlm^RhEIxCR z?BI(kI0i2G#y=M7cHEC2ik0KX`|z6mry*+K2C+g8bYg8okHVGXt50Qe_$kB*20mh| z`%m^i^{R+L+Kd;R0j?Ug1qY!17F;jZKYgU-sAN7y3|!ZX&wA_)5OM}56Un?XvWqCl zINvcG7PK)8vcj*${_xL9rsCsbTgb)9ibkPS _jVmc&B;>Nx zi7eoiqcr9{|GViGE9YOGoornro5zA!6i>t?@ot0skL|2wW8uO0Z!&c-&RjmlYmIT& zGElvO8LfA4$2NJ+{f}Uc=LhZThZgxK$3EJ=b2E$E=g1MxV?XwbBk~!~Oy1sWJrCwE zKgc-#My|_o?TkZsP_+9kVgG3JFHh#{&H;KOxibm$;6JkT&eyS&xMYiNPR`{3_8oTY z5x$?Rb$e wPC7%Y(D-X@OTv^nM}%&6P6PWlp&{e1{V*aYNAO?RB&!ek zZoC64YaIMK%bvc{#)RGEt^(i^_;MzwZ*r>@u!ax6%mgDjMo%s|$>Hv5sd^h!K9f{i zvA2K8f45d%ue?Tqs)ZmMv_F?ke2_50{D_gbsMEg{FPapHYir0}7V~F&>(jQu@tioO zzI)PS{1?usojzf|Q>a;N8rFjCVFTmE%vm4@aBg AQFhlsMu#Y7+3ei z(KtKUw$-Gll$guLPOU(H+ojG7`XX7j4&UO)V!?UV$98|gW~0C_ahKbf@x(Y#(Sn6R zHfufsk}y%w#3^DfdPU$&>R9n_JFBpiKUPh2biLB-qP&O#>!^2%*Xvu{3U6QBlrSD6 z{B!6r>?v!6a}NA;8;^X>%=lG5+s0VzsJ7uO9HV74GsK|cz@#7icXT%VRSd;=Wykpu zKAE|4Edo_p|5JaAc|pI3K>%sNo`a7RE6!0|FpV&dobeyiJu*UQvRk9LXde20=_?|5 z?VpG)6LP|a-bG518VKg^If3e|T(T?i-IFTsWaQW(FD3{1@cY|jhcV1XWzwGpGcEUc zR{2X0H7szy1RZjM8~4JWOIw?iLL529CH6}BmU-kXAR;Cu^F@yHi4JR uM*eB z 0%>K7h%i)p;KS#9~sT06jukUpZEAPM{YHYX(57z%n)* zNn<3Nwh@mjJA)ZMVNs!D+O$vndyOJh_SLw&m$(zDR%VTiLLMGTybfXer%cc2iS2F4 z^DZaU{`lDgcHijo05MCU@R{6QePqR`0Mkpm^+_Zfi;2&$cmF^}gwsZ{IxC)M)iaI! z%;sM+q*VC!t;8qwd=)Tb77Ep7l1}t(k9XfXOY1S`{wb5o|3`^)EktE%^4p2^;=NfF z7HR j?d;X+=C=9RI)5^^<$ zOXEmQfWmO!tvjfYdo3(XH#?ZIZv$`ciduYPW2Pp)$SsVo4+f1_5T`|ZhjJpvE5n*% zpR!n6kK?i)V%RtWw=KxFx-?b&gAW`Z#v@gOm&FsZi_vuV5y44t%aeu!w>#h@A7d=b zL7{D mn)l= J2Vr9-E69;5FyrwBy5k@!5#`+*vo76-4*qC|)|;O8eqJ zdcV;4bKFgmb10Y7dqk`t^#oI~TKw=?N$Fp83=Xxw^;0MLvi+xh-cQYqV`U;=x}dS= z;cvQ-%VX;Ben1kazRec`kTn6wXAeI8VXxuA98nK%OSQSmLss?3p&Z?BQmjyg6_3Qz z8y{VaIy7U>FDeF+;Q-q5(itZ|tENyno!&JQj-M&Q9u*Y1jALha5EZ4Bct^s)&JQsY zq5IuplFeyk?fV~3YWTyP8%84iFKrqkTde#ZqPV#CYPk{rl-#pSPv$gXKAdqbU4dL8 zMKjq;yuZZ%XqPj<%M|Y4#0Y~*_8(`%az;U3v)#E21#KH>D58vLE$Cy%d#kNzOz8l+ z1^Hh-0m`E|i2K~Dc#VWib9CF!=SEzXpOH=_uW0{Pn7^!$N2>)p+Q01VgcUwfDlYnG zc$)MT^fK8f<0$ZQ7Dd^YskRt?mPt& hq;* zkqlgX$(<9{ av4l*LxubwFhBmN&_E<89aYBVL=HznZO0QCK@l5de`6&yLu|R7%JuD~I z(B0aEt_}m|QsZ>aDE4CyE6SMa E~lO@>p+{W1q>j-cD*!lLcME9r7M!&f>#0=b6 z+g|z^V}6x-YyIBGK3ub?fBCbYpc_#!lQu|?g0=Hka<=7mDO->3r*npg4TkKSfR|R@Al?_pKyj?#dW8;C&Spw3T0Lq?&ohVBt19S z{PTbPZ^49Yaqg>{@B#MSE>EZ}0D#|| k{{F8gMXA6zI$oM^;;E=<2^cmXix_+;nlHjwyp`E627K+3@}~|K^dr_X zUnVW `9Yb2Xd zHkgKIt6=Atxfe%d-;b`gT2T7N0TXNY@NKvjb9#hoq1bO~;oi>*%Y<4COUs!xL&mr$ zIL7&N1)o44zA#su2wK!0Um9+)AY{xrxN+8L$^9N4h&x5UntRzRWjdaRWU_-Vx z?U7P)OifinGwdb_dDR9Pa@w&b$ll=l{8)49j~^#gI$HzPmPs}p6Qn !@GyzD zV;ukj%Ab#wFWU^tk3bb!pyl#d)oWiaA3-lZl5zg&pRC>VF;ciNA-+aJV-R}~EkBT$ ztZ#~*vUTy8BngDuq^wLx4@%QIBc3Zn#g&=Q|9G!mA9XDvX2O1WGbz7Tvt?T)UbB8C zgOc2^iT^8i8JU;t7Qn?S1_Ebl7JjGBm#lEN`AX7OUb zV#FeS4$2(nPL7JF-cAyaaVZ}B?Di)nqwjlUlG8ALCUfFf4Oy7{5$`N;?i###Tvz;& zW4mIK*4&(z^9#>1RV?`w?<@~XIGjn>xU0KnAGLIiW!u7p&>yc~;frobdm*+O=R 1h6 zV?W%N>~`tl`}l9I-MNgS<1Wiv!&UU>J;o@Tpu+x$f9ccl(Ik@blo|P{&8NQI{x5|z zY@XMS>mfGr?L6fO<53BIyIgeI?f8q@Pg&4!^=s9>d~|P$J^fVC4civ6IDi;Q$>@-= zgEav`Q{#bvOgu0Le}Bz?%YTj>-eV73lE`3Be*j_3*!$zh@As6+Gs)-Y70-AySVn|u zGVY8Buea!*(3Ny _J)1H&gRZ-Iad*rGQ&^K?x zU{WaM#0)$897DFD5Bs-9EubWi!C}XKF@orFrD1|ysAl9t00#w0g&?dUXuJvZy7QHr zp5v}DVinE&ZV4EqMewB^F%1wEF996pT6;YH18COPi_ZL^&sapSN!28L^{YjJsyS*- zRvSrb;4MU)X@X&5=48FWtWNyQiOJc<>u$Ksv5cgEu6&Z33lmBjx7o4GpCH_#4epgp zz3-%8seZC}h5{x~A19!05*)lxLqHVGUU DTjh*hBJb-G zJbFKc>70cHhQ9l} cP0ed0y;H-$rJK*fFdf-YGL3KD9 z)(OO4$0v*Co^WzvS~YUe_b9yX>Gf0Li!8ag!)@bn E|5PmBIe?44Y=#R?UVm6 z@{18rh*Qsm&Mxt`90hxsddtSkXCoqlwMX5uDI1ioB=9~*>E&Dw!&TDGh~aI?p-cDA z2g#+A;b}MgzjW+pO^V0=^4Gr %>qit`f zmv%o1=i+C+7E(XT`os6f@uLv1puv7M3?xl29;Tu%5>3R|*BT_3Va0_Jq?^o5m^Spg zUhy!J>-U3Lr=M={Ct5|vg96WX;L;Nbg*5QQuOs>Bc>Ii)_l9lv!QFnj#&{gFientR zZFev>u2=k7_lO@fc_IR_12R{qjl1A YLNpT&o<56#YDsJ)df#^M%>z?P zC~301`40BM
Km+E}{?o-bx^J+jA)csxkNQj&E43VH|e znM$^d$??!4JS?e(FL8txGT@GMcP%-dI6?e4&s5ukaapprTyD64xofTrJ956Vru^*E zFs9Pwh+&^IrjqhGlXbAnGw5y;vyp2(i*>!U`?6Q8h-adGJZ<)49MZprtC(0X;;_fQ z%_I3-WWpJ~h}`X9GbzCQZ(xwWukL6KgK(l`$jm%$N;RVe8F)Cq76`o;SQrSx#Ot zRL!0I{W2lnI>(N`nnu7q{NyT+>xf~q=r$Imfhz&5iH^y9xir%h`;!lJ#KN(^8}+sR zOzCunzJ6f2e_`C#={r7N%@xW)-jadi@g6ZBe$_=e7Rvfa65PWK%>N_F1e}CXJdW+p z$S-@LQrKuKTAmFzlZC6p<;W`*16&S1$6H&+4Ad2emu%dcFoWeGJG!I*pk8 zmBop)QGyE`o{Oigyq$2%1HVl-$G&_R){WiIMAWVJS`3ar2a5ag{qZDy#xQ=zQzFH9 zk$_2t@vq8}&)*9dPWEW*uSMCb;Nf6NBPS!`F+qII?%mD=dy{|b@!E$;C&c8tG2U!m zFMS%w!ZgP)S`Eop0n1SB7}fr@66}1sh;Lv#;)x}QWkmn*!S (tZ^%F~nJElYHREz7SV>Zm zD-OJKx25f5`?7I#rtcbloPm7esf LYnrFhD3K6g; $LPsp@5qmA6Q%>M?}OvyEEc2-8{x=!-JL=B0RVT>>u;4B?Hes z9YMdV>NlQreB~o{N3j7{Zn8BYvdOjbMMgK07pI8=GauX)&7rk0mZsY*h_wCdE1z7M z4CB(bK~EFUn&jZPr|RPQ?aW{-qOb5xnWOeVz8e^`f3zqPKgPdLXa_s@Lc?3Fx<8UH z$Tx4iv2jIQwyx;M^Nvr9)IK@r<{bUbY0D)?Jqv&^P) MeKO4v$2~yj~qRzt27{InW+@^geJ_PZkJ@!J9e5v}|^Z3lEHg0t6r}*CH zR&C>1<1s%(FwUnk!p>YURTC^qJ)duaJQ{6}AxLoKGOox;1+{ZSm~Ue$vLb^LK5|pb z1Mqz>jYd84-SF8r-!l!{RzJ?{$^Cl{;Wm=hM<4q$!V5Kq>7wL3VZ6FwzQ3$i7^Jfa zNH~n2V)zoQbRYnf!yh>MdC78v#C+^3g@!Uv4l-Rtbg09OGAVa_{17xMb-`Uo3Xpdk zo0HEXCXt5qV>;td0DS^6UXFJmlgHB{rst8f9^=X6h?Wt;%FV_5*sPd&a#fhyu@ z%GX33HG{$dk08+sQU9;;_&75N8({x~DAg887mF0^Z`p%3p2mqZkK`I(!O32P7q& * Nlq|KUkJSUoD;Bn)Tfl$V zh`z0zMR{5O0>z}uGO$2pTyk>s^_PzITG~q-XnavHEq&kGW;S!XI~fxs+9J%>;)AMg zv6(B)MZGWQS{;j{?+Wg(he{`bD0U%7STTtI0(PSzVip?_u_r=axIGJ)l0#yVc{nfS z&|6rI>66q14*clzf;mYj=@ k#pD3343R}sJQa>_k+ekcDwu{^X+3l=8<3BZ_yNRXf*t--bpZ~5k z_E)EC*anS-g;+km2lfzqvAAku7rvYeYBS#G2a_+_&v<>&W5UZzB~`qlPk(wjIW2_; zJcNhZxo;sWuvanS53x#ysp8X;dpEZuS7mCCM!iRk5PrPzT#FE@ci@y_e$v@k&m&82 z#BhV|vCtYpnd!?~3!#`a8Mh#wIfm(9BiTtm^0fhsdee1jw=!^(>|r0pe=65@JUGV6 z`7n(;vt#7CMxJLtz<)||{nz*sn7^*US#C4 =cYY4zQRp8v>@4oPgfEPJPd*0UT>BOcjMlz9 z`XbIfkCgDjEyyRzwX(6|$AQP%6lZ+XYO=80hGXr+78NT`el{4-*jXvh%M8DO9?eLV z%uVPc*YFk7=G6oR2$#vb7mLV4Ru6#B&0~#;nu+DR3%u@wPps>c1Gf~5MmT@2>D=^v zvVa#D74b5bMVqyG0qKv&`{=`(M#Lb4nBu^&WxY6z^gBljl=aI+0&y(EW*XUMI81A* z@{mzKrU4$S`%Ip5Tula1Fqr 1M8|Jtl@ZH689w$Y@w@(j9?;K x>xv1A zc-YGA<3%F4lYVErvTqfZrNbv_ULdD;NRP_5EO5|Y$fjY*9y;Q08lka}wVplEl-b!) z^(bKKiJzBnI(?+ G#AQ8FCc6!hw=om@Pf_C%;TT z9bLj;*;-im0#f(jIEm7hqYn0K#1 874@aRI%nv-?xo*5eQXeg#*x=Rx4-bln5iya@%M~NXq7WJK zC_hEm;*9ZSnb8XJ)22xo`S2ld6AHCNn>NS*iselUQQ1*;(*(0 F*TEEs%NX_v zCOq>==-siSsTY@!cf4jP9~@qp C0jx%nrUF^RRR5 z_`k^46tjIhD1`eCR?tV yJ1x++8OBv4RA{gSJ`wHa)?z?6jeqogEt(5#GIeHsBuS6(0AbVN^^lz~? z{fPYi$CuxJMgI01c_T})c|ut>)M85`$MOoeyB!-Qlr-xcT}xXCjVnQ_mY$F!o_Qf6 zEjL-VNO3#2U38S?b*o9f@JYoM<3AYwO2?l6izzhv&uzSjr_{?1@yKz+VgI=BN~V~L z8i*`Q#?lpB*O5swX$38@w#IMrrF9b&jabjqYzT> z2R IbyMzet+|wgJTxouep=O`e3KBeR z7t&KPR;! tsivpM36S_m^jA+{!$w<7#47k8)Rd#Z2k8h?Jp(O!FwqS$ZuBImu!0d`X!la zj$5O6j8PoAqQq<;e_eXu6!s&6#6Kmj>Fsko*}fjSI2Whl!0_^gQi(4-)n#M7OgfGW z(x02d6}{{sS!c6rh(HgL0GHIjMWiC=aG7j5r1C|ch 7y~CUFgOK#XZ=nNsmC}jTRh?b!(1cQ5&v31h{dNd zp8D@ar|Zo4>JHL5jawDpBmYDVo(tE> L8-}%mt$?vSE0|7m)ouMs zBP5B>PMk&fw!H0?^@XR|#(1F0nDAusslwOwd_ddRM*h@cB%JQ&GR3|!Y=RH08fzsw zEzo?s=Px3^e_%p`zM#eLzuzi~GKsj$3Z5?}SG?k{OqzPRdw+PsF10#*aIwxFw2jI+ zzZCNxGZ)B<_6a4)C>C!ycQbN;D#*;qCwxH6<9CQFaZK-{FW9FF$Y@(TaNTcucW*Uz zi#m?U1kh#$6qSm2a5r~n=>2gOgCC 4A8I%>VKl*~*Ev8{J zE&xF8Laf%(Z!WT^-)XnJ?ERaN5i(mXz=_VsHkS%yW{~Eks~>dZ6UB62WDKtM*r1NE zjmaCdX~Z})88IdT_&87a6-0=Cj0ryvqI5f~BwltIH2oG9J2~SBUuHZZHffMhPB!lA zd~pt#N~QrA7>yS@xLo=x{WW bA{fE${sx+)6wlx<-qN^ 9h-dgey}l zcZF>JfO97)`0}P+7KrYM{&UG$;e}Q`j|yxZ^{I9L;pDUJT5_TPrGWpp)p3xSPm!D7 zK76;XOU@iKISgZ5+E$NmfBL~V9`OB#NtA776B2#B^cmK#1(RKJJ-VWSOj#W3MTdqc zwQtbon-FUf%RObQgAq(rLlqWWM(PkZ=V<#(P-%xLc>Z$b$-6MS_(ah~fv(sug3M6_ zd9jz_CJ;Gw?sMK|fkQa7;-qWH#N#qRs_0+I*X->@M#Q__danptgzf~yRylQoU|N6} zF6V^GaWq$dVv SM(D}p9G6nl++J}$tlc|XG&SFCb&ft`E DygUv@4Pf)}YY^5*8*cdU} z%TwYL|Nt$#}F)vhw_hIt-oF_X@@jb|unv8z;^;r;an=vtDqjcL#XG&{cF_DA# zq!%9k1nzgtINy%T$Y?X+{qwnd;cIfH=?cDIxEyDnmU{WBZ3HHhR1y8=WCtYPGy;$< zYUQ*Dy_U>)GX49skBbZc5$EOGY%K8jW07~ohmVw@++~~|+n#B7*!kWnV$TT*?us=N zv_5()|JQl&C4Pj>2(S5$4n;dK28SucON##tFQmLiL5$ASQ0HFI46BQB@X3hjO#2QU z#FZnDxG2PS#4bT_zkX78EmYFBSMtoL(!ZV5F 16stHtXSMvOx~Ple`U8D^+cXa z`^CZ1J;3*A?~30x{N(*!pU~6)(Pm=BLQyJ|7Lg;&z#;o8Q5crIAJN*T5tAimqt^XV zhqitA0K&l?5rbt?iUB{AEp^Mv3HcW}Y>W8O?27~f$0x3Wct`-hR4i*QOCk8;X{LAU zEk*TT*?}iPoqt3G>##H?BOCO79Ap4>Ivgs%V70ZHz<8{RHn`=;p8=y)FP(*TdB)%= zaKwWQL&?2r0+;6K)%%EPumHItNs2fL;OJBQpZ5Ru--J&>{j!!7yNvU6VH|6L6|G<~ zBBFPT&C8yqpO^+#a!c*)*F=zDYhIG$lMKoal3W;bXKI_UB9~czt#`3=*#MJ-T=EBX zdij(l5C(4%i;=kpdhVFaeBk?RHsG@D)tiz@$+47faS;CR_W=HxgaX$dK{v%Vo+@NgI0e1$G?6z`FlpW${PL9n1XM^a)_m9 za H!6cfT9ZB-*@kejP423GtB;^{ToViu_(B?p;;M`HYzl8RZx zyA}yt{y9`V#%1S{Ev38X6Y;b2h@Sz5G!meQ={06=Q&{E552Ik~aWN@IKWF=81xIrH z^j!4Ek6*w27Qg-FABHa(|Ni<1=(K?C`4@|s(|)nq_dlu2Kp{7g+CuVGHK8;8WhmnK zCU-z* o@yW4jSWCO XZD5*~lxg&cY0ldcxOzTe}&x7QVxKb&VQKd0(q z#F=BbcKkPI<*F6v_Hayua^Vkp%XZH>3}cX7t3LE4K_ybt{#J7dFGode&XFd rT uJ6G^HMPr|JyR+O%P_y+<^glBH`VD3(^ ze^0bXWGiSMrI|6JTQEb3uVNOLxXyz`z7H^Dcq96ZXMt~pWNI}Iaxg#A&KbV)2-(RA zVdQ1{aNEFKO<9q&_s9HOM#4VP9TO7k+exHqbYT4)`+QGc+#Ne5JUggv-mg`|ujg#y zlT>d!neMZ*uc`G3PrM`~=buSS2JV^{zC7Xy+pReM$a!5C<3G)7=Ua|==H%Hd;SR>{ z%Zf$(moZW4+g$y-?CGN5CRlIqN>~!Eey^$dIJJ;~V^9pE4PxN){kQ+{58r;~q(b_S zm`1 zq QbK2e;4h $$z`7}w=7*#&P|J_KKhGs0pR=E yRvjNA+RdbYg!!P@Gfgr}EPd%h{lh?x698doy z`5?eQZNZCm*fp_`GtNbq72EE^N8oa6mlp5n)9arWD<;atNv?E7Jj#tR5gYrghA=1B z@>wk$v$nZqzbJ9wIYrvH#f-^z4h-)2hnYsL03FQJd!pta#%x%o=?B71%<+g7bG7KV zIdr@9k^jfeq4P(cd(|iRZT%35meXyVdPx?-M?PW}dFj#DHS2$JRTy@YY7b0V(u>y* zbmTF(+A2oQx7WAma@Bn6O=254&dn4uUk-cgaTlTP=ytLQC};Aqq7q$2iYz8MyV5q8 zfo>wElWf_gfkw!FhK1W=IfkR4?+O-mObXKLfVycew*if9BG`TnCp$E`q=u3_7F+m^ zYvsY`h=B=w+2z5u2<8A;#sG&_KXB*-PPX91KoAjC&`Oc`ay83<1{&(7D2eCP=_Y z(&e@uq88=CfE6+0CFhw52{v3vT~LJAwnsQw$Kyi|N}F6p>8#f3!(yI)f!DA0Gz%6v zCI FgmG#BR zp{_3Yw7L;65hGC&;w1ObJxre>-YkXUe1Dign>$cQAqiq2=;q !Oo`3ANVrpH=d=MKk&w1CxgJB6Ck2D z%KFD%T|##L-rKGPu6DVS4>K`~Fy6jn-I{QHM!xX0TiPz4{1?Y2cZr)Gs7cwjy6@OF z48-C^U2w%o_VM3EAr9 -*{#VMgC46cCaJP7QR~snsZJrk;79RP5+1E#U=t7 z@|Wd`DYxr0c?PwDYsY&9yTRR0O|6zwucU~+>cKA?4wBu_&-?B%(N|U(uXO8#eTOK- zo-LKU #l-ffsiP-txjqvsRZ5u{>c*hgp#FS|l$k)KD z9Y?^>W|C)5s2gs9$yS9gmZ`ssxh~Xk+VNEeR4rbAEh>?De8uE+#M7uGQoc#!u}63U zOX=g_)iurS)D7yVIW$cc>()CA>$^S6MFaD5T1@DE=ZXP^fBrq{Z-0Y|#9bKw`Xsx< zVG6>$QsFu5&nBVY{k#QRzIHz=UW6}? {@fYFm2Ta~AXtGhDzA=XYP4>#Jl`*pT@) zH62p60JXP`2r*642+`tCho|~VTOF}NGlv)ziZ3H9>s&OARyA~^*kYY{WCXM+Ba+@{ z77X*c-P@WZ jXBQ4p^)b#NWyygP#vFQ?rfd@ypP#*V7sPp8 zjN?JK{jNtpBQ_>hP8&HVwPmu9D;g)h%9=DsI_ HY4AXm z>~Y5#ZL_VPnUTusiEYz3fCM+Oa$+`BRE5r{q;moaH?t}EDAhmgc%I7(yI8!?=5{QE z{tlChT^w)n`(?SbBRy%~6BDrD*lkw&bzr*`snz!f!HphaWxVy3=}JCzki2PfId`b= zh ~f097z)yeS@5KH(+NJck8 SN9?oi9nL}yl`dz5XV6K`v$q0zmF#S0lGwyS4G6U0gYLX%XWP+4G z6#u%Mc1%u+R_DL9WY+NDx0G+oCn`oSnToACY*SunGr#&+o*T}8euisu%M=l@;r3yH zI=Fm&oclOxtb7r)m+o^c-mdYJAKaImI2q4h`|6- $EST>FBffD1c_UEby%nDDx>HRxDHK%&~GtY#5e~o`vY*WNOo9K4PzNdZJ zrAHn5`_?kvZh!E~9IF@ofp8vU=`n72Gm#f*bR0Lo#Nfl#A8 LbjC#qyxrr1}n(bG4j5lYJ_=SW!SZ 6>mZIE0|5~EpRJV$Sqv*In@QtfKWHGd`T?2{3z^q4%cTDIZt z1vW0z06_qUUR1H5FkBAlKZkyp2v5{Yql9=C!|CzV8%0toNMVbHNw-+<{>*%j(}+dl zGns|$-;<9Bw-Zp1+y;>P zP_k}@7H)#!NP2JsRB?@;8+@k-2!>W z!p(66^|*bka6IOS75K$h&j L3q@-&{&U0|t_oAUpU*uCAKC7E#y{Jqo&WuE z$;3XJc% uCV+pAK=**D8)Vg(HgJ8O7^bUJLBKF z`VA)lAoIMpCdMEsY=GY}v8SKBzKRtSxff$>!gY#(c>wGKpCyp3xEW7XH5&l8VcZ*G z@gNamrAM`g+SAD6*vDrFk#| v-4#=9G%}dSK zjHR?31P-`8L=C%qeUI@nZK%Lt4lZra^kdKVYS{)wS)KO039ZJT`5F0QIv)f&1rC9| zUWTa!n%&5V9cLl^DTn1mX{m`56ea+3TqdJAN4C?XD$GEBCiWpI$ZI8Fu$dD*KbK{M zFzD695X2}?=BZ-R$rc4ORNHw@MLf3VuzT(R4F$uT
DYRroO}_A3Pr57^?DIP(i6eQLcmd1ujBs~*x6F??&LbcC0&Z2jBwwS113uq) zsaJ#%wvvv=kSBz;Y;dyWf>>V IsfV0t+`**)~r$Ry-Pyz8A5Y zs21^NJuYrl0@U&W?13?8>3%#veG-#*8ZNQf`NC78qqv$~t~dQ;(av~C<`z!Eq73^e zrmx6V2$o?oc3$Q1I_q#^&(5-STS&amM541@u<462R3R7T$Tya$f(v&gPCrA|1KYrf z^Q!6-`Tw~Prw0b1*y$l=U0tva@^@HX@%kWaLSEycU?X3EQ^xQ9zC`phWMys3FnMq6 zUwF!R# U!-iZL;V`khIlj)zcA9-e}~R4f}fZ-BL>%DG6r9{vz#v@+{ZQImNg4} z(Yn}C7&V4-(ytyneaaQ(%7zFa`Q7!t;)nR`fJ3~X&&2lh{C_Ta%{S@_$)uVj%%3&u zHt1cNcKJfl*K27q!q)4rt3H!}E!c&*4Q9(d=A7X47h<@gB(wR}UjR8k#=r4)zT6i7 zDa;_3hFl~`8?RyUSn{{qn>ng9k>S+>FCW@qm lSi-Z9 { t1U)i=CtN~O4Pq~XS9X}h8Z&;c?p2r3 zTI?JJl+fD7zT$ae@VuAQO%9Akzl%+;o(YonWwLr=PwQmFi}4m> zV-&i@OtNPipbw z5k5zFri*lZ+D|6EGWEpf?D$W`*yFva9V_5ZEo>O3O8rE*iMw#J7qrWCz~;rSya)vM z1FJjFVyr8#5q*yZPwvoxpUr+IlEh&TwCdg^>lbh5ayb=Cj;RQbE5tN(ItwmgF@W|# zUtEjRzkUC)xb*w~_dnUc@_z-HE$3KXVibKh#I)RRizSZBDtj+!zUBnjb1q=yy8K>H z`MLqJ)H(wYp1W{mgvJO#R!S`ULtk;iiT_VaJc)89ZOMtttvObxj&P21-&2b8g@hV% z-6+zV4>d6l &_`(;`p5Ub{PnNXA}=2Ww)*iM!c+&HflL|7J2FzT z&i6Q2`_3K&ivyU{ v&FK=CMmF7vn{1v%R& Q02GsgE{9zdS1vmj7Q)ey5@t HP&hV4jni0iLTF&?~>mMGEZiIb!Al^4&LCHsEyg88c!r>5DK05PhKWrY 8-IG6Gn`LGPK)tWT$61~I$9q;UP4KS9ZNh8a+>5u(Pl$GgPm>f90mM$TfgsC z{90i&L=z0|NOAG^=e2d-)~#W9=PvY#OFJLj7LFswNl`B5&C+D8Mr@YZ8x{-}NM(A) zzsL#a=*wTt0*m)g IrXK;&xAxCwJCM zT^GTJgjoi|v9crlAImQ%ANjp=*<8SE2s@N=-tf(5%cseC7;X5n_uz>(My0k9yWyOt zZQ k$_<32C-l0CAtB`(+&iyJV{NRNPvA`TDblGL!$Fxxz6#t*~n` z*?P-K-mdqWAT3{wuIxkqcl-Nx4E2#^Ia7od&z>S40k7cxfn&jKiq$dCsAJo&53Ken z<2u+$HC)$sDNcaQ*B1BjJ74TnG0059-D^W2L;ciy=*;t+R-${7aEg%7dXi5uSSse! zk;Sgd;v#+zLn07jYb2H JXzlVL=8GNM@%gq>vb$xI z|K@fhTL2_1Uixp?p8;an0)I&AT+55GDmSH6k(d#_(x^a4)7M0(h-U)ANQRS&Ap@{8 zC+V{vk%Zgsdf0306V~GnZjZg#dopMPZ_MNvF%xrmQR2m>#(?GcgwW4#7SH`(+ql?T ze&z4dY9fmPD*z*nq`wJOtGpTVCGX|&!WmxSWw_H5ZVpcl-wDut_W`h5F#$M6z8}8N zFNM|c0@UI^wa}%f5Xu*vg!b=)*Nd3?Vl?H5gl{7?2~FA4ljwdyxFD3|sprCq_`^T+ zU~cSynV4>9c*G-OwZdgrlE=Hn>W$wb9@|i4Xrj^rH^;iHTYr!7?@QIe)dPafwe6Fu z3u7Li$< #7su`86@IA MeO=>{!Tk~)|74+aKz{17ccT_3B#zJ z_`HecvhS#u;jk=LUEr9aW%$C6W6^brahxx3fdc~Ln@KCeYV|R!FuJ(j`}4uCS{>{T z-Dq>9xnEku&mR+>fb_nsO99J`X>Dq>`jS$m!s$()(%?f*(UFcG!5Z$n89 2|g6v1Vxc z?~H{|M@55 Me7CfsT6F%^>MY!_@=v{Q^c;p2yAt;H1;p%_e! znoEX)p9bfhe6;x%i%f$=EZ4bG^g%j*it8$$`M-vICI8#DFkZt|J>`mdJQpT}#zUj+ z#Jxewp5(t4v`pf$Aa{k@{ozMUc&cB`QMg>Pgwu0It79@0n}h$t%nE$3I4t -j0XvHS&F$O&_)O9R^VCC%u2w(p3VZ*oB_? zX8p2Db73trZjQ#OHWBk3pKMlxq`lT5>RyaNDffQ-_I{iB-Ka}lZR nOMc*9e z=#KP0MV#t(0gF&H!uCniIX1n1f7lO6GMD|_B $>^ObiA2mWnHG*Y zE@ZKr%@Mp_pb`kB1qmjgurrV^Bh4-CmCV;mk!bmnmh6_6%fOkO+p#<1&2Ukkkb>TD zWVpXzoA2@4c;tu6;s?0uYmaEtVDaP#{_cmpzFk4aD`xOvoO|*J$CHf^lLHr4P!5Y2 zRyb5_S=6(0&+qpvQ0Ig+$8iy{5~MhPL^!rDQ0D{Fc^tAx>1|T%$M4T;?7VRLgC8Cd zHG6X}A8htLX|b3h-7YAsn{}#IT%OBw&q>5tSUnPOxQ`>W2wLPIw|6SAEaoEIk4Ug- z?KJV0{Nobt?t^|asCtUKSn%`~h4JHaYNCFBY{!{&?BiDd1J7>weBv?|-{m8|sMh1X z!n;Vq024p6AIB{37w-M!j1u>&HYi@PL7x$l>0(mfVvJX)fUB?VIX{NI5JxH0x^5%R zOS%s^3 1^L@pfSgYRwrq2eYc|+?!-c)??uE@X zJiHC{!x)SSTpbYZSKR;a*eS*U7NCw?5ieBn$QSVYZfY6#+b3=p82RfqU1KgZEW%Tp z%n?f#$?|guW3)MPh}m^m&c`!uH+WjVXB_$OjPZ8qyxS@F{!d4@*G95Uj5s;6l(n7Q z;T+vHj$KYah}1CAQ8|Qt)z`TFyrL#;{|I|iO|y*Kxzud2fHX1vBk*Im=w&AJ{dLDH zd_#+A^<6N(pH_G|DB?9BUP)-?&2K2WbNz3QpVJXP_CLs~j~_((XR4H56pt-A_n#h8 z&KjfEXp64S85}hng=#xG_ZWHpLGFvRV`r4u-`>}#gSr)I!)uj}ZEb=xh$FXk!d^I! z_UJX&Hd_s()g~!tC!A{)^vi-)^yKIVf3L;uBN?3zf-766qgfDUnDn1N#eUExEvboj z^DXuw&0#f&0B7Uucs%tj+;ur^*Q-mi%e2o-b_@4kjO{2{Nto}m9D`DKDOgj>OuGzA zK0JI-k0T&43ePZ|H-Xf`_>;m?V6(4|Ar{yjXW#!yF~qUW0cDAF$e&t_2%1R+X__`n zI*e@u`{&OcIx}(eGFVM4up$`59AyuKLWxLy75|I%(gZb~LueY=6PJh53|?HP@RjMl zRbQEA_Y8|ow1X{V=7<^7z&lJZe9xkwFUz&qJyKfHFUff jeo6D`EbdeoQ;#);v-!huJJFF0_HXj##IT5#d*8;P*H?iIMEsSoUGj zZ@Ch|h>QeWU=famU@9Zv3 b!$W?BSxF57j0+K1I`uoxh&hn`b+Ao;w;WIZGf@Ve&_n6#d8 z?LlS&IAaBiTD5KPk?{)oJ7O+KL`)mU#LtNT+%3xbMa*ciw?$Oj{Vly#HtgooBgG@T zR`5$(yG!o<{ENxwH~xR|^lD_z0a)*T?Pc*_HF6~4+-pmJGVbb_T>L%OCjSr?BMmMz z`)tQNTmZSM9=4RsbMa;hCp>&7$o=igd^=#+cL{4DSF64Jj2bro@{fPrIMk2&;XC?% z5o`|oY~soK{H{gZqkI^{{4`{Zmik2*v^i`ZI*y@w=4=m@dFJGaFF!kK)W{%05T?u5 z&4zq)U}4Xc&t*I9c3t%AZI$uqrF#6Wm>{0I#oGT|nZyV0rW saj5YWCMRUhphkBH)vBR*iX {}apyp#P43@2q4-Tp6@7TOQvz-N5c%g?$1n*_6Au9QdNS;Iz!%?;` zM{w7|NW27+`2kJS&s a_?OtQ llm|@$-3TQlook`X9;Zi0Zl@c&xF0rg-5{&W)|4?sABJmQKAs=77 z&(F@D=WLC_3&Y@?dZ8Z{L>^mCv>AGIc!)33^eN~*`#DeYK9>%nu<->I#)}nYkk+63 zAYWUgXGX60pGgSE=Cq~7|0}ci_H7f|JynRzvpPDX2%lu=apsPfZ71PJ1u~!X`mh6j zOENwxzFwleO0k8BYBd|mkqc|jzH${JZ-rN+@m2Q;3~9o0w(laTRslJ q1<>XX7# z3!r``VB%tSKwp=0h&}z)Em_Wky^sX(<-|%FJD1rzkoR!^YGW`dyXz)iD5j1ab{WNY zycEYNJo+S*rXV&?JHGlyTB|?wRSE#dFk<_0Xb%9JTz#e_`@v}n_{snB7J?(&w|cb# z ?1YxfJ@hU+n*Wa5x42{&U~K zx@!9U#9Iwc?y)>4Wdu(S< 6@?h+4&N>fh)D4 z&8M0UTn0`CAJCtU)r#;*BI+t1(`^FT3OKg+ehz&i$MMg}y7JzhghyDc1+Ou%{&Ia! z9jtiG*w|Svn%if^-1t0}flgR6(L?Y8faj z2|_>n&U_D5jo=4x`6VARe7+7mb0e3RBw+mjIcDW0BK zqX{z#*eJ#VFBL8upFNwM@h;I&-(!-4;O_sp=+pbkbSd^-58HE53AW0Uq~U`d_r;_D zqQKdvVxoZ1pE2>uiD@6jA!;+A1)tAx*QLS _xB zOrR`G%vZqbh4`15ERMR(l}qO8%cXO2DC9w$;^r3e>Sl!*qwbGz+eHl>u>bY}9(`^K zkq+1?p2>kd1zqpyg3Vu2j-*`?Ox|8>cqi<$_2)R3;(4Ai*jyg57w*pO)D=Fls~25w zvw1G5_+Hk<_fZx5Y<1XV1Pc>m;gq<=i`m76?gr{-HimGlDKZvbgWJ(Wv0yf7zGHkt z(* DUWRgF~@h z)Z1_;Fq&P$+V`Fc%7OPPM`%K-__rs7C$MwP4%0De@(@!a7|qAhBitr2vu&c0<^S7w z{p<#Rhhhhp3LlMP6y?Tq;4q)4rpeDmQskwc3>E|+e%p2tBYrK?D5p;dctZ u`AL@KnAMVMh>ZyIu$jF~chQ!jEq((B>su4&`Jsn{G?! zQJVxgmmAA{s>K4;i^bnQB>f)7-Nu*7|7CgCM1I`P6+CX nn*eS&S I$1mj>L+yGIAt{!~!$bs~oKrv24PxkpM8BR`$eS)AF>*2&iCUudG4J5gv%1TuM5| z#6X1t+lCkx3OB(E^iZ@@&J|o`h0$K&sd7^6W50crC0U4Zne~DovhzYqvm@KEYQFst zjzJVhjU>jtqhDUE$TwwO>5K&A2qm3tS-<04CTlQ$Q-&d-5|8jmu1R_qbu+kvZ8cGx z@i#r8anF9!0mW`B!xrCUR~AzxUptS0GG6Z=jX}oca^H5A;X7HH F>?mEIEt6k2JF-0ptwa4A`U5bO MLWlbM zZ`vmoR+ #-o4{2vDq+j{*E>d5G z;_>TZfHyuH?l8`6;T{vdKt~zl!U!Sn(%wg2VCOTM7X$RFe)C%&N9UrEf(ItW@;_ zlX7KaJA1;dX=nYyCa+eIgTsci+h;reca4!YqCwA_7 $`Y+o77I z7WL-q&Poe0IdZdn`;x*qc9~7eXp}mA{~8Z0JxEZ*vf%|>fOZc~Z~a0PgV-AVW1B6c zeRGtZa6rG&x$tt|5Id0b!E_OGOd?;KbMXK%`Ga*wr$Az=|CqZdr+-q)=G$>KlLVDx z5Me^08U8-Ygw83M0dtT;7+_Mz*C2rEKFn5(hon3pe 5OR?L6fCWSz;9|~#8++iK=7e9;i(?^( 9EV3>Y-k2cTFluW8fOB{@s3&YF!ch45C7{5X%UzzRt7c!R zvEeg5HlM NCFa%ssG84B}iws`i{fjmbj{T+YHq-1=4Rk34hz`@eU(Y8CP^){N|Y_yh5D;$2vD zHbNbu*u93Z>Lo1y*LXy&aDNS*lD0!^7=Of1_j7to1&d`af+44@NGG1wHtD*RO2 zAPU1vq_+5`Wa5@JqlDS^3nwbMj6?w>FX$Z$Pu>n=@9l~ifZ?>HkI=4S@G@%^;xueu zP@eOpBzmF0GTC}e9K^AWbzSbZ=cucUTQpJ32e*%fz3}jx{8EgC$$bwczu#47+_hwd z(r >XgEP_vx4Huy2ES#>2b>k5 zYhO%{t_2n(Ye0ZC4Q{y@3kv-im!ZFW6#u$X;1Ty2J6>wWw7JVZw)0)Mxj%UD*R`Ea zLh%M$1-Tyf7z-15xG*jE&A*Vh@j>5CqjJd=z%L7!%tZ{t(X`6gwlULIi1{&vjhuaj zE&@T`sqCO*_Z(@Pv*oV{GpzP8PE9HN=DLgPW8ehwaTd?NQ!A)@z847y9fG{;QRIZQ zt_eX>;3}#h?+Dm!p?uM;vq6Pf2>tx=&-IUgVS6u<+%GzdQw&PjXRMqW0Ogl>$eNK4 z9C1&Ybu2kV7 {(crjIt&0$3Ttk z$SF9Q&=e#@-MxM{`92ceicpO|ZMc)V6zbT`Gu|o4Odw#iG{OzfGx@mK*)bpIEbJcz zwO*rOGJmmkTXNAp#ET}et)uZ)TD+44J;T*z3>7;5B6sq6<6hqhs6Z0@wEdnL^UDcQ z>{$_&CfDcpcT$0P=eRC&Fu5vz({pg^VmxXI#Eb3E_g|lQw<3wWoH!BSV|omVr#{>G z?z5C;7<)q+6F`nb9QA%)wrg!8#yc-t$zwY_km#}^`AWWK1eXd|Igh~x=ggfiE~e>C zi1c{JH}>SrXg)1YyA-n41u UfAfIFk)Pb~(U z+){BE#4)hX#8BVf_u(x+ i>-2fK6YJiU_Z- z=Va3`gyUnk(N5;Cxwt-U16(4$sZa0b* PQ7H(ed0nnb`}7* zddJ^oIvkji{gP|C*(JX?ZyF|J@JfcGPFFOTvwFgLTh2pvfgJ4#@nbx(eE!VE&lQE% zQteYAq`E&sc?NzTZ8F}b@LU*t@UzCprMsh!Zj<<80%(Ra37WV@42&_pu@=HhI%9h* zg~gJ5Q50r=&H`seL_M&3moB6BNRp+K*AId=8&vfq>G4q@;qOvcq(7TcXdA7a+QOwA z0H>vaww_Wb5I)~aKk5+-!HFt^mHJQ8(JF!U-Nk15{*H2zbejIlh=?&2Ymg&$)P$cE zZQ2cAea;m))q{JX84oa?Gj>|-i3lUbafUQX#)>(?eCQRj83J&VUf5q(H?0rhJFQ== zNaX`ZJH`}T+QvyRijUr&JVaKOttpX`)3A61UNbYb2}kGYiehwuA8`nS|-7Ad>HO(rlF-2esS-|nfP6Erxx@9|Iar~xn_D^{Y_f`i9( z3}DaX03CxFQE?*?M{+cQ=j2m;4y8&MJC_mlyYf22`XqWRmY_f2vm}eh8| 94 U(iFG|c$$EEJCrwrID~rM6WS584~9SFC!mkNmSn|M53?&lh<3nwaL1 z??ZTCFWNK%_117#sScrim;B}Y2eMo-!tEg*;_Xsrqw2RFpvZIEhmu3}Nj~EP1px5& zx4%XF{=03nMVOef;rp>awT=plVagm6Ps=~xA*VUmR+B>E=BweQHl|e|-pv!={_a&@ zK8Q0E7aTWaQ{ k7+ge84cI;_lGl5;MaBuPF_aSG1lg|Gx^1REQnUhPDUBs_7 zJs~}6 YAlOQrE8eot0H4hESQRQO>S!E0`_N#t{5n3aXPNw%v?yp1_*$i6 z@`Ob*A2>O-lHE1VQFbKFc3#q1XP}CeJkeLiEUL!eIcEIO$KY_b?7bO7r*s_U8lg-k z? &8O8unioa0G3YCMw0M*Ed%;+-Cyv!w zq|9KSBhUSGIMdMm`NSw|6lfNJgC8=5Y4H!@UzhsTnrw6kjDDm?JQ7~@w%iPq#%cbr zkC ui(rO&IOlc0T#Y&MFR~&iSnOJ{V5p1TK>|j i)ObUu~t@KnZl6W_J-#jo;l4R$C8+V3G(SdkS_nO z=BgaNiS<2U+zOuUKl&)60kJ+Y c V_1Pv)|kW9Sqa_OP4S6VH117}it~F{wLp zm&LJ?_awD*lwp~A*7zS~Uh*h;nr}|yu(+T5%*V8<`Fk7`1Bp!L5#f@3|IZh|e-hs1 zX8|Q5F2A)UoAA$xt#c8l>;JU~9y)`Er wi+iCL $k=bXC$q6@s%&xF!3`SyO^FB9S?Y_2zPm!&BZku2YNnup*s_=*{cPb7t8ed z+O|6?>+2f)(1R0reVJ#46}gg`2zca-u$X_w3+_uCMLhZjN1&L_9QSO(6fn=ZbUDQ= zEL?*ehso_1iP=1wF*yyrAji>$^;{r$978RL3l^GHv=t` 1AsBbJapYnS;7TH^Q19WmKaCqo z#j|IZBuI)6YlBZPxr)vGj#=p0@p1UV`gN>_7r!z1{(G>^zsqa|MVVK~0Ra44jOpX~ z;l%$XjFfPm8O(Jxcto5-Z(N~bzijO47_#_h9vOnDw`5RR{|F_+hjBCUo_(L%sY|9aV zH}++*Y5sfgJ#A>0JGb9z2npRU*6-sMl$FoiCrccA{(<@j=D+cuF*ypp2;qsZK=WEJ z7AG}7%Rbhl49w%n8TDMuJ|;2zSuE~_-M9=Rh5{@+m63L)*{py$X${#KZ@@yv3lCI> zLvGWx4$A-1)6QI4tU(s9{ZSh{xfwNA4O{p;=dFsLHg>?o<4M%$6Q3&_46QgishEnZ zYYoP2)um@9xv934u>va0SrH`scCQ%$t>*-@dtmpfN9sY2EZ$-nBVx3*9D25y?vMo- zEAJz`2$u{f$TMb<&H)*+0JZEfKHn8Ao0O-B^?lN#0wY=U#3ad-k8ws|7B>K>6F?Yo zW1}Av^d*YHvl ~nyp<{{@vh9U0wyx!ZBoSr8JDY%|C+;|JPJHu;b1Hfq;y71 z`JxFHN4rJ4vX#a~46xlD7GNiWxG#j)2rXKKahc98B)hnNBmnzkcnq`aG?=vm@0Z6v z#?pU? X;;E9K*w7F{-Q5BAH9? zHB4|k36yv})EFmagf>*kg554;Ps>Z5GlS6Y$@8W9%qZjFrk}E&b99b1{i=BNiSnm2 z_}h7G_EqZ*H QECx<TJUIk;7nsljS8JLv<{*%cH8?E}%easoAB>6oG5>#MkszZ6 zS02Sq >*{>#q%w;z`Z_-U{K z@0j5OaJz9GG#M-yNZ>K@ l>m3~ZmNVA5)u2X-p6S 9Hkq+Ys|!ART#D`EuS8PZqbdi1~jjT4 6mEa~ z_q!K8O)Lz5cZnCyk>O{FydelUs8_^Jv`&9*p&9?!dU?NgPhsnG?!7b_ndqvE ^{8>F8;a+1_I2`9QV;p*d*na=9{2zHr|=wz zJm$C3fYKF%c%Poft;t`eBjhE=Coe2AGn}uqAB}jV>iea0cH1^VueItO$Xo7KTEJ@% z;UQuYN+>LMoI!`R?=C$wg#HNQL)HRkf|C8T8S*{aR>YXp8K;i3bfwITZ%4jVTt|#o zBWfWRoZpPnXU`7Sb$dlR_mNl4_p~amoypL +yJ@}1FOZSlSTj{b-CA*I8LEvLGd zp~A&-anZ(xcUifD9#dcMONn~+%XYveNZh}_$fAB_i?NMzoujnMuZhIG6spFtJB$^) z#e(AnI?{^-6ruMnw)7S3)%fFQEn)Y^SW`0J=RWf<8=D(>+rImQaAmRcDz&3-N?7(U zk4s;DP5o;^)$E{;d%^3gQ|9Dz;1GCr87g#Bd-NpPig|pgFi?IYy7}iwD5QXkFsJT`Vd1yRU)O zU@v|)3CHHhjWf2;Zcn<+eUk0U+19v#laLkv%TjUhP>=W&IZU0>3{~~gr;!Tn+5Ib5 zD#v|(>j6{U{u@gfn +ibACS;Fjc(S z*xyd%J7S}4fqZtFXN*jV@mGut8%1Npo&?WfH(&CfAAAhC*a-f)#=ec+%E8v&sl`Y= zc#C*RbG3H?ofc<5>=Y@C&EXy&@@<52)UZpwLWd#hyx^j?j}G>gx8?ICl;eJku=k5c zYUm)FynQekpIw{(hVVEjIbogwsXl1`vc=ur@4f+F?JAZzxVz+{)tlAR4uL3Rym<5* zBhTgAE^;#YuWe&4V?H7N94lcbFh*P&0kq$5yqUmp%G{Alkty#NOW_qQ B!|*#q2`XO81rWQr Q(&Bl zNdKM@kIBjgEw%tyW8i!x6(?@2s^HSHe7b~f@w*~+U22%m2g!RZY3zu4*fL%$O*#NL zhV8YwdF0=Ahqa&qYy~`GD_;`d2i3ii1Y51nYp()re5sfi#E?l`4$Svg*tmr}hjT)i zF ESy@Gz0x*YY{| zVypo>h&mV53rOHj4@C$j$TTOR*oIHO``>_Q_HZ6U67 LE|Df$%vKv;*HRk(=VbYMkoAAlwix-B^ zOh@?ce7}STV3mf+?-;{|82=-0>5RYya_W72(r<*F!C?>GU)kl;;C^s%PU9h7(tOo? zG8Ya2u^I{g8#kXbthb6iwgwqjn2*_=IEFE*ciA!K`k$J>WxLPkdavG_OO2PYSoPg+ z _GwtsBpkCiDOrA=`82A%BAx{@+n7L=^3%NWt$!g#Fp?JItWzvU% zKL*xPd6^pSLIgeFa^Q|P-FsQjwq{?XD)8#a6D3}OSMR&jemvYsc5$xKcrpG(|G =LS{`cc 0C+#$u7j?i!Is2Dw&z0upg1RQkxUvQL(yIB*(SP~Je=ImS zovEl`#B(+JrCrt7Uqh?s89c&nVTT8&2qDZ?J}esVRqRI}5t@M8cBvv~qY3Qsrc@U@(0%Gn?3)%LO-e%6y`afW I8OQN@%=^A~-_N7p&H}fJ z?4@1?Op`T!7-vqWJxr5h%rkU0QP9ZYXP>tP-wKDVC;EEB`{U4h>53sW&$c)gL5Q!m z?2d5OOqju{G&z#Vb*{bw$Ir8Ab09m5x-jmx(?C4{zn|ULb&Vu|9Upn|{;45vkCcYp zBe=!M=;;N!?5lX};0lX3^zcZ6-B|ID@iih=hxesTQ92{yg>)_aNMD$5#w&L2GmdFL z_C4-XeqX6 7n%lugs+*QN9QV zNz#t-UfJgaymI}=Z^j{`NQ~BF>9=ENf_y5q{%Wbz$cmku1D%iOH#;mHD6?>mA4GZo zNSQ%zk5)L#eK-$X^X$$rg}xF!L_8*Ap>WFlNhT5CtfStr3Y*0Alf@<%BE#(HJUJ$d zu^!}ucH*I!FdThTi}sD;XyVnk`zWW|kIn5?frY YTG$ZjP&>RY+f#*VJ#lQ07dEnchVb zoF%b#Zv|lTzgd*0ksU~c%2W_sPF7VGbmrh(qwW w&l4csuU7o@!5KVAj-Y`|&y}2t4L8{O{HKW_T9&m_Kl6L? ze{GzQ^ES9q5S{;m1oDt+ks)-SYvs@}AFXG9XoCT}KaxQbGrrn96Urn8wd=l#S%U1D zd^dkIDWILsjLl{l=9|%-&VcEm6%jtE{amGRSx|sL82`sW;ge7hNP5uRI*ZDuCcBK} z!rM&cSkCU|w&RL={a&|XxQh2Wl^FiWmnmXRJIgoXU%rBmhrUs~$2FE7VVv;o{}S3Z zk;mfD4n00BL1SO-XW%SHUCDL<{|Mid1N>s3+KV2QI;*tfml>`$irNQvF)mqPR~R2i zU=~8XoewBka-Dwh#LF1_$62bJ1+>RMbs!JMD$0Vgf?Irt3XL567P$W^*BQ1lN*Ir( z&tXsaBpCl?a~++%i#KLU !1glwbc4iv;8b}0cmI$>{%A1_ zcfk8@Hx`=Huwiy`-EU9&fv-6xp~Ocnj*T+Yr0t)%HYFTQh$n&jy@U~NOUWcYZZ2g= zl+`6=dhLQ(JWTG%#qZ^M1RBT}1!N`pO}00ihjW|TES|EKPh8Pet$Zw;G5(!5yB~;* zJV|~Eu{QX9#(y%tt17g}n3Ao;`&F}!N&XXzv-3A>?6TH5xWPh{9bs!)ZuxxhN(=WL zZ32IoZGHYXYhRF7nBA32f@8Rnk}gvC3y*>Hab~Qzp*%m5mn=utum`yEY{ZjzRr9+Z zx$GG{jz8?_`PjxeYza$s&-}t#YGKf8CFRqOQ>fr!g8jfkGzB!?GyEh_=%HYZXMall zupjNHagYaaB^_i+5dd++`XMcTQaQmfjMo?CP%Z|IG8XY _D8OMwdZYRa9J;0sySVK znw~m03K=lg)&pW}kC=F9oyW?4)k8<_vm+^AsrY{M-CMVA9t=^_AEQ8rGS*iPVt+es z%J)4o6-0di`%UU^$K-q9=hG2Gyq&LNb+LV}KBwipXjP#S^f%Zc!&cp4>rb1@@7Y*p zc>p-oZ-97m`Y1XneJ7lk`Ajxs%s%2>^3KFti!;Kuy`bKy%?JAY`7m*>%FY7xJ@)V~ z&&&;2)MFbk4BtIU!iB%ofuZ7l#tw>gt=OWdSiCXGPPTY?&mt829Q!$YjeAyDFqYlV z*N1(6|6ZCjkBO#@kK^Ve`aiorXTq=Wi3auPV+X&4)2%Sa05acyF0Y-JD`po$lh2)i zhR+)(_{j;=j{hkXz2&Rz*zWNntW5o+Q`q*hG_A>jCNn*b+avNNo@T#;r
!;ss|mZWXU)iH&3M%fkllPyHZ`x=o?S zaEo|L?GXP~qx@L6EZtu~WR%UpaUZ(lIs#Y~9StiiUOv9F+)Er;kmoSM;iJ!=%o)@0 z&k%k9I6%k0DwEdMa}#(xy(Y >ESy8=U5u%$!Ww5_{Vqt6Vmf#Ebfn& z1W?jEcl1H}N0DJ_#W{YzfZHd=>A`OS8sRpI$M$7b`z9jveUrqV7+&TcA8}n$X9uz; zl2J? 4z#?kkDnuD*AS$r1~0|je&=6vi)9~T*D4~frxgB zkPjqb_l0+EY9T;L%iGq9Go3L*dtqFKZ43`8F0|$J$_Xr j4hvUe&%?S#o*tC0kp@YMepS^%RUm6O=NdcX4Azxp_ri5FMM(?1&^4C zwuZaQ 1EV?5g+v&Xmy>6JyK`KbA}W-18MGVaG~D%! z7a()-)FUYE=UfPAXM}4p^Zz=dQ95H%xA>TGY1ltpG8a(20H4^G%_{aHnI?$4hLG3* z&Wx2~GkbEm`Al5vY?RXu5mAd%wyqp8Eyt|fgeJf=IWV7m?O1%osX-k2_>aUj{Nac?E~hF0OCm^v9|fX70f;M z F(Ll-`U83%L#EYZ zh!@BdOa8r(hYv5{$t_&f2&k}xxdoR)O#mnUM?xyk$ rkAmo5b0Cp6}RQA!bnOEg_x%80%zh?+i% zIKuC9oL`=}e5uS7nrI6DBEb31XT$SG$c64o! j)q<>>;3y3qY!OT z82<^rO~!kb=hoB6xj=g6x6syS`mBs#_rgxq u1Av~uSn1RA9V=m()$73AW5NUSd81>HMz{J--b}#a1WU_uIFLw>=P)FvPmESNf z+*SvZV$vGLw>Yf$3X*yM+$=%|;c)m?gt0&CLtt#yEg{ULE#$;=63WR#X}D@`8uZ$2 zZkJbkLyOx?1M)!yIks#^v27`jr*PqZk6tvKV+Vhy*(1HSFdqAA#>*&Srx=7J41<@a z&%gNk>~ri>U&o{?AMDF9`1 hU$We`%z1W35yf4=OF=GiCN?qhQ zo(o~^8$1pzp?3Nrbld2I!RuPVaoVDmR4 -RO)5eoay_A`6XiUeB_~a_Q!x L(X&%m9Icc)-ao8 zA9I?ipji>X9*o~D2GU+s98sagjBYVP;jE}#gF5i^(F$8l_x>}77GXBvSVqjf=b=T7 z7shRQ{WvMD_u2h12JXSE)wZm^PIxFFhTujP{AoA{G; lGtKjcPyk?l37*+RT4@fotsehk<+Jb=@v;HI-u^Dwo;jM*_XMfr@ zY}Fj~-M+ZsyYI3X!4qVS2ZfFEUqY^Y+KiL(+%dS&itTaoj2B_9%%azDkbp~RZ`M-N zhPoGhu~qrvuxtd*%t32V{`_SjpO9ZNoEk5P%UnF3%&Ym}HN=L;eht~dPW58b;Jcr* zV;mpE3h>NDoUm6|ArHik|2a86d>qE%;pITvJm`1fMg0uBrWOX!Q7(21w|{B^nh$Il z25PXP#t19&Ls--z0lZyMl*32FsPTAq*WzEmQGS2S4U9Le`Q(YRhLE>JMXnFqgzS;- zo7E>^t}8Hnan$A^QSo$zJDY?MdJU5Anp^}Zj|CEwb%eid^ceZN*8QG}ic!0x?<*~b z7JXSmw=x0z?0M228M&Ng&NFNPr{n#-Y$mxz> - 8^(v(fr~}IIiGfnUSsltHt$w)dZhod% z)68Y}MCiw=;PAqnIdUCC9g69ceYrl99$)-jeulZA8sI3-iC_{Xx5Y3Aj{!!9oH5A? z{;rb#Bf*sZaB}WrJ+RcRQwe`gHsi%AlE=vdlg%^sO&nfMq;0q4G3tHdx>j;=Y31+7 zgfK#j1%b1LhEJRDB-*kl6QQ0*@dpbx*VlEROt&K$a+smU_9o0*Ma_uKvNI7pFh^f$ zq|V2*w^6xb=
_-!H2+*jY&l3!Qt@5N*3R-tPkJ} z`37d+(#bPM3>lC+8DGa(N7vEk`P{A3i77l(C@nsdK1lap!mcw$iT{`%72MCl zmN*pjWA>t~#dJWfn!|_Nb&sFj7kRXQH`ip#e;bGGdz$&0r0{pIW{BL+Qn$wwxnV%K z 8%*Eqi8wiMsE z)PV6yf1babm^P1Su?(@o0m+NR2RPzW&zAc(?8)tNT(aISbx1!*U4Io%t8Y1_*8Jef zl_To2Oz&vpl<`O!2i15j49#ub!QN#3RIXv{J>=9Vj#0(B77U#&jx99 Im(vf_ z8}aU(0RP4_22}He1&zgj3k&>VCeW-AL6tAsH{I5>hGr3n_seDiYDl81P=Yq$LC~y3 z;w1oc96M!kp$4aV44QGmij!{Cs3;mjiNOKBi%{!&$$ $2iRZR#Q2JR|4{;SgE9 z=o2GpL7*D&Jgu}X$8pcGgZQ&~0fz>}F=n}o48r(_7&FqNs52}me_Fs-=(o!e&biJR zCLC fuvLKo`PjOOj %P53F&|{=1*_e9rLiOW0>Y zdMz2Fx#D^d7+bWT>pPhSmK3K+71)8tU(F~? R}2NzNW|j7%ku(yl1mw zPA(GuzRweMjoPV$?gyNi!sFjLO(Gi4??4!wU5iVwVA2gMbU>QFI7C|4lQVo#mGv(0 z<}p<3ghL;Lh0Zcv7)o{QiTHQ*z8iU_y4ctE*NrL=2utERQfCrtaFCtl53|kS#0Uei z&x7eTXEYC!a@mc~yiUfElt)r_=)Iu||2|9JUN!BuYM5$!-3XKZP^o|MNQ-pHrjx zDflCT`P}C}gER6ur3L4UA0xbA{yz10`1xG7WttaoK;wxl_F%!{jrb2UF$)(J*TRF8 zltO)oIKc#u8MydcHa)JfhwZ #jG}6L9lz +eOuVuoB(q)l;F^I|wZxoNdA23vb6e4NGqK$2ZGn0LL z)twWTuw0&SOQ|?V@ +nJmQAWr!Wcl#&HsKa;Xs*gV3|bj nc9bZ*gnMGZbb*EMLJ}%* zwoMGaZTu;d-uE<7JSt?gG2Q~N?uUV}-7Y#xi*8y4x1&BAF5!OvMWGc;%%S*)2A}r# z%{IXK_fmQE9pXyF9*U~HB0X$_2_t A? zQVVamJ&R{(JqEk4@6|tu0gKr=$t?4I