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:
Danny WhiteandJoshen Lim authored and GitHub committed 2025-10-17 11:08:04 +11:00
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>
)
}
@@ -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>
)
}
@@ -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
+1 -1
View File
@@ -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