mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
studio(chore): improve analytics bucket page (#39545)
* specialized dialog for analytics (and vector) bucket creation * handle missing wrapper extension and iceberg wrapper integration * bucket creation improvements * extension and integration checks * dialog header consistency * simplify bucket dialog creation button trigger * fix subtitle ternary * example bucket without dynamic route * create dynamic segment * fancy connection table * improve bucket page * connection table polish * polish * delete bucket modal * improve missing installations design * new table modal * fix copy icon * copy all env button placeholder * list all analytics buckets * specialised bucket modal * shared installer hooks and components * make iceberg wrapper not dependent on wrapper extension There are cases where the Iceberg Wrapper can be installed but the Wrapper extension has since been removed * better admonition copywriting * todo * misc * simplify * simplifications * work within existing page * improvements to existing route * v2 tables * simplify * new table modal * change route to thin wrapper * reduce size of PR * remove double scaffold element * minor nits --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
869ec0541e
commit
44f0eff125
10 files changed
+474
-317
No files matched your search
@@ -2,7 +2,7 @@ import { Copy } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { Button } from 'ui'
|
||||
import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { copyToClipboard } from 'ui'
|
||||
@@ -34,29 +34,14 @@ export const CopyEnvButton = ({
|
||||
).then((values) => values.join('\n'))
|
||||
|
||||
copyToClipboard(envFile, () => {
|
||||
toast.success('Copied to clipboard')
|
||||
toast.success('Copied to clipboard as .env file')
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [serverOptions, values])
|
||||
|
||||
return (
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
icon={<Copy />}
|
||||
onClick={onCopy}
|
||||
tooltip={{
|
||||
content: {
|
||||
text: (
|
||||
<span>
|
||||
Copies an <span className="text-brand">.env file</span> with the configuration details
|
||||
to your clipboard.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
Copy env values
|
||||
</ButtonTooltip>
|
||||
<Button type="default" loading={isLoading} icon={<Copy />} onClick={onCopy}>
|
||||
Copy all
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+62
-48
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
||||
import { useParams } from 'common'
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Button, CardContent, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
export const DecryptedReadOnlyInput = ({
|
||||
value,
|
||||
@@ -41,52 +43,64 @@ export const DecryptedReadOnlyInput = ({
|
||||
: value
|
||||
|
||||
return (
|
||||
<Input
|
||||
readOnly
|
||||
// If the value is secure, allow copying to clipboard if the value is revealed. Otherwise, always allow copying
|
||||
copy={!secureEntry || (!isDecryptedValueLoading && showHidden)}
|
||||
disabled
|
||||
label={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${ref}/integrations/vault/secrets?search=${value}`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-foreground-lighter hover:text-foreground-light transition"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">View parameter in Vault</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={renderedValue}
|
||||
type={secureEntry ? (isLoading ? 'text' : showHidden ? 'text' : 'password') : 'text'}
|
||||
descriptionText={descriptionText}
|
||||
layout="horizontal"
|
||||
actions={
|
||||
secureEntry ? (
|
||||
isLoading ? (
|
||||
<div className="flex items-center justify-center mr-1">
|
||||
<Button disabled type="default" icon={<Loader className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center mr-1">
|
||||
<Button
|
||||
type="default"
|
||||
icon={showHidden ? <Eye /> : <EyeOff />}
|
||||
onClick={() => setShowHidden(!showHidden)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<CardContent className="py-6 border-b border-panel-border-interior-light">
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${ref}/integrations/vault/secrets?search=${value}`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-foreground-muted hover:text-foreground-lighter transition"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Open in Vault</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={descriptionText}
|
||||
isReactForm={false}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
// If the value is secure, allow copying to clipboard if the value is revealed. Otherwise, always allow copying
|
||||
copy={!secureEntry || (!isDecryptedValueLoading && showHidden)}
|
||||
value={renderedValue}
|
||||
type={secureEntry ? (isLoading ? 'text' : showHidden ? 'text' : 'password') : 'text'}
|
||||
actions={
|
||||
secureEntry ? (
|
||||
isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
disabled
|
||||
type="default"
|
||||
className="w-7"
|
||||
icon={<Loader className="animate-spin" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
type="default"
|
||||
className="w-7"
|
||||
loading={showHidden && isDecryptedValueLoading}
|
||||
icon={showHidden ? <Eye /> : <EyeOff />}
|
||||
onClick={() => setShowHidden(!showHidden)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
</CardContent>
|
||||
)
|
||||
}
|
||||
+25
-20
@@ -1,8 +1,14 @@
|
||||
import Link from '@ui/components/Typography/Link'
|
||||
import { ScaffoldSectionDescription, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import {
|
||||
ScaffoldHeader,
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionDescription,
|
||||
ScaffoldSectionTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { Card } from 'ui'
|
||||
import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils'
|
||||
import { DESCRIPTIONS } from './constants'
|
||||
@@ -11,9 +17,9 @@ import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
|
||||
|
||||
const wrapperMeta = {
|
||||
options: [
|
||||
{ name: 'vault_token', label: 'Vault Token', secureEntry: false },
|
||||
{ name: 'warehouse', label: 'Warehouse', secureEntry: false },
|
||||
{ name: 's3.endpoint', label: 'S3 Endpoint', secureEntry: false },
|
||||
{ name: 'vault_token', label: 'Vault token', secureEntry: false },
|
||||
{ name: 'warehouse', label: 'Warehouse name', secureEntry: false },
|
||||
{ name: 's3.endpoint', label: 'S3 endpoint', secureEntry: false },
|
||||
{ name: 'catalog_uri', label: 'Catalog URI', secureEntry: false },
|
||||
],
|
||||
}
|
||||
@@ -37,24 +43,23 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldHeader className="flex flex-row justify-between items-end gap-x-8">
|
||||
<div>
|
||||
<ScaffoldSectionTitle>Configuration Details</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription className="mb-4">
|
||||
You can use the following configuration details to connect to the bucket from your code.
|
||||
<ScaffoldSectionTitle>Connection details</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>
|
||||
Connect to this bucket from an Iceberg client.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/analytics/connecting-to-analytics-bucket`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</ScaffoldSectionDescription>
|
||||
</div>
|
||||
<CopyEnvButton serverOptions={wrapperMeta.options} values={values} />
|
||||
</div>
|
||||
<Card className="flex flex-col gap-6 p-6 pb-0">
|
||||
<p className="text-sm text-foreground-light mb-4">
|
||||
To get AWS credentials, you can create them using the{' '}
|
||||
<Link href={`/project/${project?.ref}/storage/settings`}>
|
||||
<a className="underline ">S3 Access Keys</a>
|
||||
</Link>{' '}
|
||||
feature.
|
||||
</p>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<Card>
|
||||
{wrapperMeta.options.map((option) => {
|
||||
return (
|
||||
<DecryptedReadOnlyInput
|
||||
@@ -67,6 +72,6 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
@@ -8,17 +8,17 @@ export const OPTION_ORDER = [
|
||||
]
|
||||
|
||||
export const LABELS: Record<string, string> = {
|
||||
vault_aws_access_key_id: 'S3 Access Key ID',
|
||||
vault_aws_secret_access_key: 'S3 Secret Access Key',
|
||||
vault_token: 'Catalog Token',
|
||||
warehouse: 'Warehouse Name',
|
||||
's3.endpoint': 'S3 Endpoint',
|
||||
vault_aws_access_key_id: 'S3 access key ID',
|
||||
vault_aws_secret_access_key: 'S3 secret access key',
|
||||
vault_token: 'Catalog token',
|
||||
warehouse: 'Warehouse name',
|
||||
's3.endpoint': 'S3 endpoint',
|
||||
catalog_uri: 'Catalog URI',
|
||||
}
|
||||
|
||||
export const DESCRIPTIONS: Record<string, string> = {
|
||||
vault_aws_access_key_id: 'Matches the AWS access key ID from a S3 Access Key.',
|
||||
vault_aws_secret_access_key: 'Matches the AWS secret access from a S3 Access Key.',
|
||||
vault_aws_access_key_id: 'Matches the AWS access key ID from an S3 access key.',
|
||||
vault_aws_secret_access_key: 'Matches the AWS secret access from an S3 access key.',
|
||||
vault_token: 'Corresponds to the service role key.',
|
||||
warehouse: 'Matches the name of the bucket.',
|
||||
's3.endpoint': '',
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { snakeCase, uniq } from 'lodash'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
|
||||
import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
|
||||
@@ -10,14 +7,17 @@ import {
|
||||
formatWrapperTables,
|
||||
wrapperMetaComparator,
|
||||
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
|
||||
import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import {
|
||||
ScaffoldContainer,
|
||||
ScaffoldHeader,
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionDescription,
|
||||
ScaffoldSectionTitle,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import {
|
||||
DatabaseExtension,
|
||||
useDatabaseExtensionsQuery,
|
||||
@@ -29,20 +29,24 @@ import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-cr
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { snakeCase, uniq } from 'lodash'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { DeleteBucketModal } from '../DeleteBucketModal'
|
||||
import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './constants'
|
||||
import { CopyEnvButton } from './CopyEnvButton'
|
||||
import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
|
||||
@@ -51,6 +55,8 @@ import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
|
||||
import { useIcebergWrapperExtension } from './useIcebergWrapper'
|
||||
|
||||
export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
const [modal, setModal] = useState<'delete' | null>(null)
|
||||
const isStorageV2 = useIsNewStorageUIEnabled()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const { data: extensionsData } = useDatabaseExtensionsQuery({
|
||||
@@ -131,6 +137,8 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
|
||||
const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
|
||||
|
||||
const config = BUCKET_TYPES['analytics']
|
||||
|
||||
const state = isFDWsLoading
|
||||
? 'loading'
|
||||
: extensionState === 'installed'
|
||||
@@ -140,131 +148,225 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
: extensionState
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<ScaffoldContainer className="flex flex-row justify-between items-center gap-10">
|
||||
<ScaffoldHeader>
|
||||
<ScaffoldTitle>
|
||||
Analytics Bucket <span className="text-brand">{bucket.name}</span>
|
||||
</ScaffoldTitle>
|
||||
<ScaffoldSectionDescription>
|
||||
Namespaces and tables connected to this bucket.
|
||||
</ScaffoldSectionDescription>
|
||||
</ScaffoldHeader>
|
||||
<DocsButton href={`${DOCS_URL}/guides/storage/analytics/introduction`} />
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldContainer className="flex flex-col gap-4" bottomPadding>
|
||||
{state === 'loading' && <GenericSkeletonLoader />}
|
||||
{state === 'not-installed' && (
|
||||
<ExtensionNotInstalled
|
||||
bucketName={bucket.name}
|
||||
projectRef={project?.ref!}
|
||||
wrapperMeta={wrapperMeta}
|
||||
wrappersExtension={wrappersExtension!}
|
||||
/>
|
||||
)}
|
||||
{state === 'needs-upgrade' && (
|
||||
<ExtensionNeedsUpgrade
|
||||
bucketName={bucket.name}
|
||||
projectRef={project?.ref!}
|
||||
wrapperMeta={wrapperMeta}
|
||||
wrappersExtension={wrappersExtension!}
|
||||
/>
|
||||
)}
|
||||
{state === 'added' && wrapperInstance && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoadingNamespaces || isFDWsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : namespaces.length === 0 ? (
|
||||
<Card className="flex flex-col px-20 py-16 items-center justify-center space-y-3">
|
||||
<p className="text-sm text-foreground-light">No namespaces in this bucket</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Create a namespace and add some data{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand underline"
|
||||
href={`${DOCS_URL}/guides/storage/analytics/connecting-to-analytics-bucket`}
|
||||
>
|
||||
{' '}
|
||||
to get started
|
||||
</a>
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Tables</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{namespaces.map(({ namespace, schema, tables }) => (
|
||||
<NamespaceRow
|
||||
key={namespace}
|
||||
bucketName={bucket.name}
|
||||
namespace={namespace}
|
||||
schema={schema}
|
||||
tables={tables as any}
|
||||
token={token!}
|
||||
wrapperInstance={wrapperInstance}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<PageLayout
|
||||
title={bucket.name}
|
||||
breadcrumbs={
|
||||
isStorageV2
|
||||
? [
|
||||
{
|
||||
label: 'Analytics',
|
||||
href: `/project/${project?.ref}/storage/analytics`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
secondaryActions={config?.docsUrl ? [<DocsButton key="docs" href={config.docsUrl} />] : []}
|
||||
>
|
||||
<ScaffoldContainer bottomPadding>
|
||||
{state === 'loading' && (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<GenericSkeletonLoader />
|
||||
</ScaffoldSection>
|
||||
)}
|
||||
{state === 'not-installed' && (
|
||||
<ExtensionNotInstalled
|
||||
bucketName={bucket.name}
|
||||
projectRef={project?.ref!}
|
||||
wrapperMeta={wrapperMeta}
|
||||
wrappersExtension={wrappersExtension!}
|
||||
/>
|
||||
)}
|
||||
{state === 'needs-upgrade' && (
|
||||
<ExtensionNeedsUpgrade
|
||||
bucketName={bucket.name}
|
||||
projectRef={project?.ref!}
|
||||
wrapperMeta={wrapperMeta}
|
||||
wrappersExtension={wrappersExtension!}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<ScaffoldSectionTitle>Connection Details</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription className="mb-4">
|
||||
You can use the following parameters to connect to the bucket from an Iceberg
|
||||
client.
|
||||
</ScaffoldSectionDescription>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{state === 'added' && wrapperInstance && (
|
||||
<>
|
||||
{isStorageV2 ? (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldHeader className="flex flex-row justify-between items-end gap-x-8">
|
||||
<div>
|
||||
<ScaffoldSectionTitle>Tables</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>
|
||||
Analytics tables connected to this bucket.
|
||||
</ScaffoldSectionDescription>
|
||||
</div>
|
||||
<Button type="primary" size="tiny" icon={<Plus size={14} />} onClick={() => {}}>
|
||||
New table
|
||||
</Button>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-foreground-muted">Name</TableHead>
|
||||
<TableHead className="text-foreground-muted">Schema</TableHead>
|
||||
<TableHead className="text-foreground-muted">Created at</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No tables yet</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Create an analytics table to get started
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</ScaffoldSection>
|
||||
) : (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldHeader>
|
||||
<ScaffoldSectionTitle>Namespaces</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>
|
||||
Connected namespaces and tables.
|
||||
</ScaffoldSectionDescription>
|
||||
</ScaffoldHeader>
|
||||
|
||||
{isLoadingNamespaces || isFDWsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : namespaces.length === 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-foreground-muted">Namespace</TableHead>
|
||||
<TableHead className="text-foreground-muted">Schema</TableHead>
|
||||
<TableHead className="text-foreground-muted">Tables</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">
|
||||
No namespaces in this bucket
|
||||
</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Create a namespace and add some data
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Tables</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{namespaces.map(({ namespace, schema, tables }) => (
|
||||
<NamespaceRow
|
||||
key={namespace}
|
||||
bucketName={bucket.name}
|
||||
namespace={namespace}
|
||||
schema={schema}
|
||||
tables={tables as any}
|
||||
token={token!}
|
||||
wrapperInstance={wrapperInstance}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
)}
|
||||
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldHeader className="flex flex-row justify-between items-end gap-x-8">
|
||||
<div>
|
||||
<ScaffoldSectionTitle>Configuration</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>
|
||||
Connect to this bucket from an Iceberg client.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/analytics/connecting-to-analytics-bucket`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</ScaffoldSectionDescription>
|
||||
</div>
|
||||
<CopyEnvButton
|
||||
serverOptions={wrapperMeta.server.options.filter(
|
||||
(option) => !option.hidden && wrapperValues[option.name]
|
||||
)}
|
||||
values={wrapperValues}
|
||||
/>
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/guides/storage/analytics/connecting-to-analytics-bucket`}
|
||||
/>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<Card>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden && wrapperValues[option.name])
|
||||
.sort((a, b) => OPTION_ORDER.indexOf(a.name) - OPTION_ORDER.indexOf(b.name))
|
||||
.map((option) => {
|
||||
return (
|
||||
<DecryptedReadOnlyInput
|
||||
key={option.name}
|
||||
label={LABELS[option.name]}
|
||||
value={wrapperValues[option.name]}
|
||||
secureEntry={option.secureEntry}
|
||||
descriptionText={DESCRIPTIONS[option.name]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
</ScaffoldSection>
|
||||
</>
|
||||
)}
|
||||
{state === 'missing' && <WrapperMissing bucketName={bucket.name} />}
|
||||
|
||||
<ScaffoldSection isFullWidth className="flex flex-col gap-y-4">
|
||||
<header>
|
||||
<ScaffoldSectionTitle>Manage</ScaffoldSectionTitle>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col md:flex-row md:justify-between gap-y-4 gap-x-8 md:items-center">
|
||||
<div className="flex flex-col">
|
||||
<h3>Delete bucket</h3>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
This will also delete any data in your bucket. Make sure you have a backup if
|
||||
you want to keep your data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="flex flex-col gap-8 p-6">
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden && wrapperValues[option.name])
|
||||
.sort((a, b) => OPTION_ORDER.indexOf(a.name) - OPTION_ORDER.indexOf(b.name))
|
||||
.map((option) => {
|
||||
return (
|
||||
<DecryptedReadOnlyInput
|
||||
key={option.name}
|
||||
label={LABELS[option.name]}
|
||||
value={wrapperValues[option.name]}
|
||||
secureEntry={option.secureEntry}
|
||||
descriptionText={DESCRIPTIONS[option.name]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state === 'missing' && <WrapperMissing bucketName={bucket.name} />}
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
setModal('delete')
|
||||
}}
|
||||
>
|
||||
Delete bucket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
|
||||
<DeleteBucketModal
|
||||
visible={modal === `delete`}
|
||||
bucket={bucket}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,24 +386,22 @@ const ExtensionNotInstalled = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
You need to install the wrappers extension to connect this Analytics bucket to the
|
||||
database.
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing required extension" className="mb-0">
|
||||
<p>
|
||||
The {wrapperMeta.label} wrapper requires the Wrappers extension to be installed. You can
|
||||
install version {wrappersExtension?.installed_version}
|
||||
The Wrappers extension is required in order to query analytics tables.{' '}
|
||||
{databaseNeedsUpgrading &&
|
||||
' which is below the minimum version that supports Iceberg wrapper'}
|
||||
. Please {databaseNeedsUpgrading && 'upgrade your database then '}install the{' '}
|
||||
<code className="text-xs">wrappers</code> extension to create this wrapper.
|
||||
'Please first upgrade your database and then install the extension.'}{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-lighter hover:text-foreground transition-colors"
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<Button asChild type="default">
|
||||
<Button type="default" asChild className="mt-2" onClick={() => {}}>
|
||||
<Link
|
||||
href={
|
||||
databaseNeedsUpgrading
|
||||
@@ -309,11 +409,11 @@ const ExtensionNotInstalled = ({
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Install wrappers extension'}
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
@@ -337,26 +437,19 @@ const ExtensionNeedsUpgrade = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
Your extension version is outdated for this wrapper.
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Outdated extension version" className="mb-0">
|
||||
<p>
|
||||
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
|
||||
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
|
||||
{wrappersExtension?.installed_version} installed. Please{' '}
|
||||
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
|
||||
disabling and enabling the <code className="text-xs">wrappers</code> extension to create
|
||||
this wrapper.
|
||||
{databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension
|
||||
by disabling and enabling the Wrappers extension.
|
||||
</p>
|
||||
<p className="text-warning">
|
||||
Warning: Before reinstalling the wrapper extension, you must first remove all existing
|
||||
wrappers. Afterward, you can recreate the wrappers.
|
||||
<p>
|
||||
Before reinstalling the wrapper extension, you must first remove all existing wrappers.
|
||||
Afterward, you can recreate the wrappers.
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={
|
||||
@@ -365,11 +458,11 @@ const ExtensionNeedsUpgrade = ({
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
@@ -385,20 +478,14 @@ const WrapperMissing = ({ bucketName }: { bucketName: string }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
This Analytics bucket does not have a foreign data wrapper setup.
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<p>You need to setup a wrapper to connect this bucket to the database.</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing integration" className="mb-0">
|
||||
<p>The Iceberg Wrapper integration is required in order to query analytics tables.</p>
|
||||
<Button type="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}>
|
||||
Setup a wrapper
|
||||
Install wrapper
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -550,59 +550,61 @@ export const CreateBucketModal = ({
|
||||
) : (
|
||||
<>
|
||||
{icebergWrapperExtensionState === 'installed' ? (
|
||||
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
|
||||
<p>
|
||||
<span>Supabase will setup a </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
foreign data wrapper
|
||||
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
|
||||
</a>
|
||||
<span>
|
||||
{' '}
|
||||
for easier access to the data. This action will also create{' '}
|
||||
<DialogSection>
|
||||
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
|
||||
<p>
|
||||
<span>Supabase will setup a </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
S3 Access Keys
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
named <span className="text-brand"> {`${bucketName}_keys`}</span>
|
||||
</>
|
||||
)}
|
||||
foreign data wrapper
|
||||
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
|
||||
</a>
|
||||
<span> and </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
four Vault Secrets
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
prefixed with{' '}
|
||||
<span className="text-brand"> {`${bucketName}_vault_`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
As a final step, you'll need to create an{' '}
|
||||
<span className="text-foreground-light">Iceberg namespace</span> before you
|
||||
connect the Iceberg data to your database.
|
||||
</p>
|
||||
</Label_Shadcn_>
|
||||
<span>
|
||||
{' '}
|
||||
for easier access to the data. This action will also create{' '}
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
S3 Access Keys
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
named <span className="text-brand"> {`${bucketName}_keys`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
<span> and </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
four Vault Secrets
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
prefixed with{' '}
|
||||
<span className="text-brand"> {`${bucketName}_vault_`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
As a final step, you'll need to create an{' '}
|
||||
<span className="text-foreground-light">Iceberg namespace</span> before you
|
||||
connect the Iceberg data to your database.
|
||||
</p>
|
||||
</Label_Shadcn_>
|
||||
</DialogSection>
|
||||
) : (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<Alert_Shadcn_ variant="warning" className="rounded-none border-0">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
You need to install the Iceberg wrapper extension to connect your Analytic
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useParams } from 'common'
|
||||
import { useBucketsQuery } from 'data/storage/buckets-query'
|
||||
import { useStorageV2Page } from '../Storage.utils'
|
||||
|
||||
export const useSelectedBucket = () => {
|
||||
const page = useStorageV2Page()
|
||||
const { ref, bucketId } = useParams()
|
||||
|
||||
const { data: buckets = [], isSuccess, isError, error } = useBucketsQuery({ projectRef: ref })
|
||||
const bucket = buckets.find((b) => b.id === bucketId)
|
||||
const bucketsByType =
|
||||
page === 'files'
|
||||
? buckets.filter((b) => b.type === 'STANDARD')
|
||||
: page === 'analytics'
|
||||
? buckets.filter((b) => b.type === 'ANALYTICS')
|
||||
: buckets
|
||||
const bucket = bucketsByType.find((b) => b.id === bucketId)
|
||||
|
||||
return { bucket, isSuccess, isError, error }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => {
|
||||
const suffix = !!featurePreviewModal ? `?featurePreviewModal=${featurePreviewModal}` : ''
|
||||
|
||||
if (isStorageV2) {
|
||||
// From old UI to new UI
|
||||
if (pathname.endsWith('/storage/settings')) {
|
||||
router.push(`/project/${ref}/storage/files/settings${suffix}`)
|
||||
} else if (pathname.endsWith('/storage/policies')) {
|
||||
@@ -38,6 +39,7 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// From new UI to old UI
|
||||
if (pathname.endsWith('/files/settings')) {
|
||||
router.push(`/project/${ref}/storage/settings${suffix}`)
|
||||
} else if (pathname.endsWith('/files/policies')) {
|
||||
@@ -47,6 +49,12 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => {
|
||||
pathname.endsWith('/analytics/buckets/[bucketId]')
|
||||
) {
|
||||
router.push(`/project/${ref}/storage/buckets/${bucketId}${suffix}`)
|
||||
} else if (
|
||||
pathname.endsWith('/storage/files') ||
|
||||
pathname.endsWith('/storage/analytics') ||
|
||||
pathname.endsWith('/storage/vectors')
|
||||
) {
|
||||
router.push(`/project/${ref}/storage/buckets`)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -11,7 +11,7 @@ interface InlineLinkProps {
|
||||
}
|
||||
|
||||
export const InlineLinkClassName =
|
||||
'underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground'
|
||||
'underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-inherit hover:text-foreground'
|
||||
|
||||
export const InlineLink = ({
|
||||
href,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticBucketDetails'
|
||||
import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError'
|
||||
import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const AnalyticsBucketPage: NextPageWithLayout = () => {
|
||||
const { bucketId } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { projectRef } = useStorageExplorerStateSnapshot()
|
||||
const { bucket, error, isSuccess, isError } = useSelectedBucket()
|
||||
|
||||
// [Joshen] Checking against projectRef from storage explorer to check if the store has initialized
|
||||
if (!project || !projectRef) return null
|
||||
|
||||
return (
|
||||
<div className="storage-container flex flex-grow p-4">
|
||||
{isError && <StorageBucketsError error={error as any} />}
|
||||
|
||||
{isSuccess ? (
|
||||
!bucket ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-foreground-light">Bucket {bucketId} cannot be found</p>
|
||||
</div>
|
||||
) : bucket.type === 'ANALYTICS' ? (
|
||||
<AnalyticBucketDetails bucket={bucket} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-foreground-light">This bucket is not an analytics bucket</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AnalyticsBucketPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<StorageLayout title="Buckets">{page}</StorageLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default AnalyticsBucketPage
|
||||
Reference in new issue
Block a user