diff --git a/.github/workflows/trigger-nimbus-sync.yml b/.github/workflows/trigger-nimbus-sync.yml new file mode 100644 index 0000000000000..603360eb1706e --- /dev/null +++ b/.github/workflows/trigger-nimbus-sync.yml @@ -0,0 +1,17 @@ +name: Trigger Nimbus Sync + +on: + push: + branches: [master] + workflow_dispatch: # Allow manual triggering + +jobs: + trigger-nimbus-sync: + runs-on: ubuntu-latest + steps: + - name: Trigger sync in supabase-nimbus + uses: peter-evans/repository-dispatch@v4 + with: + token: ${{ secrets.NIMBUS_SYNC_TOKEN }} + repository: supabase/supabase-nimbus + event-type: sync_from_upstream diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index cea90123024e1..ec803d13c2959 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -131,7 +131,8 @@ export function loadTableEditorStateFromLocalStorage( schema?: string | null ): SavedState | undefined { const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) - const jsonStr = localStorage.getItem(storageKey) + // Prefer sessionStorage (scoped to current tab) over localStorage + const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey) if (!jsonStr) return const json = JSON.parse(jsonStr) const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}` @@ -154,7 +155,7 @@ export function saveTableEditorStateToLocalStorage({ filters?: string[] }) { const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) - const savedStr = localStorage.getItem(storageKey) + const savedStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey) const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}` const config = { @@ -171,7 +172,9 @@ export function saveTableEditorStateToLocalStorage({ } else { savedJson = { [tableKey]: config } } + // Save to both localStorage and sessionStorage so it's consistent to current tab localStorage.setItem(storageKey, JSON.stringify(savedJson)) + sessionStorage.setItem(storageKey, JSON.stringify(savedJson)) } export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise( diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index 56b8644e1cb97..a9bb25d96aa98 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -124,7 +124,7 @@ export const RedirectUrlList = ({
{ > - {promptProPlanUpgrade && ( -
- -
- )} - { )} />
+ + {promptProPlanUpgrade && ( + + )} + {userSessionsForm.formState.isDirty && ( + + ) + } + + if (isError) { + return + } + + if (!isActiveHealthy) { + return ( + + ) + } + + if ( + !isLoading && + PITR_ENABLED && + !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix + ) { + return ( + + ) + } + + if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) { + return ( + <> + + + ) + } + + const additionalMonthlySpend = projectSpecToMonthlyPrice({ + targetVolumeSizeGb: targetVolumeSizeGb ?? 0, + targetComputeSize: targetComputeSize ?? 'nano', + planId: planId ?? 'free', + storageType: storageType as DiskType, + }) + + return ( +
+ { + setShowConfirmationDialog(false) + setShowNewProjectDialog(true) + }} + additionalMonthlySpend={additionalMonthlySpend} + /> + { + refetchCloneStatus() + setRefetchInterval(5000) + setShowNewProjectDialog(false) + }} + /> + {isRestoring ? ( + + + Restoration in progress + +

+ The new project {(restoringClone?.target_project as any)?.name || ''} is currently + being created. You'll be able to restore again once the project is ready. +

+ +
+
+ ) : null} + {previousClones?.length ? ( +
+

Previous restorations

+ + {previousClones?.map((c) => )} + +
+ ) : null} + {PITR_ENABLED ? ( + <> + { + setShowConfirmationDialog(true) + setRecoveryTimeTarget(v.recoveryTimeTargetUnix) + }} + earliestAvailableBackupUnix={ + cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0 + } + latestAvailableBackupUnix={ + cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0 + } + /> + + ) : ( + { + setSelectedBackupId(id) + setShowConfirmationDialog(true) + }} + /> + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx new file mode 100644 index 0000000000000..8b6fc9d6f66e7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx @@ -0,0 +1,25 @@ +import { CloneStatus } from 'data/projects/clone-status-query' +import { Badge } from 'ui' + +export const StatusBadge = ({ + status, +}: { + status: NonNullable[number]['status'] +}) => { + const statusTextMap = { + IN_PROGRESS: 'RESTORING', + COMPLETED: 'COMPLETED', + REMOVED: 'REMOVED', + FAILED: 'FAILED', + } + + if (status === 'IN_PROGRESS') { + return {statusTextMap[status]} + } + + if (status === 'FAILED') { + return {statusTextMap[status]} + } + + return {statusTextMap[status]} +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts index 7df4a3e4f98b3..789a42f1dbc25 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts @@ -1,3 +1,4 @@ +import { CloudProvider } from 'shared-data' import { z } from 'zod' import { ComputeInstanceAddonVariantId } from './DiskManagement.types' import { @@ -9,7 +10,6 @@ import { formatNumber, } from './DiskManagement.utils' import { DISK_LIMITS, DiskType } from './ui/DiskManagement.constants' -import { CloudProvider } from 'shared-data' const baseSchema = z.object({ storageType: z.enum(['io2', 'gp3']).describe('Type of storage: io2 or gp3'), @@ -41,13 +41,22 @@ const baseSchema = z.object({ .nullable(), }) -export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: CloudProvider) => { +export const CreateDiskStorageSchema = ({ + defaultTotalSize, + cloudProvider, +}: { + defaultTotalSize: number + cloudProvider: CloudProvider +}) => { const isFlyProject = cloudProvider === 'FLY' + const isAwsNimbusProject = cloudProvider === 'AWS_NIMBUS' + + const validateDiskConfiguration = !isFlyProject && !isAwsNimbusProject const schema = baseSchema.superRefine((data, ctx) => { const { storageType, totalSize, provisionedIOPS, throughput, maxSizeGb } = data - if (!isFlyProject && totalSize < 8) { + if (validateDiskConfiguration && totalSize < 8) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Allocated disk size must be at least 8 GB.', @@ -55,7 +64,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: }) } - if (!isFlyProject && totalSize < defaultTotalSize) { + if (validateDiskConfiguration && totalSize < defaultTotalSize) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Disk size cannot be reduced in size. Reduce your database size and then head to the Infrastructure settings and go through a Postgres version upgrade to right-size your disk.`, @@ -64,7 +73,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: } // Validate maxSizeGb cannot be lower than totalSize - if (!isFlyProject && !!maxSizeGb && maxSizeGb < totalSize) { + if (validateDiskConfiguration && !!maxSizeGb && maxSizeGb < totalSize) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Max disk size cannot be lower than the current disk size. Must be at least ${formatNumber(totalSize)} GB.`, @@ -72,7 +81,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: }) } - if (!isFlyProject && storageType === 'io2') { + if (validateDiskConfiguration && storageType === 'io2') { // Validation rules for io2 if (provisionedIOPS > DISK_LIMITS[DiskType.IO2].maxIops) { @@ -129,7 +138,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: } } - if (!isFlyProject && storageType === 'gp3') { + if (validateDiskConfiguration && storageType === 'gp3') { const maxIopsAllowedForDiskSizeWithGp3 = calculateMaxIopsAllowedForDiskSizeWithGp3(totalSize) if (provisionedIOPS > DISK_LIMITS[DiskType.GP3].maxIops) { diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index a79cfa8db1eab..cee0f6f73c742 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -3,7 +3,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' import { ChevronRight } from 'lucide-react' -import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -11,6 +10,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { MAX_WIDTH_CLASSES, PADDING_CLASSES, ScaffoldContainer } from 'components/layouts/Scaffold' import { DocsButton } from 'components/ui/DocsButton' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useDiskAttributesQuery, useRemainingDurationForDiskAttributeUpdate, @@ -30,6 +30,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider, + useIsAwsNimbusCloudProvider, useSelectedProjectQuery, } from 'hooks/misc/useSelectedProject' import { DOCS_URL, GB, PROJECT_STATUS } from 'lib/constants' @@ -78,6 +79,7 @@ export function DiskManagementForm() { const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled const isAws = useIsAwsCloudProvider() const isAwsK8s = useIsAwsK8sCloudProvider() + const isAwsNimbus = useIsAwsNimbusCloudProvider() const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { @@ -156,7 +158,10 @@ export function DiskManagementForm() { const form = useForm({ resolver: zodResolver( - CreateDiskStorageSchema(defaultValues.totalSize, project?.cloud_provider as CloudProvider) + CreateDiskStorageSchema({ + defaultTotalSize: defaultValues.totalSize, + cloudProvider: project?.cloud_provider as CloudProvider, + }) ), defaultValues, mode: 'onBlur', @@ -241,12 +246,14 @@ export function DiskManagementForm() { let willUpdateDiskConfiguration = false setMessageState(null) + // [Joshen] Skip disk configuration related stuff for AWS Nimbus try { if ( - payload.storageType !== form.formState.defaultValues?.storageType || - payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS || - payload.throughput !== form.formState.defaultValues?.throughput || - payload.totalSize !== form.formState.defaultValues?.totalSize + !isAwsNimbus && + (payload.storageType !== form.formState.defaultValues?.storageType || + payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS || + payload.throughput !== form.formState.defaultValues?.throughput || + payload.totalSize !== form.formState.defaultValues?.totalSize) ) { willUpdateDiskConfiguration = true @@ -260,9 +267,10 @@ export function DiskManagementForm() { } if ( - payload.growthPercent !== form.formState.defaultValues?.growthPercent || - payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb || - payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb + !isAwsNimbus && + (payload.growthPercent !== form.formState.defaultValues?.growthPercent || + payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb || + payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb) ) { await updateDiskAutoscaleConfig({ projectRef, @@ -310,15 +318,7 @@ export function DiskManagementForm() { type="default" visible={isPlanUpgradeRequired} title="Compute and Disk configuration is not available on the Free Plan" - actions={ - - } + actions={} description="You will need to upgrade to at least the Pro Plan to configure compute and disk" /> @@ -344,12 +344,16 @@ export function DiskManagementForm() {
) : null} + - + + {!(isAws || isAwsNimbus) && } + + { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() + const isAwsNimbus = useIsAwsNimbusCloudProvider() const { formState, getValues } = form @@ -213,19 +214,22 @@ export const DiskManagementReviewAndSubmitDialog = ({ const hasComputeChanges = form.formState.defaultValues?.computeSize !== form.getValues('computeSize') const hasTotalSizeChanges = - form.formState.defaultValues?.totalSize !== form.getValues('totalSize') + !isAwsNimbus && form.formState.defaultValues?.totalSize !== form.getValues('totalSize') const hasStorageTypeChanges = - form.formState.defaultValues?.storageType !== form.getValues('storageType') + !isAwsNimbus && form.formState.defaultValues?.storageType !== form.getValues('storageType') const hasThroughputChanges = - form.formState.defaultValues?.throughput !== form.getValues('throughput') + !isAwsNimbus && form.formState.defaultValues?.throughput !== form.getValues('throughput') const hasIOPSChanges = + !isAwsNimbus && form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS') const hasGrowthPercentChanges = - form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent') + !isAwsNimbus && form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent') const hasMinIncrementChanges = + !isAwsNimbus && form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb') - const hasMaxSizeChanges = form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb') + const hasMaxSizeChanges = + !isAwsNimbus && form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb') const hasDiskConfigChanges = hasIOPSChanges || diff --git a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx index 2d740de982bfa..864c3338fa164 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx @@ -18,9 +18,7 @@ const CATEGORIES = [ ] as const export const AvailableIntegrations = () => { - const { integrationsShowStripeWrapper } = useIsFeatureEnabled([ - 'integrations:show_stripe_wrapper', - ]) + const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) const [selectedCategory, setSelectedCategory] = useQueryState( 'category', @@ -43,9 +41,9 @@ export const AvailableIntegrations = () => { const installedIds = installedIntegrations.map((i) => i.id) // available integrations for install - const availableIntegrations = integrationsShowStripeWrapper + const availableIntegrations = integrationsWrappers ? allIntegrations - : allIntegrations.filter((x) => x.id !== 'stripe_wrapper') + : allIntegrations.filter((x) => !x.id.endsWith('_wrapper')) const integrationsByCategory = selectedCategory === 'all' ? availableIntegrations diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 9df420f1b1318..0834121c2ea63 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useFDWsQuery } from 'data/fdw/fdws-query' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { EMPTY_ARR } from 'lib/void' import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils' @@ -10,6 +11,15 @@ import { INTEGRATIONS } from './Integrations.constants' export const useInstalledIntegrations = () => { const { data: project } = useSelectedProjectQuery() + const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) + + const allIntegrations = useMemo(() => { + if (integrationsWrappers) { + return INTEGRATIONS + } else { + return INTEGRATIONS.filter((integration) => !integration.id.endsWith('_wrapper')) + } + }, [integrationsWrappers]) const { data, @@ -47,28 +57,30 @@ export const useInstalledIntegrations = () => { const wrappers = useMemo(() => data ?? EMPTY_ARR, [data]) const installedIntegrations = useMemo(() => { - return INTEGRATIONS.filter((i) => { - // special handling for supabase webhooks - if (i.id === 'webhooks') { - return isHooksEnabled - } - if (i.type === 'wrapper') { - return wrappers.find((w) => wrapperMetaComparator(i.meta, w)) - } - if (i.type === 'postgres_extension') { - return i.requiredExtensions.every((extName) => { - const foundExtension = (extensions ?? []).find((ext) => ext.name === extName) - return !!foundExtension?.installed_version - }) - } - return false - }).sort((a, b) => a.name.localeCompare(b.name)) + return allIntegrations + .filter((i) => { + // special handling for supabase webhooks + if (i.id === 'webhooks') { + return isHooksEnabled + } + if (i.type === 'wrapper') { + return wrappers.find((w) => wrapperMetaComparator(i.meta, w)) + } + if (i.type === 'postgres_extension') { + return i.requiredExtensions.every((extName) => { + const foundExtension = (extensions ?? []).find((ext) => ext.name === extName) + return !!foundExtension?.installed_version + }) + } + return false + }) + .sort((a, b) => a.name.localeCompare(b.name)) }, [wrappers, extensions, isHooksEnabled]) // available integrations are all integrations that can be installed. If an integration can't be installed (needed // extensions are not available on this DB image), the UI will provide a tooltip explaining why. const availableIntegrations = useMemo( - () => INTEGRATIONS.sort((a, b) => a.name.localeCompare(b.name)), + () => allIntegrations.sort((a, b) => a.name.localeCompare(b.name)), [] ) diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index e4b49eecc6ea5..92b454ebc25f4 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -3,7 +3,6 @@ import { useParams } from 'common' import dayjs from 'dayjs' import { ArrowDown, ArrowUp, RefreshCw, User } from 'lucide-react' import Image from 'next/legacy/image' -import Link from 'next/link' import { useEffect, useState } from 'react' import { LogDetailsPanel } from 'components/interfaces/AuditLogs' @@ -15,6 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FilterPopover } from 'components/ui/FilterPopover' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { AuditLog, @@ -231,11 +231,9 @@ export const AuditLogs = () => {
- + + Upgrade subscription +
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx index ed79e6d5687ca..17a8c0171133d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx @@ -1,12 +1,13 @@ import Link from 'next/link' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { PricingMetric } from 'data/analytics/org-daily-stats-query' import type { OrgSubscription } from 'data/subscriptions/types' import type { OrgUsageResponse } from 'data/usage/org-usage-query' import { formatCurrency } from 'lib/helpers' import { ChevronRight } from 'lucide-react' import { useMemo } from 'react' -import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' +import { cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' import { billingMetricUnit, formatUsage } from '../helpers' import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constants' @@ -156,13 +157,9 @@ export const BillingMetric = ({ ) : (
- + + Upgrade +
)} diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx index 3d8359288c802..7bd3b6df393ee 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -1,5 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod' -import Link from 'next/link' import { useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import z from 'zod' @@ -8,6 +7,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { InlineLink } from 'components/ui/InlineLink' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useSSOConfigCreateMutation } from 'data/sso/sso-config-create-mutation' import { useOrgSSOConfigQuery } from 'data/sso/sso-config-query' import { useSSOConfigUpdateMutation } from 'data/sso/sso-config-update-mutation' @@ -67,7 +67,7 @@ export type SSOConfigFormSchema = z.infer export const SSOConfig = () => { const FORM_ID = 'sso-config-form' - const { data: organization, isLoading: isLoadingOrganization } = useSelectedOrganizationQuery() + const { data: organization } = useSelectedOrganizationQuery() const plan = organization?.plan.id const canSetupSSOConfig = ['team', 'enterprise'].includes(plan ?? '') @@ -179,13 +179,7 @@ export const SSOConfig = () => {
- +
diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx index 4254c054a91d9..22c0513dfc9b7 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -12,6 +11,7 @@ import AlertError from 'components/ui/AlertError' import { InlineLink } from 'components/ui/InlineLink' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation' import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query' @@ -128,11 +128,9 @@ export const SecuritySettings = () => {
- + + Upgrade subscription +
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 8c420f7500869..6872cf036db85 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -10,6 +10,7 @@ import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InformationBox from 'components/ui/InformationBox' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' @@ -309,13 +310,7 @@ export const InviteMemberButton = () => { {(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && ( - + )} diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index b7b4b05a9e520..606d9df09105d 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -9,7 +9,8 @@ import { useParams } from 'common' import { ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { InlineLink } from 'components/ui/InlineLink' +import { ToggleSpendCapButton } from 'components/ui/ToggleSpendCapButton' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation' @@ -61,6 +62,7 @@ export const RealtimeSettings = () => { schema: 'realtime', }) + const isFreePlan = organization?.plan.id === 'free' const isUsageBillingEnabled = organization?.usage_billing_enabled // Check if RLS policies exist for realtime.messages table @@ -255,21 +257,27 @@ export const RealtimeSettings = () => { {isSuccessOrganization && !isUsageBillingEnabled && ( - - You may adjust this setting in the{' '} - - organization billing settings - - - } - /> + +
+
+
+ Spend cap needs to be disabled to configure this value +
+

+ {isFreePlan + ? 'Upgrade to the Pro plan first to disable spend cap' + : 'You may adjust this setting in the organization billing settings'} +

+
+
+ {false ? ( + + ) : ( + + )} +
+
+
)} diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index c172b266acf4f..172f0c9966c23 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation' @@ -271,15 +272,7 @@ const PITRSidePanel = () => { variant="info" className="mb-4" title="Changing your Point-In-Time-Recovery is only available on the Pro Plan" - actions={ - - } + actions={} > Upgrade your plan to change PITR for your project diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx index 70c996685443f..65f5cb57fcc32 100644 --- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx @@ -13,8 +13,9 @@ import Panel from 'components/ui/Panel' import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation' import { useDatabaseSizeQuery } from 'data/database/database-size-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { DOCS_URL } from 'lib/constants' import { formatBytes } from 'lib/helpers' @@ -29,6 +30,9 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() + const isAwsNimbus = useIsAwsNimbusCloudProvider() + const { reportsAll } = useIsFeatureEnabled(['reports:all']) + const [{ show_increase_disk_size_modal }, setUrlParams] = useUrlState() const showIncreaseDiskSizeModal = show_increase_disk_size_modal === 'true' const setShowIncreaseDiskSizeModal = (value: SetStateAction) => { @@ -80,9 +84,10 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) Supabase employs auto-scaling storage and allows for manual disk size adjustments when necessary

-
+ {!isAwsNimbus && ( setShowIncreaseDiskSizeModal(true)} tooltip={{ @@ -96,7 +101,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) > Increase disk size -
+ )}
@@ -112,15 +117,17 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) {currentDiskSize} GB
-
- -
+ {reportsAll && ( +
+ +
+ )}
diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 070aa1788e432..f138b40cc576c 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -67,7 +67,7 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( ) -const NetworkRestrictions = () => { +export const NetworkRestrictions = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() const [isAddingAddress, setIsAddingAddress] = useState() @@ -300,5 +300,3 @@ const NetworkRestrictions = () => { ) } - -export default NetworkRestrictions diff --git a/apps/studio/components/interfaces/Settings/Database/index.ts b/apps/studio/components/interfaces/Settings/Database/index.ts index 0b38459d61419..94970d9e98532 100644 --- a/apps/studio/components/interfaces/Settings/Database/index.ts +++ b/apps/studio/components/interfaces/Settings/Database/index.ts @@ -1,2 +1 @@ export { ConnectionPooling } from './ConnectionPooling/ConnectionPooling' -export { default as NetworkRestrictions } from './NetworkRestrictions/NetworkRestrictions' diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index b58a5ff716211..46d19f4d58879 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -10,6 +10,7 @@ import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProjectByRefQuery, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -31,6 +32,10 @@ const General = () => { const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref) const isBranch = parentProject !== undefined + const { projectSettingsRestartProject } = useIsFeatureEnabled([ + 'project_settings:restart_project', + ]) + const formId = 'project-general-settings' const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' } const { can: canUpdateProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { @@ -122,7 +127,9 @@ const General = () => {
-

Restart project

+

+ {projectSettingsRestartProject ? 'Restart project' : 'Restart database'} +

Your project will not be available for a few minutes. diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 55c0f44e7f327..ac120c3008c51 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -12,6 +12,7 @@ import { useProjectRestartMutation } from 'data/projects/project-restart-mutatio import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -31,6 +32,10 @@ const RestartServerButton = () => { const isAwsK8s = useIsAwsK8sCloudProvider() const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>() + const { projectSettingsRestartProject } = useIsFeatureEnabled([ + 'project_settings:restart_project', + ]) + const projectRef = project?.ref ?? '' const projectRegion = project?.region ?? '' @@ -88,68 +93,80 @@ const RestartServerButton = () => { return ( <> -

- + setServiceToRestart('project')} + tooltip={{ + content: { + side: 'bottom', + text: projectRestartDisabled + ? 'Project restart is currently disabled' + : !canRestartProject + ? 'You need additional permissions to restart this project' + : !isProjectActive + ? 'Unable to restart project as project is not active' + : isAwsK8s + ? 'Project restart is not supported for AWS (Revamped) projects' + : undefined, + }, + }} + > + Restart project + + {canRestartProject && isProjectActive && !projectRestartDisabled && ( + + +
+ ) : ( +
+ Restart database + + )} () const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() + const isAws = useIsAwsCloudProvider() + const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) + const [view, setView] = useState<'flow' | 'map'>('flow') const [showDeleteAllModal, setShowDeleteAllModal] = useState(false) const { showNewReplicaPanel, setShowNewReplicaPanel } = useShowNewReplicaPanel() @@ -225,7 +233,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU {isError && } {isSuccessReplicas && !isLoadingProject && ( <> - {!diagramOnly && ( + {!diagramOnly && infrastructureReadReplicas && (
)}
- {project?.cloud_provider === 'AWS' && ( + {isAws && (
+ ) +} diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx new file mode 100644 index 0000000000000..8c9db51c95c43 --- /dev/null +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -0,0 +1,45 @@ +import Link from 'next/link' +import { PropsWithChildren } from 'react' + +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Button } from 'ui' + +export const PLAN_REQUEST_EMPTY_PLACEHOLDER = + '' + +interface UpgradePlanButtonProps { + source?: string + type?: 'default' | 'primary' + plan?: 'Pro' | 'Team' | 'Enterprise' + href?: string // [Joshen] As an override if needed (Used in UpgradeToPro) + disabled?: boolean +} + +export const UpgradePlanButton = ({ + source, + type = 'default', + plan, + href: propsHref, + disabled, + children, +}: PropsWithChildren) => { + const { data: organization } = useSelectedOrganizationQuery() + const slug = organization?.slug ?? '_' + + const { billingAll } = useIsFeatureEnabled(['billing:all']) + + const subject = `Enquiry to upgrade ${!!plan ? `to ${plan} ` : ''}plan for organization` + const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}\nRequested plan: ${plan ?? PLAN_REQUEST_EMPTY_PLACEHOLDER}` + + const href = billingAll + ? propsHref ?? + `/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}` + : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}` + + return ( + + ) +} diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index 7cf9aa395238a..dd4e1f07dbcbe 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -1,13 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' import { ReactNode } from 'react' import { useFlag } from 'common' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button, cn } from 'ui' +import { cn } from 'ui' import { ButtonTooltip } from './ButtonTooltip' +import { UpgradePlanButton } from './UpgradePlanButton' interface UpgradeToProProps { icon?: ReactNode @@ -74,23 +74,21 @@ const UpgradeToPro = ({ {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} ) : ( - + {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} + )}
diff --git a/apps/studio/data/projects/clone-status-query.ts b/apps/studio/data/projects/clone-status-query.ts index 9f9b6a5253b17..6a85892d1388f 100644 --- a/apps/studio/data/projects/clone-status-query.ts +++ b/apps/studio/data/projects/clone-status-query.ts @@ -1,7 +1,10 @@ -import { get, handleError } from 'data/fetchers' -import { projectKeys } from './keys' import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' +import { projectKeys } from './keys' + +export type CloneStatus = components['schemas']['ProjectClonedStatusResponse'] export async function getCloneStatus(projectRef?: string) { if (!projectRef) throw new Error('Project ref is required') diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index b5ddceaf4270b..91af532e4a4be 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -1,6 +1,9 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { sqlEventParser } from 'lib/sql-event-parser' import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query' // [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query @@ -31,12 +34,38 @@ export const useExecuteSqlMutation = ({ 'mutationFn' > = {}) => { const queryClient = useQueryClient() + const { mutate: sendEvent } = useSendEventMutation() + const { data: org } = useSelectedOrganizationQuery() + return useMutation( (args) => executeSql(args), { async onSuccess(data, variables, context) { const { contextualInvalidation, sql, projectRef } = variables + // Track all table-related events from SQL execution + try { + const tableEvents = sqlEventParser.getTableEvents(sql) + tableEvents.forEach((event) => { + if (projectRef) { + sendEvent({ + action: event.type, + properties: { + method: 'sql_editor', + schema_name: event.schema, + table_name: event.tableName, + }, + groups: { + project: projectRef, + ...(org?.slug && { organization: org.slug }), + }, + }) + } + }) + } catch (error) { + console.error('Failed to parse SQL for telemetry:', error) + } + // [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate const sqlLower = sql.toLowerCase() const isMutationSQL = diff --git a/apps/studio/data/table-rows/table-row-create-mutation.ts b/apps/studio/data/table-rows/table-row-create-mutation.ts index e31cc4609dc4b..f901c10ce8416 100644 --- a/apps/studio/data/table-rows/table-row-create-mutation.ts +++ b/apps/studio/data/table-rows/table-row-create-mutation.ts @@ -3,6 +3,8 @@ import { toast } from 'sonner' import { Query } from '@supabase/pg-meta/src/query' import { executeSql } from 'data/sql/execute-sql-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation' import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' import type { ResponseError } from 'types' @@ -65,12 +67,33 @@ export const useTableRowCreateMutation = ({ 'mutationFn' > = {}) => { const queryClient = useQueryClient() + const { mutate: sendEvent } = useSendEventMutation() + const { data: org } = useSelectedOrganizationQuery() return useMutation( (vars) => createTableRow(vars), { async onSuccess(data, variables, context) { const { projectRef, table } = variables + + // Track data insertion event + try { + sendEvent({ + action: 'table_data_added', + properties: { + method: 'table_editor', + schema_name: table.schema, + table_name: table.name, + }, + groups: { + project: projectRef, + ...(org?.slug && { organization: org.slug }), + }, + }) + } catch (error) { + console.error('Failed to track table data insertion event:', error) + } + await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id)) await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 33d9404c64ee0..0adedd2129244 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -16,6 +16,7 @@ import { checkForWithClause, } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { DOCS_URL } from 'lib/constants' export interface LogsQueryHook { @@ -46,6 +47,8 @@ const useLogsQuery = ( : defaultHelper.calcTo(), }) + const { logsMetadata } = useIsFeatureEnabled(['logs:metadata']) + useEffect(() => { setParams((prev) => ({ ...prev, @@ -113,10 +116,19 @@ const useLogsQuery = ( setParams((prev) => ({ ...prev, sql: newQuery })) } + const logData = (data?.result ?? []).map((x) => { + if (logsMetadata) { + return x + } else { + const { metadata, ...log } = x + return log + } + }) + return { params, isLoading: (_enabled && isLoading) || isRefetching, - logData: data?.result ?? [], + logData: logData, error, changeQuery, runQuery: () => refetch(), diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx index b773b45e67398..6e0dbe9cc50d6 100644 --- a/apps/studio/hooks/analytics/useSingleLog.tsx +++ b/apps/studio/hooks/analytics/useSingleLog.tsx @@ -8,6 +8,7 @@ import type { } from 'components/interfaces/Settings/Logs/Logs.types' import { genSingleLogQuery } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' interface SingleLogHook { data: LogData | undefined @@ -35,6 +36,8 @@ function useSingleLog({ const enabled = Boolean(id && table) + const { logsMetadata } = useIsFeatureEnabled(['logs:metadata']) + const { data, error: rcError, @@ -67,8 +70,11 @@ function useSingleLog({ let error: null | string | object = rcError ? (rcError as any).message : null const result = data?.result ? data.result[0] : undefined + return { - data: result, + data: !!result + ? { ...result, metadata: logsMetadata ? result?.metadata : undefined } + : undefined, isLoading: (enabled && isLoading) || isRefetching, error, refresh: () => refetch(), diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index a84484def9c99..4aa8b0216c8d0 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -1,6 +1,8 @@ import type { CloudProvider } from 'shared-data' export type CustomContentTypes = { + appTitle: string + dashboardAuthCustomProvider: string organizationLegalDocuments: { diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index 2702c8c2728c8..10dfa88265fbd 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -1,6 +1,8 @@ { "$schema": "./custom-content.schema.json", + "app:title": null, + "dashboard_auth:custom_provider": null, "organization:legal_documents": null, diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index b7adc83af68c2..943d54512736c 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -1,6 +1,8 @@ { "$schema": "./custom-content.schema.json", + "app:title": "Test Title", + "dashboard_auth:custom_provider": "Nimbus", "organization:legal_documents": [ diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index af9bd62d2720a..208c77c01fdea 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -6,6 +6,11 @@ "type": "string" }, + "app:title": { + "type": ["string", "null"], + "description": "Render a custom HTML title for the site" + }, + "dashboard_auth:custom_provider": { "type": ["string", "null"], "description": "Show a custom provider on the sign in page (Continue with X)" @@ -85,6 +90,7 @@ } }, "required": [ + "app:title", "organization:legal_documents", "project_homepage:client_libraries", "project_homepage:example_projects", diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index 88a4d3c2eb3e8..6edab42d9bdc0 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -53,6 +53,13 @@ export const useIsAwsK8sCloudProvider = () => { return isAwsK8s } +export const useIsAwsNimbusCloudProvider = () => { + const { data: project } = useSelectedProjectQuery() + const isAwsNimbus = project?.cloud_provider === PROVIDERS.AWS_NIMBUS.id + + return isAwsNimbus +} + export const useIsOrioleDb = () => { const { data: project } = useSelectedProjectQuery() const isOrioleDb = project?.dbVersion?.endsWith('orioledb') diff --git a/apps/studio/lib/sql-event-parser.test.ts b/apps/studio/lib/sql-event-parser.test.ts new file mode 100644 index 0000000000000..dafaa37712a45 --- /dev/null +++ b/apps/studio/lib/sql-event-parser.test.ts @@ -0,0 +1,462 @@ +import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' +import { describe, expect, it } from 'vitest' +import { sqlEventParser } from './sql-event-parser' + +describe('SQL Event Parser', () => { + describe('CREATE TABLE detection', () => { + it('detects basic CREATE TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects CREATE TABLE with schema', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'users', + }) + }) + + it('detects CREATE TABLE IF NOT EXISTS', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'user_table', + }) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + + it('detects CREATE TEMPORARY TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + + it('detects CREATE TEMP TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + + it('detects CREATE UNLOGGED TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'fast_table', + }) + }) + + it('detects CREATE TEMP TABLE IF NOT EXISTS', () => { + const results = sqlEventParser.getTableEvents( + 'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + }) + + describe('INSERT detection', () => { + it('detects basic INSERT INTO', () => { + const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')") + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects INSERT with schema', () => { + const results = sqlEventParser.getTableEvents( + "INSERT INTO public.users (name) VALUES ('John')" + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'auth', + tableName: 'users', + }) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"') + expect(results).toHaveLength(0) + }) + }) + + describe('COPY detection', () => { + it('detects basic COPY FROM', () => { + const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'") + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects COPY with schema', () => { + const results = sqlEventParser.getTableEvents( + "COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER" + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'auth', + tableName: 'users', + }) + }) + + it('returns empty array for COPY TO', () => { + const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'") + expect(results).toHaveLength(0) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + }) + + describe('SELECT INTO detection', () => { + it('detects SELECT INTO', () => { + const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('detects SELECT INTO with schema', () => { + const results = sqlEventParser.getTableEvents( + 'SELECT id, name INTO public.new_users FROM users' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'new_users', + }) + }) + + it('detects CREATE TABLE AS SELECT', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => { + const results = sqlEventParser.getTableEvents( + 'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents( + 'SELECT * INTO "backup"."users_2024" FROM users' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'backup', + tableName: 'users_2024', + }) + }) + + it('returns empty array for regular SELECT', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + }) + + describe('RLS detection', () => { + it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects short form ENABLE RLS', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects with schema', () => { + const results = sqlEventParser.getTableEvents( + 'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles other ALTER TABLE statements in between', () => { + const results = sqlEventParser.getTableEvents( + 'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('returns empty array for disabling RLS', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY') + expect(results).toHaveLength(0) + }) + }) + + describe('ReDoS protection', () => { + it('handles extremely long identifier names efficiently', () => { + const longIdentifier = 'a'.repeat(10000) + const sql = `CREATE TABLE ${longIdentifier} (id INT)` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: longIdentifier, + }) + }) + + it('handles nested dots in schema names without catastrophic backtracking', () => { + const maliciousInput = 'a.'.repeat(1000) + 'table' + const sql = `CREATE TABLE ${maliciousInput} (id INT)` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results.length).toBeGreaterThan(0) + }) + + it('handles pathological SELECT INTO patterns', () => { + const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users' + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(maliciousSQL) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'table', + }) + }) + + it('handles ALTER TABLE with many operations between', () => { + const manyOperations = 'ADD COLUMN test INT, '.repeat(100) + const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('handles mixed quotes and backticks efficiently', () => { + const mixedQuotes = '`"`.'.repeat(100) + 'tablename' + const sql = `CREATE TABLE ${mixedQuotes} (id INT)` + + const startTime = Date.now() + sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + }) + }) + + describe('Edge cases and special characters', () => { + it('handles Unicode identifiers', () => { + const sql = 'CREATE TABLE 用户表 (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(0) + }) + + it('handles identifiers with numbers', () => { + const sql = 'CREATE TABLE table123 (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'table123', + }) + }) + + it('handles identifiers with underscores', () => { + const sql = 'CREATE TABLE user_accounts (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'user_accounts', + }) + }) + + it('handles escaped quotes in identifiers', () => { + const sql = 'CREATE TABLE "user""table" (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'usertable', + }) + }) + + it('handles dollar-quoted strings in SQL', () => { + const sql = ` + CREATE TABLE users (id INT); + INSERT INTO logs VALUES ($$CREATE TABLE fake$$); + INSERT INTO users VALUES (1); + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(3) + expect(results[0].type).toBe(TABLE_EVENT_ACTIONS.TableCreated) + expect(results[0]).toMatchObject({ tableName: 'users' }) + expect(results[1].type).toBe(TABLE_EVENT_ACTIONS.TableCreated) + expect(results[1]).toMatchObject({ tableName: 'fake' }) + expect(results[2].type).toBe(TABLE_EVENT_ACTIONS.TableDataAdded) + }) + + it('handles SQL injection attempts safely', () => { + const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)" + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + }) + + describe('getTableEvents', () => { + it('filters only table-related events', () => { + const sql = ` + CREATE TABLE users (id INT); + CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; + INSERT INTO users (id) VALUES (1); + ALTER TABLE users ENABLE RLS; + CREATE VIEW user_view AS SELECT * FROM users; + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(3) + expect(results.map((r) => r.type)).toEqual([ + TABLE_EVENT_ACTIONS.TableCreated, + TABLE_EVENT_ACTIONS.TableDataAdded, + TABLE_EVENT_ACTIONS.TableRLSEnabled, + ]) + }) + + it('returns empty array for non-table SQL', () => { + const sql = ` + CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; + CREATE VIEW user_view AS SELECT * FROM users; + SELECT * FROM users; + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(0) + }) + }) +}) diff --git a/apps/studio/lib/sql-event-parser.ts b/apps/studio/lib/sql-event-parser.ts new file mode 100644 index 0000000000000..dece7fd5c858d --- /dev/null +++ b/apps/studio/lib/sql-event-parser.ts @@ -0,0 +1,128 @@ +/** + * Lightweight SQL parser for telemetry event detection. + * + * [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a + * browser-compatible version is available. + */ +import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants' + +export interface TableEventDetails { + type: TableEventAction + schema?: string + tableName?: string +} + +type Detector = { + type: TableEventAction + patterns: RegExp[] +} + +export class SQLEventParser { + private static DETECTORS: Detector[] = [ + { + type: TABLE_EVENT_ACTIONS.TableCreated, + patterns: [ + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i, + /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /SELECT\s+.*?\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/is, + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+AS\s+SELECT/i, + ], + }, + { + type: TABLE_EVENT_ACTIONS.TableDataAdded, + patterns: [ + /INSERT\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /COPY\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+FROM/i, + ], + }, + { + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + patterns: [ + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i, + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+RLS/i, + ], + }, + ] + + private cleanIdentifier(identifier?: string) { + return identifier?.replace(/["`']/g, '').replace(/\.$/, '') + } + + private match(sql: string): TableEventDetails | null { + for (const { type, patterns } of SQLEventParser.DETECTORS) { + for (const pattern of patterns) { + const match = sql.match(pattern) + if (match?.groups) { + return { + type, + schema: this.cleanIdentifier(match.groups.schema), + tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object), + } + } + } + } + return null + } + + private splitStatements(sql: string): string[] { + // Regex matches: + // - single quotes ('...') with escapes + // - double quotes ("...") + // - dollar-quoted blocks ($$...$$ or $tag$...$tag$) + // - semicolons + // - everything else + const tokens = + sql.match( + /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g + ) || [] + + const statements: string[] = [] + let current = '' + + for (const token of tokens) { + if (token === ';') { + if (current.trim()) statements.push(current.trim()) + current = '' + } else { + current += token + } + } + + if (current.trim()) { + statements.push(current.trim()) + } + + return statements + } + + private deduplicate(events: TableEventDetails[]): TableEventDetails[] { + const seen = new Set() + return events.filter((e) => { + const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + } + + private removeComments(sql: string): string { + return sql + .replace(/--.*?$/gm, '') // line comments + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + } + + getTableEvents(sql: string): TableEventDetails[] { + const statements = this.splitStatements(this.removeComments(sql)) + const results: TableEventDetails[] = [] + + for (const stmt of statements) { + const event = this.match(stmt) + if (event) results.push(event) + } + + return this.deduplicate(results) + } +} + +export const sqlEventParser = new SQLEventParser() diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index a4034e0b3efe7..7eea0ec1d046e 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -50,6 +50,7 @@ import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvid import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { AuthProvider } from 'lib/auth' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants' import { ProfileProvider } from 'lib/profile' @@ -84,6 +85,7 @@ loader.config({ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const queryClient = useRootQueryClient() + const { appTitle } = useCustomContent(['app:title']) const getLayout = Component.getLayout ?? ((page) => page) @@ -127,7 +129,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { > - Supabase + {appTitle ?? 'Supabase'} {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */} diff --git a/apps/studio/pages/claim-project.tsx b/apps/studio/pages/claim-project.tsx index f534495011e5f..a109a561195cf 100644 --- a/apps/studio/pages/claim-project.tsx +++ b/apps/studio/pages/claim-project.tsx @@ -1,5 +1,5 @@ import Head from 'next/head' -import { useMemo, useState } from 'react' +import { PropsWithChildren, useMemo, useState } from 'react' import { useParams } from 'common' import { ProjectClaimBenefits } from 'components/interfaces/Organization/ProjectClaim/benefits' @@ -10,10 +10,24 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query' import { useOrganizationProjectClaimQuery } from 'data/organizations/organization-project-claim-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { withAuth } from 'hooks/misc/withAuth' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' +const ClaimProjectPageLayout = ({ children }: PropsWithChildren) => { + const { appTitle } = useCustomContent(['app:title']) + + return ( + <> + + Claim project | {appTitle || 'Supabase'} + + {children} + + ) +} + const ClaimProjectPage: NextPageWithLayout = () => { const { auth_id, token: claimToken } = useParams() const [selectedOrgSlug, setSelectedOrgSlug] = useState() @@ -111,12 +125,9 @@ const ClaimProjectPage: NextPageWithLayout = () => { } ClaimProjectPage.getLayout = (page) => ( - <> - - Claim project | Supabase - +
{page}
- +
) export default withAuth(ClaimProjectPage) diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index a95b71e6339ad..7f18c92f157f0 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -1,42 +1,22 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ChevronRightIcon, Loader2 } from 'lucide-react' -import Link from 'next/link' -import { useState } from 'react' - +import { useParams } from 'common' import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav' -import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form' -import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList' -import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog' -import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog' -import { projectSpecToMonthlyPrice } from 'components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils' -import { DiskType } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants' -import { Markdown } from 'components/interfaces/Markdown' +import { RestoreToNewProject } from 'components/interfaces/Database/RestoreToNewProject/RestoreToNewProject' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import AlertError from 'components/ui/AlertError' import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import Panel from 'components/ui/Panel' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import UpgradeToPro from 'components/ui/UpgradeToPro' -import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' -import { useCloneBackupsQuery } from 'data/projects/clone-query' -import { useCloneStatusQuery } from 'data/projects/clone-status-query' -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { - useIsAwsK8sCloudProvider, - useIsOrioleDb, - useSelectedProjectQuery, -} from 'hooks/misc/useSelectedProject' -import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' -import { getDatabaseMajorVersion } from 'lib/helpers' +import { UnknownInterface } from 'components/ui/UnknownInterface' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' -import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Button } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' const RestoreToNewProjectPage: NextPageWithLayout = () => { + const { ref } = useParams() + const { databaseRestoreToNewProject } = useIsFeatureEnabled(['database:restore_to_new_project']) + + if (!databaseRestoreToNewProject) { + return + } + return ( @@ -60,367 +40,4 @@ RestoreToNewProjectPage.getLayout = (page) => ( ) -const RestoreToNewProject = () => { - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const isFreePlan = organization?.plan?.id === 'free' - const isOrioleDb = useIsOrioleDb() - const isAwsK8s = useIsAwsK8sCloudProvider() - - const [refetchInterval, setRefetchInterval] = useState(false) - const [selectedBackupId, setSelectedBackupId] = useState(null) - const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) - const [showNewProjectDialog, setShowNewProjectDialog] = useState(false) - const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(null) - - const { - data: cloneBackups, - error, - isLoading: cloneBackupsLoading, - isError, - } = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan }) - - const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - - const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( - PermissionAction.READ, - 'physical_backups' - ) - const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions( - PermissionAction.INFRA_EXECUTE, - 'queue_job.restore.prepare' - ) - const PITR_ENABLED = cloneBackups?.pitr_enabled - const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled - const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '') - const IS_PG15_OR_ABOVE = dbVersion >= 15 - const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb - const targetComputeSize = cloneBackups?.target_compute_size - const planId = organization?.plan?.id ?? 'free' - const { data } = useDiskAttributesQuery({ projectRef: project?.ref }) - const storageType = data?.attributes?.type ?? 'gp3' - - const { - data: cloneStatus, - refetch: refetchCloneStatus, - isLoading: cloneStatusLoading, - } = useCloneStatusQuery( - { - projectRef: project?.ref, - }, - { - refetchInterval, - refetchOnWindowFocus: false, - onSuccess: (data) => { - const hasTransientState = data?.clones.some((c) => c.status === 'IN_PROGRESS') - if (!hasTransientState) setRefetchInterval(false) - }, - enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED, - } - ) - const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false - const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading - - const previousClones = cloneStatus?.clones - const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS') - const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS') - - const StatusBadge = ({ - status, - }: { - status: NonNullable[number]['status'] - }) => { - const statusTextMap = { - IN_PROGRESS: 'RESTORING', - COMPLETED: 'COMPLETED', - REMOVED: 'REMOVED', - FAILED: 'FAILED', - } - - if (status === 'IN_PROGRESS') { - return {statusTextMap[status]} - } - - if (status === 'FAILED') { - return {statusTextMap[status]} - } - - return {statusTextMap[status]} - } - - const PreviousRestoreItem = ({ - clone, - }: { - clone: NonNullable[number] - }) => { - if (clone.status === 'REMOVED') { - return ( -
-
{(clone.target_project as any).name}
-
- -
-
- -
-
- ) - } else { - return ( - -
{(clone.target_project as any).name}
-
- -
-
- -
-
- -
- - ) - } - } - - if (organization?.managed_by === 'vercel-marketplace') { - return ( - - ) - } - - if (isFreePlan) { - return ( - - ) - } - - if (isOrioleDb) { - return ( - - ) - } - - if (isAwsK8s) { - return ( - - ) - } - - if (!canReadPhysicalBackups) { - return - } - - if (!canTriggerPhysicalBackups) { - return - } - - if (!IS_PG15_OR_ABOVE) { - return ( - - - - ) - } - - if (!PHYSICAL_BACKUPS_ENABLED) { - return ( - - Physical backups must be enabled to restore your database to a new project. -
Find out more about how backups work at supabase{' '} - - in our docs - - . - - } - /> - ) - } - - if (isLoading) { - return - } - - if (IS_CLONED_PROJECT) { - return ( - - - - - ) - } - - if (isError) { - return - } - - if (!isActiveHealthy) { - return ( - - ) - } - - if ( - !isLoading && - PITR_ENABLED && - !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix - ) { - return ( - - ) - } - - if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) { - return ( - <> - - - ) - } - - const additionalMonthlySpend = projectSpecToMonthlyPrice({ - targetVolumeSizeGb: targetVolumeSizeGb ?? 0, - targetComputeSize: targetComputeSize ?? 'nano', - planId: planId ?? 'free', - storageType: storageType as DiskType, - }) - - return ( -
- { - setShowConfirmationDialog(false) - setShowNewProjectDialog(true) - }} - additionalMonthlySpend={additionalMonthlySpend} - /> - { - refetchCloneStatus() - setRefetchInterval(5000) - setShowNewProjectDialog(false) - }} - /> - {isRestoring ? ( - - - Restoration in progress - -

- The new project {(restoringClone?.target_project as any)?.name || ''} is currently - being created. You'll be able to restore again once the project is ready. -

- -
-
- ) : null} - {previousClones?.length ? ( -
-

Previous restorations

- - {previousClones?.map((c) => )} - -
- ) : null} - {PITR_ENABLED ? ( - <> - { - setShowConfirmationDialog(true) - setRecoveryTimeTarget(v.recoveryTimeTargetUnix) - }} - earliestAvailableBackupUnix={ - cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0 - } - latestAvailableBackupUnix={ - cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0 - } - /> - - ) : ( - { - setSelectedBackupId(id) - setShowConfirmationDialog(true) - }} - /> - )} -
- ) -} - export default RestoreToNewProjectPage diff --git a/apps/studio/pages/project/[ref]/database/settings.tsx b/apps/studio/pages/project/[ref]/database/settings.tsx index 920d10470bd65..aff7f37700b59 100644 --- a/apps/studio/pages/project/[ref]/database/settings.tsx +++ b/apps/studio/pages/project/[ref]/database/settings.tsx @@ -1,14 +1,16 @@ import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm' -import { ConnectionPooling, NetworkRestrictions } from 'components/interfaces/Settings/Database' +import { ConnectionPooling } from 'components/interfaces/Settings/Database' import BannedIPs from 'components/interfaces/Settings/Database/BannedIPs' import { DatabaseReadOnlyAlert } from 'components/interfaces/Settings/Database/DatabaseReadOnlyAlert' import ResetDbPassword from 'components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword' import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' +import { NetworkRestrictions } from 'components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' @@ -18,6 +20,8 @@ const ProjectSettings: NextPageWithLayout = () => { const showNewDiskManagementUI = isAws || isAwsK8s + const { databaseNetworkRestrictions } = useIsFeatureEnabled(['database:network_restrictions']) + return ( <> @@ -40,7 +44,7 @@ const ProjectSettings: NextPageWithLayout = () => { ) : ( )} - + {databaseNetworkRestrictions && } diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index b0eb70e5efe46..1b7ced2f8eee2 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -3,9 +3,11 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' -import { PageLayout, NavigationItem } from 'components/layouts/PageLayout/PageLayout' +import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { UnknownInterface } from 'components/ui/UnknownInterface' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useRouter } from 'next/compat/router' import { useEffect, useMemo } from 'react' import { NextPageWithLayout } from 'types' @@ -14,6 +16,7 @@ import { Admonition } from 'ui-patterns' const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() const { ref, id, pageId, childId } = useParams() + const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = useInstalledIntegrations() @@ -117,6 +120,10 @@ const IntegrationPage: NextPageWithLayout = () => { return null } + if (!integrationsWrappers && id?.endsWith('_wrapper')) { + return + } + return ( { const { ref, q, queryId } = useParams() const projectRef = ref as string const { data: organization } = useSelectedOrganizationQuery() + const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) + + const allTemplates = useMemo(() => { + if (logsShowMetadataIpTemplate) return TEMPLATES + else return TEMPLATES.filter((x) => x.label !== 'Metadata IP') + }, [logsShowMetadataIpTemplate]) const editorRef = useRef() const [editorId] = useState(uuidv4()) @@ -301,7 +308,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { defaultTo={timestampEnd || ''} onDateChange={handleDateChange} onSelectSource={handleInsertSource} - templates={TEMPLATES.filter((template) => template.mode === 'custom')} + templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings} /> diff --git a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx index 9c93527c5706f..15a52d0cbc4cd 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx @@ -15,17 +15,23 @@ import { Button, Popover, cn } from 'ui' export const LogsTemplatesPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() - const isTemplatesEnabled = useIsFeatureEnabled('logs:templates') + const { logsTemplates: isTemplatesEnabled, logsShowMetadataIpTemplate: showMetadataIpTemplate } = + useIsFeatureEnabled(['logs:templates', 'logs:show_metadata_ip_template']) if (!isTemplatesEnabled) { return } + const allTemplates = showMetadataIpTemplate + ? TEMPLATES + : TEMPLATES.filter((template) => template.label !== 'Metadata IP') + return (
- {TEMPLATES.sort((a, b) => a.label!.localeCompare(b.label!)) + {allTemplates + .sort((a, b) => a.label!.localeCompare(b.label!)) .filter((template) => template.mode === 'custom') .map((template, i) => { return