mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: Support CLI for Vector buckets (#46381)
## Context > [!IMPORTANT] > Will open up for review once CLI PR is merged and deployed so that it's easier to test Related PR: https://github.com/supabase/cli/pull/5230 Adding support for vector buckets for local CLI - will need to be tested locally via `pnpm run dev:studio-local` ## To test There's a bit of testing instructions in the linear ticket [here](https://linear.app/supabase/issue/FE-3474/show-vector-buckets-in-local-admin-studio) as it involves using a branch of CLI - otherwise do reach out to Fabrizio if any help might be needed, but generally: ### Local CLI You might need to manually set `isCli` to `true` in `StorageMenuV2` if the "Vectors" nav item isn't showing up on the storage UI given we're testing via `pnpm run dev:studio-local` - [x] Can create bucket - [x] Can delete bucket - [x] Can create indexes - [x] Can insert data into indexes (via FDW) - [x] Can delete indexes Known issues (that aren't directly solvable from FE end) Reach out to Fabrizio for context as we were both investigating this - PG database needs to be on 17.6 (otherwise there's no S3 vectors FDW) - Storage version needs to be on 1.59.0 ### Self-hosted (This might be tricky to actually test, but just ensure that the code satisfies this) - [x] Cannot see vector buckets ### Hosted - [x] Everything works status quo <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Vector bucket management UI and platform APIs (create/list/delete buckets & indexes) * Local S3 credentials endpoint and client-side hook for self‑hosted/CLI use * **Bug Fixes** * Improved S3 vector setup notifications and clearer error guidance for manual installation * **Refactor** * Deployment-mode gating: platform vs CLI/self‑hosted now controls feature visibility and page behavior * **Tests** * Added suites covering deployment-mode gates and vector bucket error/usage scenarios * **Chores** * Build env updated to expose local S3 credential vars <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46381?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
b4448fedd2
commit
1d203f6c93
22 files changed
+654
-28
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Badge, Menu } from 'ui'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useIsAnalyticsBucketsEnabled,
|
||||
useIsVectorBucketsEnabled,
|
||||
} from '@/data/config/project-storage-config-query'
|
||||
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
@@ -25,6 +26,8 @@ export const StorageMenuV2 = () => {
|
||||
const { ref } = useParams()
|
||||
const page = useStorageV2Page()
|
||||
|
||||
const { isCli, isPlatform } = useDeploymentMode()
|
||||
|
||||
const { storageAnalytics, storageVectors } = useIsFeatureEnabled([
|
||||
'storage:analytics',
|
||||
'storage:vectors',
|
||||
@@ -33,8 +36,8 @@ export const StorageMenuV2 = () => {
|
||||
const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
|
||||
const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef: ref })
|
||||
|
||||
const showAnalytics = IS_PLATFORM && storageAnalytics
|
||||
const showVectors = IS_PLATFORM && storageVectors
|
||||
const showAnalytics = isPlatform && storageAnalytics
|
||||
const showVectors = (isPlatform && storageVectors) || isCli
|
||||
|
||||
useShortcut(SHORTCUT_IDS.NAV_STORAGE_FILES, () => router.push(`/project/${ref}/storage/files`))
|
||||
useShortcut(
|
||||
@@ -48,7 +51,7 @@ export const StorageMenuV2 = () => {
|
||||
{ enabled: showVectors }
|
||||
)
|
||||
useShortcut(SHORTCUT_IDS.NAV_STORAGE_S3, () => router.push(`/project/${ref}/storage/s3`), {
|
||||
enabled: IS_PLATFORM,
|
||||
enabled: isPlatform,
|
||||
})
|
||||
|
||||
const bucketTypes = Object.entries(BUCKET_TYPES).filter(([key]) => {
|
||||
@@ -95,7 +98,7 @@ export const StorageMenuV2 = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
{isPlatform && (
|
||||
<>
|
||||
<div className="h-px w-[calc(100%-1.5rem)] mx-auto md:w-full bg-border" />
|
||||
<div className="md:mx-3">
|
||||
|
||||
+11
-4
@@ -141,15 +141,22 @@ export const CreateVectorBucketDialog = ({
|
||||
}
|
||||
|
||||
await createS3VectorsWrapper({ bucketName: values.name })
|
||||
toast.success(`Successfully created vector bucket ${values.name}`)
|
||||
} catch (error: any) {
|
||||
toast.warning(
|
||||
`Failed to create vector bucket integration: ${error.message}. The bucket will be created but you will need to manually install the integration.`
|
||||
toast.success(
|
||||
<div>
|
||||
<p>Successfully created vector bucket {values.name}</p>
|
||||
<p className="text-foreground-light text-xs">
|
||||
However, bucket integration will need to be manually installed as we ran into an error:
|
||||
</p>
|
||||
<p className="text-foreground-light text-xs">{error.message}</p>
|
||||
</div>,
|
||||
{ duration: 8000 }
|
||||
)
|
||||
}
|
||||
setIsLoading(false)
|
||||
|
||||
setIsLoading(false)
|
||||
track('storage_bucket_created', { bucketType: 'vector' })
|
||||
toast.success(`Successfully created vector bucket ${values.name}`)
|
||||
form.reset()
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
|
||||
import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -104,6 +105,7 @@ interface CreateVectorTableSheetProps {
|
||||
|
||||
export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { isCli } = useDeploymentMode()
|
||||
|
||||
const [visible, setVisible] = useQueryState(
|
||||
'newTable',
|
||||
@@ -117,7 +119,8 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro
|
||||
?.split('supabase_target_schema=')[1]
|
||||
|
||||
// [Joshen] Can remove this once this restriction is removed
|
||||
const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
|
||||
const showIndexCreationNotice =
|
||||
isStagingLocal && !isCli && !!project && project?.region !== 'us-east-1'
|
||||
|
||||
const defaultValues = {
|
||||
name: '',
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { VectorBucketsErrorState } from './VectorBucketsErrorState'
|
||||
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
const { mockUseDeploymentMode } = vi.hoisted(() => ({
|
||||
mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useDeploymentMode', () => ({
|
||||
useDeploymentMode: mockUseDeploymentMode,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/telemetry/track', () => ({
|
||||
useTrack: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const deploymentMode = (overrides: Partial<DeploymentMode>): DeploymentMode => ({
|
||||
isPlatform: false,
|
||||
isCli: false,
|
||||
isSelfHosted: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('VectorBucketsErrorState', () => {
|
||||
beforeEach(() => {
|
||||
mockUseDeploymentMode.mockReset()
|
||||
})
|
||||
|
||||
test('CLI: shows the config.toml enable guidance, not the support error', () => {
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
|
||||
|
||||
customRender(<VectorBucketsErrorState error={new ResponseError('boom')} />)
|
||||
|
||||
expect(screen.getByText('Vector buckets are not enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('supabase/config.toml')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Contact support')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('platform: shows the generic support error', () => {
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
|
||||
|
||||
customRender(<VectorBucketsErrorState error={new ResponseError('boom')} />)
|
||||
|
||||
expect(screen.getByText('Failed to retrieve vector buckets')).toBeInTheDocument()
|
||||
expect(screen.getByText('Contact support')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Vector buckets are not enabled')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { VectorBucketsLocalDisabledState } from './VectorBucketsLocalDisabledState'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
interface VectorBucketsErrorStateProps {
|
||||
error: ResponseError | null
|
||||
}
|
||||
|
||||
export const VectorBucketsErrorState = ({ error }: VectorBucketsErrorStateProps) => {
|
||||
const { isCli } = useDeploymentMode()
|
||||
|
||||
if (isCli) return <VectorBucketsLocalDisabledState />
|
||||
|
||||
return <AlertError error={error} subject="Failed to retrieve vector buckets" />
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
const CONFIG_SNIPPET = `[storage.vector]
|
||||
enabled = true
|
||||
max_buckets = 10
|
||||
max_indexes = 5`
|
||||
|
||||
/**
|
||||
* Shown on the local CLI when listing vector buckets fails — most commonly
|
||||
* because `[storage.vector]` is not enabled in `config.toml`. Studio can't read
|
||||
* `config.toml` directly, so we surface the snippet to enable the feature rather
|
||||
* than the generic "contact support" error.
|
||||
*/
|
||||
export const VectorBucketsLocalDisabledState = () => {
|
||||
return (
|
||||
<Admonition type="default" title="Vector buckets are not enabled">
|
||||
<p className="text-foreground-light">
|
||||
To use vector buckets locally, enable them in your{' '}
|
||||
<code className="text-code-inline">supabase/config.toml</code> and restart with{' '}
|
||||
<code className="text-code-inline">supabase start</code>.
|
||||
</p>
|
||||
<CodeBlock language="toml" hideLineNumbers className="mt-2 max-w-full">
|
||||
{CONFIG_SNIPPET}
|
||||
</CodeBlock>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
import { EmptyBucketState } from '../EmptyBucketState'
|
||||
import { CreateBucketButton } from '../NewBucketButton'
|
||||
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { VectorBucketsErrorState } from './VectorBucketsErrorState'
|
||||
import { AlphaNotice } from '@/components/ui/AlphaNotice'
|
||||
import { useVectorBucketsQuery } from '@/data/storage/vector-buckets-query'
|
||||
import { createNavigationHandler } from '@/lib/navigation'
|
||||
@@ -62,9 +62,7 @@ export const VectorsBuckets = () => {
|
||||
|
||||
{isLoadingBuckets && <GenericSkeletonLoader />}
|
||||
|
||||
{isErrorBuckets && (
|
||||
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
|
||||
)}
|
||||
{isErrorBuckets && <VectorBucketsErrorState error={bucketsError} />}
|
||||
|
||||
{isSuccessBuckets && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
useIsVectorBucketsEnabled,
|
||||
type ProjectStorageConfigData,
|
||||
} from './project-storage-config-query'
|
||||
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { customRenderHook } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
const { mockIsPlatform, mockUseDeploymentMode } = vi.hoisted(() => ({
|
||||
mockIsPlatform: { value: false },
|
||||
mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
|
||||
}))
|
||||
|
||||
// `useProjectStorageConfigQuery` (same module as the hook under test) gates its
|
||||
// fetch on the build-time `IS_PLATFORM` constant — mock it so the query fires in
|
||||
// the platform cases.
|
||||
vi.mock('@/lib/constants', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@/lib/constants')
|
||||
return {
|
||||
...actual,
|
||||
get IS_PLATFORM() {
|
||||
return mockIsPlatform.value
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/misc/useDeploymentMode', () => ({
|
||||
useDeploymentMode: mockUseDeploymentMode,
|
||||
}))
|
||||
|
||||
const createStorageConfig = (vectorBucketsEnabled: boolean): ProjectStorageConfigData =>
|
||||
({
|
||||
capabilities: { iceberg_catalog: false, list_v2: false },
|
||||
databasePoolMode: 'transaction',
|
||||
external: { upstreamTarget: 'main' },
|
||||
features: {
|
||||
icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
|
||||
imageTransformation: { enabled: false },
|
||||
s3Protocol: { enabled: false },
|
||||
vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
|
||||
},
|
||||
fileSizeLimit: 0,
|
||||
migrationVersion: 'v1',
|
||||
}) as ProjectStorageConfigData
|
||||
|
||||
const deploymentMode = (overrides: Partial<DeploymentMode>): DeploymentMode => ({
|
||||
isPlatform: false,
|
||||
isCli: false,
|
||||
isSelfHosted: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useIsVectorBucketsEnabled', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPlatform.value = false
|
||||
mockUseDeploymentMode.mockReset()
|
||||
})
|
||||
|
||||
test('platform + storage config flag enabled: true', async () => {
|
||||
mockIsPlatform.value = true
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref/config/storage',
|
||||
response: () => HttpResponse.json<ProjectStorageConfigData>(createStorageConfig(true)),
|
||||
})
|
||||
|
||||
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
|
||||
|
||||
await waitFor(() => expect(result.current).toBe(true))
|
||||
})
|
||||
|
||||
test('platform + storage config flag disabled: false', async () => {
|
||||
mockIsPlatform.value = true
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
|
||||
|
||||
let configRequested = false
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref/config/storage',
|
||||
response: () => {
|
||||
configRequested = true
|
||||
return HttpResponse.json<ProjectStorageConfigData>(createStorageConfig(false))
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
|
||||
|
||||
await waitFor(() => expect(configRequested).toBe(true))
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
test('CLI: true regardless of the storage config flag', () => {
|
||||
// Query is disabled off-platform, so no storage config endpoint is needed.
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
|
||||
|
||||
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
test('self-hosted: false', () => {
|
||||
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isSelfHosted: true }))
|
||||
|
||||
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { configKeys } from './keys'
|
||||
import { components } from '@/data/api'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
@@ -60,6 +61,8 @@ export const useIsAnalyticsBucketsEnabled = ({ projectRef }: { projectRef?: stri
|
||||
|
||||
export const useIsVectorBucketsEnabled = ({ projectRef }: { projectRef?: string }) => {
|
||||
const { data } = useProjectStorageConfigQuery({ projectRef })
|
||||
const isVectorBucketsEnabled = !!data?.features.vectorBuckets?.enabled
|
||||
const { isCli, isPlatform } = useDeploymentMode()
|
||||
|
||||
const isVectorBucketsEnabled = isCli || (isPlatform && !!data?.features.vectorBuckets?.enabled)
|
||||
return isVectorBucketsEnabled
|
||||
}
|
||||
@@ -5,4 +5,5 @@ export const miscKeys = {
|
||||
ipAddress: () => ['ip-address'] as const,
|
||||
clockSkew: () => ['clock-skew'] as const,
|
||||
enabledFeaturesOverride: () => ['enabled-features-override'] as const,
|
||||
localS3Keys: () => ['local-s3-keys'] as const,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { miscKeys } from './keys'
|
||||
import { fetchHandler } from '@/data/fetchers'
|
||||
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export async function getLocalS3Keys() {
|
||||
try {
|
||||
const data = await fetchHandler(`${BASE_PATH}/api/get-s3-keys`).then((res) => res.json())
|
||||
return data as { accessKey?: string; secretKey?: string }
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalS3KeysData = Awaited<ReturnType<typeof getLocalS3Keys>>
|
||||
export type LocalS3KeysError = ResponseError
|
||||
|
||||
/**
|
||||
* Specifically only for local CLI - to use the S3 keys as defined in the env file
|
||||
*/
|
||||
export const useLocalS3KeysQuery = <TData = LocalS3KeysData>({
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<LocalS3KeysData, LocalS3KeysError, TData> = {}) =>
|
||||
useQuery<LocalS3KeysData, LocalS3KeysError, TData>({
|
||||
queryKey: miscKeys.localS3Keys(),
|
||||
queryFn: () => getLocalS3Keys(),
|
||||
enabled: enabled && !IS_PLATFORM,
|
||||
...options,
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
|
||||
import { useLocalS3KeysQuery } from '../misc/local-s3-keys-query'
|
||||
import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation'
|
||||
import { WRAPPERS } from '@/components/interfaces/Integrations/Wrappers/Wrappers.constants'
|
||||
import { getVectorURI } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
|
||||
@@ -11,14 +13,29 @@ import {
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { FDWCreateVariables, useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
export const useS3VectorsWrapperCreateMutation = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: localKeys } = useLocalS3KeysQuery()
|
||||
const { isPlatform } = useDeploymentMode()
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
|
||||
|
||||
/**
|
||||
* [Joshen] Endpoint for vectors FDW needs to use docker domain
|
||||
* Not sure if this affects other areas of the dashboard hence why conditionally rendering here
|
||||
* instead of updating `lib/api/self-hosted/settings -> api_config`
|
||||
*/
|
||||
const port =
|
||||
!isPlatform && !!settings
|
||||
? new URL(`${protocol}://${settings.app_config?.endpoint}`).port
|
||||
: null
|
||||
const endpoint = !isPlatform
|
||||
? `host.docker.internal:${port}`
|
||||
: settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
|
||||
|
||||
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 's3_vectors_wrapper')
|
||||
|
||||
@@ -30,13 +47,32 @@ export const useS3VectorsWrapperCreateMutation = () => {
|
||||
const { mutateAsync: createS3AccessKey, isPending: isCreatingS3AccessKey } =
|
||||
useS3AccessKeyCreateMutation()
|
||||
|
||||
const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation()
|
||||
// [Joshen] Silence the error, handled upstream
|
||||
const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation({
|
||||
onError: () => {},
|
||||
})
|
||||
|
||||
const mutateAsync = async ({ bucketName }: { bucketName: string }) => {
|
||||
const createS3KeyData = await createS3AccessKey({
|
||||
projectRef: project?.ref,
|
||||
description: getVectorBucketS3KeyName(bucketName),
|
||||
})
|
||||
let accessKey: string | undefined
|
||||
let secretKey: string | undefined
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
const createS3KeyData = await createS3AccessKey({
|
||||
projectRef: project?.ref,
|
||||
description: getVectorBucketS3KeyName(bucketName),
|
||||
})
|
||||
accessKey = createS3KeyData.access_key
|
||||
secretKey = createS3KeyData.secret_key
|
||||
} else {
|
||||
accessKey = localKeys?.accessKey
|
||||
secretKey = localKeys?.secretKey
|
||||
}
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
IS_PLATFORM ? 'Failed to obtain S3 keys from the API' : 'Local S3 keys are not configured'
|
||||
)
|
||||
}
|
||||
|
||||
const wrapperName = getVectorBucketFDWName(bucketName)
|
||||
const serverName = getVectorBucketFDWServerName(bucketName)
|
||||
@@ -48,8 +84,8 @@ export const useS3VectorsWrapperCreateMutation = () => {
|
||||
formState: {
|
||||
wrapper_name: wrapperName,
|
||||
server_name: serverName,
|
||||
vault_access_key_id: createS3KeyData?.access_key,
|
||||
vault_secret_access_key: createS3KeyData?.secret_key,
|
||||
vault_access_key_id: accessKey,
|
||||
vault_secret_access_key: secretKey,
|
||||
aws_region: settings!.region,
|
||||
endpoint_url: getVectorURI(project?.ref ?? '', protocol, endpoint),
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('api/self-hosted/settings', () => {
|
||||
expect(settings.db_port).toBe(5432)
|
||||
expect(settings.db_user).toBe('postgres')
|
||||
expect(settings.ref).toBe('default')
|
||||
expect(settings.region).toBe('ap-southeast-1')
|
||||
expect(settings.region).toBe('local')
|
||||
expect(settings.status).toBe('ACTIVE_HEALTHY')
|
||||
expect(settings.ssl_enforced).toBe(false)
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export function getProjectSettings() {
|
||||
process.env.AUTH_JWT_SECRET ?? 'super-secret-jwt-token-with-at-least-32-characters-long',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
ref: 'default',
|
||||
region: 'ap-southeast-1',
|
||||
region: 'local',
|
||||
service_api_keys: [
|
||||
{
|
||||
api_key: process.env.SUPABASE_SERVICE_KEY ?? '',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const accessKey = process.env.S3_PROTOCOL_ACCESS_KEY_ID
|
||||
const secretKey = process.env.S3_PROTOCOL_ACCESS_KEY_SECRET
|
||||
|
||||
const handler = async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.status(200).json({ accessKey: accessKey, secretKey })
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from '@/lib/api/apiWrapper'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGet(req, res)
|
||||
case 'DELETE':
|
||||
return handleDelete(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'PATCH', 'DELETE'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.query
|
||||
const { data, error } = await supabase.storage.vectors.getBucket(id as string)
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data.vectorBucket)
|
||||
}
|
||||
|
||||
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.query
|
||||
const { data, error } = await supabase.storage.vectors.deleteBucket(id as string)
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from '@/lib/api/apiWrapper'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'DELETE':
|
||||
return handleDelete(req, res)
|
||||
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id, indexName } = req.query
|
||||
|
||||
const { data, error } = await supabase.storage.vectors
|
||||
.from(id as string)
|
||||
.deleteIndex(indexName as string)
|
||||
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from '@/lib/api/apiWrapper'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGet(req, res)
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.query
|
||||
|
||||
const { data, error } = await supabase.storage.vectors
|
||||
.from(id as string)
|
||||
.listIndexes({ maxResults: 100 })
|
||||
|
||||
if (error) return res.status(500).json({ error: { message: error.message } })
|
||||
|
||||
const indexes = await Promise.all(
|
||||
data.indexes.map(async ({ indexName }) => {
|
||||
return (await supabase.storage.vectors.from(id as string).getIndex(indexName)).data?.index
|
||||
})
|
||||
)
|
||||
|
||||
return res.status(200).json({ indexes, nextToken: data.nextToken })
|
||||
}
|
||||
|
||||
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.query
|
||||
const { indexName, dataType, dimension, distanceMetric, metadataKeys } = req.body
|
||||
const payload = {
|
||||
indexName,
|
||||
dataType,
|
||||
dimension,
|
||||
distanceMetric,
|
||||
metadataConfiguration: { nonFilterableMetadataKeys: metadataKeys },
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.storage.vectors.from(id as string).createIndex(payload)
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from '@/lib/api/apiWrapper'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGet(req, res)
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleGet = async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { data, error } = await supabase.storage.vectors.listBuckets()
|
||||
if (error) return res.status(500).json({ error: { message: error.message } })
|
||||
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { bucketName } = req.body
|
||||
const { data, error } = await supabase.storage.vectors.createBucket(bucketName)
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
|
||||
import { BucketsUpgradePlan } from '@/components/interfaces/Storage/BucketsUpgradePlan'
|
||||
import { VectorsBuckets } from '@/components/interfaces/Storage/VectorBuckets'
|
||||
@@ -23,10 +23,12 @@ const StorageVectorsPage: NextPageWithLayout = () => {
|
||||
project?.region ?? ''
|
||||
)
|
||||
|
||||
if (!isAvailableInProjectRegion) {
|
||||
if (IS_PLATFORM && !isAvailableInProjectRegion) {
|
||||
return <RegionLimitation />
|
||||
} else if (!isVectorBucketsEnabled) {
|
||||
} else if (IS_PLATFORM && !isVectorBucketsEnabled) {
|
||||
return <BucketsUpgradePlan type="vector" />
|
||||
} else if (!isVectorBucketsEnabled) {
|
||||
return null
|
||||
} else {
|
||||
return <VectorsBuckets />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { ProjectStorageConfigData } from '@/data/config/project-storage-config-query'
|
||||
import type { ProjectDetail } from '@/data/projects/project-detail-query'
|
||||
import { API_URL } from '@/lib/constants'
|
||||
import StorageVectorsPage from '@/pages/project/[ref]/storage/vectors'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, mswServer } from '@/tests/lib/msw'
|
||||
|
||||
const { mockIsPlatform } = vi.hoisted(() => ({
|
||||
mockIsPlatform: { value: true },
|
||||
}))
|
||||
|
||||
// `IS_PLATFORM` is a build-time constant, so it can't be driven over the
|
||||
// network — mock it in both modules that read it (the page imports from
|
||||
// `common`; the data hooks read from `@/lib/constants`). Everything else the
|
||||
// page branches on comes from real queries via MSW.
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ ref: 'default' }),
|
||||
get IS_PLATFORM() {
|
||||
return mockIsPlatform.value
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_PLATFORM() {
|
||||
return mockIsPlatform.value
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Stub the heavy leaf children — each fires its own queries and renders a large
|
||||
// tree. We only assert which branch the page picks. Keep the real
|
||||
// `VECTOR_BUCKETS_AVAILABLE_REGIONS` so the region gate runs for real.
|
||||
vi.mock(
|
||||
'@/components/interfaces/Storage/VectorBuckets/RegionLimitation',
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
RegionLimitation: () => <div>region-limitation</div>,
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/components/interfaces/Storage/VectorBuckets', () => ({
|
||||
VectorsBuckets: () => <div>vectors-buckets</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Storage/BucketsUpgradePlan', () => ({
|
||||
BucketsUpgradePlan: ({ type }: { type: string }) => <div>buckets-upgrade-plan-{type}</div>,
|
||||
}))
|
||||
|
||||
const AVAILABLE_REGION = 'us-east-1'
|
||||
const UNAVAILABLE_REGION = 'ap-south-1'
|
||||
|
||||
const mockProject = (region: string) => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref',
|
||||
// The page only reads `region` off the project
|
||||
response: () => HttpResponse.json<ProjectDetail>({ region } as unknown as ProjectDetail),
|
||||
})
|
||||
}
|
||||
|
||||
const mockStorageConfig = (vectorBucketsEnabled: boolean) => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref/config/storage',
|
||||
response: () =>
|
||||
HttpResponse.json<ProjectStorageConfigData>({
|
||||
capabilities: { iceberg_catalog: false, list_v2: false },
|
||||
databasePoolMode: 'transaction',
|
||||
external: { upstreamTarget: 'main' },
|
||||
features: {
|
||||
icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
|
||||
imageTransformation: { enabled: false },
|
||||
s3Protocol: { enabled: false },
|
||||
vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
|
||||
},
|
||||
fileSizeLimit: 0,
|
||||
migrationVersion: 'v1',
|
||||
} as ProjectStorageConfigData),
|
||||
})
|
||||
}
|
||||
|
||||
const mockDeploymentMode = (isCli: boolean) => {
|
||||
mswServer.use(
|
||||
http.get(`${API_URL}/platform/deployment-mode`, () => HttpResponse.json({ is_cli_mode: isCli }))
|
||||
)
|
||||
}
|
||||
|
||||
describe('StorageVectorsPage', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPlatform.value = true
|
||||
mockProject(AVAILABLE_REGION)
|
||||
})
|
||||
|
||||
test('platform + region not supported: shows region limitation', async () => {
|
||||
mockProject(UNAVAILABLE_REGION)
|
||||
mockStorageConfig(true)
|
||||
|
||||
customRender(<StorageVectorsPage dehydratedState={undefined} />)
|
||||
|
||||
expect(await screen.findByText('region-limitation')).toBeInTheDocument()
|
||||
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('platform + supported region + not enabled: shows upgrade plan', async () => {
|
||||
mockStorageConfig(false)
|
||||
|
||||
customRender(<StorageVectorsPage dehydratedState={undefined} />)
|
||||
|
||||
expect(await screen.findByText('buckets-upgrade-plan-vector')).toBeInTheDocument()
|
||||
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('platform + supported region + enabled: shows vector buckets', async () => {
|
||||
mockStorageConfig(true)
|
||||
|
||||
customRender(<StorageVectorsPage dehydratedState={undefined} />)
|
||||
|
||||
expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('CLI (non-platform, enabled): shows vector buckets, skips region/upgrade gates', async () => {
|
||||
mockIsPlatform.value = false
|
||||
// Even an unsupported region shouldn't gate off-platform
|
||||
mockProject(UNAVAILABLE_REGION)
|
||||
mockDeploymentMode(true)
|
||||
|
||||
customRender(<StorageVectorsPage dehydratedState={undefined} />)
|
||||
|
||||
expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
|
||||
expect(screen.queryByText('region-limitation')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('self-hosted (non-platform): renders nothing', async () => {
|
||||
mockIsPlatform.value = false
|
||||
mockDeploymentMode(false)
|
||||
|
||||
const { container } = customRender(<StorageVectorsPage dehydratedState={undefined} />)
|
||||
|
||||
// `useDeploymentMode` defaults to CLI during its loading window, so the page
|
||||
// briefly renders before resolving to self-hosted — wait for it to settle.
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement())
|
||||
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,8 @@
|
||||
"VERCEL_GIT_COMMIT_SHA",
|
||||
"SNIPPETS_MANAGEMENT_FOLDER",
|
||||
"EDGE_FUNCTIONS_MANAGEMENT_FOLDER",
|
||||
"S3_PROTOCOL_ACCESS_KEY_ID",
|
||||
"S3_PROTOCOL_ACCESS_KEY_SECRET",
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", "!.next/dev/**/*"],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user