diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index a78333698828c..ace11af3d924d 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -54,21 +54,23 @@ export const APIKeyRow = ({ - - - - diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index 94e9523d568f7..9bd053a40ce7e 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import dayjs from 'dayjs' -import { ReactNode, useMemo, useRef } from 'react' +import { useMemo, useRef } from 'react' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' @@ -8,11 +8,11 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import useLogsQuery from 'hooks/analytics/useLogsQuery' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Card, CardContent, EyeOffIcon, Skeleton, cn } from 'ui' +import { Card, EyeOffIcon } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, @@ -75,106 +75,59 @@ export const SecretAPIKeys = () => { const empty = secretApiKeys?.length === 0 && !isLoadingApiKeys && !isLoadingPermissions - const RowLoading = () => ( - - - - - - - - - - - - - - - ) - - const TableContainer = ({ children, className }: { children: ReactNode; className?: string }) => ( + return (
} /> - - - - - - - Name - - - API Key - - - Last Seen - - + {isLoadingApiKeys || isLoadingPermissions ? ( + + ) : !canReadAPIKeys ? ( + +
+ +

+ You do not have permission to read API Secret Keys +

+

+ Contact your organization owner/admin to request access. +

+
+
+ ) : isErrorApiKeys ? ( + + ) : empty ? ( + +
+

No secret API keys found

+

+ Your project is not accessible via secret keys—there are no active secret keys + created. +

+
+
+ ) : ( + +
+ + + Name + API Key + Last Seen + - {children} + + {secretApiKeys.map((apiKey) => ( + + ))} +
-
-
+ + )}
) - - if (isLoadingApiKeys || isLoadingPermissions) { - return ( - - - - - ) - } - - if (!canReadAPIKeys) { - return ( - -
- -

- You do not have permission to read API Secret Keys -

-

- Contact your organization owner/admin to request access. -

-
-
- ) - } - - if (isErrorApiKeys) { - return ( - - - - ) - } - - if (empty) { - return ( - -
-

No secret API keys found

-

- Your project is not accessible via secret keys—there are no active secret keys created. -

-
-
- ) - } - - return ( - - {secretApiKeys.map((apiKey) => ( - - ))} - - ) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx index 06557305f6dd6..218a83eb2d8bf 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx @@ -80,7 +80,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { .find((w) => w.name === snakeCase(`${bucket.name}_fdw`)) }, [data, bucket.name]) - const extensionState = useIcebergWrapperExtension() + const { state: extensionState } = useIcebergWrapperExtension() const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx index 57cdbe2f21fe8..b5d629295a351 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx @@ -13,7 +13,7 @@ export const useIcebergWrapperExtension = () => { if (!integration || integration.type !== 'wrapper') { // This should never happen - return 'not-found' + return { extension: undefined, state: 'not-found' } } const wrapperMeta = integration.meta @@ -23,13 +23,13 @@ export const useIcebergWrapperExtension = () => { const hasRequiredVersion = (wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '') - const state = isExtensionsLoading + const state: 'loading' | 'installed' | 'needs-upgrade' | 'not-installed' = isExtensionsLoading ? 'loading' : isWrappersExtensionInstalled ? hasRequiredVersion ? 'installed' : 'needs-upgrade' - : ('not-installed' as const) + : 'not-installed' - return state + return { extension: wrappersExtension, state } } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx index 2da6510625794..3071838300e7e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx @@ -1,6 +1,175 @@ +import { MoreVertical, Search, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +import { useParams } from 'common' +import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' +import { + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal' +import { DeleteBucketModal } from './DeleteBucketModal' import { EmptyBucketState } from './EmptyBucketState' export const AnalyticsBuckets = () => { - // Placeholder component - will be implemented in a later PR - return + const router = useRouter() + const { ref } = useParams() + + const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null) + const [selectedBucket, setSelectedBucket] = useState() + const [filterString, setFilterString] = useState('') + + const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref }) + + const analyticsBuckets = buckets + .filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS') + .filter((bucket) => + filterString.length === 0 + ? true + : bucket.name.toLowerCase().includes(filterString.toLowerCase()) + ) + + return ( + <> + {!isLoadingBuckets && + buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS').length === + 0 ? ( + + ) : ( + // Override the default first:pt-12 to match other storage types + + + Buckets + +
+ setFilterString(e.target.value)} + icon={} + /> + +
+ + {isLoadingBuckets ? ( + + ) : ( + + + + + Name + Created at + + + + + {analyticsBuckets.length === 0 && filterString.length > 0 && ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+ )} + {analyticsBuckets.map((bucket) => ( + { + const url = `/project/${ref}/storage/analytics/buckets/${bucket.id}` + if (event.metaKey) window.open(url, '_blank') + else router.push(url) + }} + > + +

{bucket.name}

+
+ + +

+ +

+
+ + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+ )} + + {selectedBucket && ( + setModal(null)} + /> + )} + + ) } diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 2586ce15220f6..994707ac6c727 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -53,6 +53,7 @@ import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' +import { BUCKET_TYPES } from './Storage.constants' import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' const FormSchema = z @@ -131,6 +132,8 @@ export const CreateBucketModal = ({ const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0) const formattedGlobalUploadLimit = `${value} ${unit}` + const config = BUCKET_TYPES['files'] + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { @@ -149,7 +152,7 @@ export const CreateBucketModal = ({ const isStandardBucket = form.watch('type') === 'STANDARD' const hasFileSizeLimit = form.watch('has_file_size_limit') const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false) - const icebergWrapperExtensionState = useIcebergWrapperExtension() + const { state: icebergWrapperExtensionState } = useIcebergWrapperExtension() const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled const onSubmit: SubmitHandler = async (values) => { @@ -264,7 +267,7 @@ export const CreateBucketModal = ({ - Create storage bucket + Create a {isStorageV2 ? config.singularName : 'storage'} bucket @@ -279,8 +282,8 @@ export const CreateBucketModal = ({ render={({ field }) => ( !value.endsWith(' '), + 'The name of the bucket cannot end with a whitespace' + ) + .refine( + (value) => value !== 'public', + '"public" is a reserved name. Please choose another name' + ), + }) + .superRefine((data, ctx) => { + if (!validBucketNameRegex.test(data.name)) { + const [match] = data.name.match(inverseValidBucketNameRegex) ?? [] + ctx.addIssue({ + path: ['name'], + code: z.ZodIssueCode.custom, + message: !!match + ? `Bucket name cannot contain the "${match}" character` + : 'Bucket name contains an invalid special character', + }) + } + }) + +const formId = 'create-specialized-storage-bucket-form' + +export type CreateSpecializedBucketForm = z.infer + +interface CreateSpecializedBucketModalProps { + bucketType: 'analytics' | 'vectors' + buttonSize?: 'tiny' | 'small' + buttonType?: 'default' | 'primary' + buttonClassName?: string + disabled?: boolean + tooltip?: { + content: { + side?: 'top' | 'bottom' | 'left' | 'right' + text?: string + } + } +} + +export const CreateSpecializedBucketModal = ({ + bucketType, + buttonSize = 'tiny', + buttonType = 'default', + buttonClassName, + disabled = false, + tooltip, +}: CreateSpecializedBucketModalProps) => { + const router = useRouter() + const { ref } = useParams() + const { data: org } = useSelectedOrganizationQuery() + const { data: project } = useSelectedProjectQuery() + const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + const { extension: wrappersExtension, state: wrappersExtensionState } = + useIcebergWrapperExtension() + + const [visible, setVisible] = useState(false) + + const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) + const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled + + const { mutate: sendEvent } = useSendEventMutation() + const { mutateAsync: createBucket, isLoading: isCreatingBucket } = useBucketCreateMutation({ + // [Joshen] Silencing the error here as it's being handled in onSubmit + onError: () => {}, + }) + const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } = + useIcebergWrapperCreateMutation() + const { mutateAsync: enableExtension, isLoading: isEnablingExtension } = + useDatabaseExtensionEnableMutation() + + const config = BUCKET_TYPES['analytics'] + const isCreating = isCreatingBucket || isEnablingExtension || isCreatingIcebergWrapper + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: '', + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + if (!ref) return console.error('Project ref is required') + if (!project) return console.error('Project details is required') + if (!wrappersExtension) return console.error('Unable to find wrappers extension') + + if (wrappersExtensionState === 'needs-upgrade') { + // [Joshen] Double check if this is the right CTA + return toast.error( +

+ Wrappers extensions needs to be updated to create an Iceberg Wrapper. Update the extension + by disabling and enabling the wrappers extension first in + the{' '} + + database extensions page + {' '} + before creating an Analytics bucket. +

+ ) + } + + // Determine bucket type based on the bucketType prop + // [Danny] Change STANDARD to VECTORS when ready + const bucketTypeValue = bucketType === 'analytics' ? 'ANALYTICS' : 'STANDARD' + + try { + await createBucket({ + projectRef: ref, + id: values.name, + type: bucketTypeValue, + isPublic: false, // Specialized buckets are not public by default + file_size_limit: undefined, // Specialized buckets do not have a file size limit + allowed_mime_types: undefined, // Specialized buckets do not have allowed MIME types + }) + + sendEvent({ + action: 'storage_bucket_created', + properties: { bucketType: bucketTypeValue }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + + if (wrappersExtensionState === 'not-installed') { + await enableExtension({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: wrappersExtension.name, + schema: wrappersExtension.schema ?? 'extensions', + version: wrappersExtension.default_version, + }) + await createIcebergWrapper({ bucketName: values.name }) + } else if (wrappersExtensionState === 'installed') { + await createIcebergWrapper({ bucketName: values.name }) + } + + toast.success(`Created bucket “${values.name}”`) + form.reset() + setVisible(false) + router.push(`/project/${ref}/storage/${bucketType}/${values.name}`) + } catch (error: any) { + toast.error(`Failed to create bucket: ${error.message}`) + } + } + + const handleClose = () => { + form.reset() + setVisible(false) + } + + return ( + { + if (!open) handleClose() + }} + > + + } + disabled={!canCreateBuckets || !icebergCatalogEnabled || disabled} + style={{ justifyContent: 'start' }} + onClick={() => setVisible(true)} + tooltip={{ + content: { + side: tooltip?.content?.side || 'bottom', + className: cn(!icebergCatalogEnabled ? 'w-72 text-center' : ''), + text: !icebergCatalogEnabled + ? 'Analytics buckets are not enabled for your project. Please contact support to enable it.' + : !canCreateBuckets + ? 'You need additional permissions to create buckets' + : tooltip?.content?.text, + }, + }} + > + New bucket + + + + + + Create {config.singularName} bucket + + + + + +
+ + ( + + + + + + )} + /> + + {bucketType === 'analytics' && ( + +

+ Supabase will install the{' '} + {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} + Iceberg Wrapper integration on your behalf.{' '} + + Learn more + + . +

+
+ )} +
+
+
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index 7476bfe5264db..5099a03e18186 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -156,7 +156,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa id="confirm" autoComplete="off" {...field} - placeholder="Type in name of bucket" + placeholder="Type bucket name" />
diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 47e7f318a0942..c70e00e021dbd 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -213,8 +213,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx index 32a1b30ff017e..dde4c6e3166a0 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -1,5 +1,6 @@ import { BucketAdd } from 'icons' import { CreateBucketModal } from './CreateBucketModal' +import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal' import { BUCKET_TYPES } from './Storage.constants' interface EmptyBucketStateProps { @@ -10,19 +11,35 @@ export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => { const config = BUCKET_TYPES[bucketType] return ( -