mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Add user flow for iceberg wrapper if api keys are rotated (#47336)
## Context We found an issue regarding Analytics Buckets and the Iceberg wrapper - upon creation of an analytics bucket, the wrapper is automatically created for the users which involves using the project's API keys as the catalog's token. However, if the user were to rotate the API keys, this will cause the wrapper to break and there's currently no clear user flow for the user to self-remediate - the only indicator they'll see is just a 403 error (e.g when trying to view the analytics bucket table via FDW on the table editor or SQL editor) ## Changes involved Am adding a user path for users to self-remediate a little, starting from the Table Editor - we'll add a contextual error message as such if we detect a 403 that's caused by an invalid token: <img width="1110" height="320" alt="Screenshot 2026-06-26 at 17 33 11" src="https://github.com/user-attachments/assets/28ea4ce6-5b81-4217-9952-880acb02f2bd" /> We'll subsequently also float this issue up in the Analytics Bucket UI (which is linked from the contextual error above) <img width="1114" height="466" alt="Screenshot 2026-06-26 at 17 31 52" src="https://github.com/user-attachments/assets/8d112e5b-6ecc-458b-b4dc-7e7647da3fb2" /> And users can then choose to use another API key as the catalog token <img width="585" height="246" alt="Screenshot 2026-06-26 at 17 31 56" src="https://github.com/user-attachments/assets/3d9689a5-b18d-4f07-a5a5-d882e41c5958" /> The warning will thereafter go away, and users will be able to query the FDW again via Table Editor or SQL Editor ## To test - [ ] Create an analytics bucket, set up a table and foreign schema (via Query via Postgres) - [ ] Insert some data, or verify that you can view the iceberg table from the Table Editor - [ ] Now rotate your API secret key (delete the old, create a new) - [ ] Verify that you'll run into that error if you view the iceberg table from the Table Editor - [ ] Follow the flow -> Go to the Analytics Bucket UI to update the catalog token - [ ] Verify that thereafter, you can view the iceberg table again from the Table Editor <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added clearer Iceberg/analytics bucket setup prompts for missing, outdated, or uninstalled wrappers. * Added an “Update catalog token” dialog and a collapsible “View error” troubleshooting UI. * **Bug Fixes** * Improved detection of Iceberg authorization failures and now shows a more specific error with guidance. * Warn users when the saved catalog token no longer matches available API keys. * Enhanced post-update refresh behavior so updated token values display correctly. * **Documentation** * Clarified vault token description to indicate it may be a secret or service role key. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
8192d97008
commit
3d10f2cab9
7 files changed
+398
-217
No files matched your search
@@ -1,13 +1,14 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { isFilterRelatedError } from './GridError.utils'
|
||||
import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
|
||||
import { useTableSort } from '@/components/grid/hooks/useTableSort'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { HighCostError } from '@/components/ui/HighQueryCost'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
@@ -53,6 +54,12 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => {
|
||||
const isForeignTableMissingVaultKeyError =
|
||||
isForeignTable && error?.message?.includes('query vault failed')
|
||||
|
||||
const isIcebergUnauthorizedError =
|
||||
isForeignTable &&
|
||||
error?.message.includes('iceberg error') &&
|
||||
error?.message.includes('403 Forbidden') &&
|
||||
error?.message.includes('Invalid Compact JWS')
|
||||
|
||||
const hasActiveFilters = filters.length > 0
|
||||
|
||||
const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message)
|
||||
@@ -79,6 +86,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => {
|
||||
return <FilterError removeAllFilters={removeAllFilters} />
|
||||
} else if (isInvalidOrderingOperatorError) {
|
||||
return <InvalidOrderingOperatorError error={error} />
|
||||
} else if (isIcebergUnauthorizedError) {
|
||||
return <IcebergUnauthorizedError error={error} />
|
||||
}
|
||||
|
||||
return <GeneralError error={error} />
|
||||
@@ -165,6 +174,30 @@ const InvalidOrderingOperatorError = ({ error }: { error: ResponseError }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const IcebergUnauthorizedError = ({ error }: { error: ResponseError }) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="pointer-events-auto"
|
||||
title="Failed to retrieve rows from Iceberg foreign table"
|
||||
>
|
||||
<p className="text-balance">
|
||||
The API key from your project that's used to retrieve data from your foreign table is either
|
||||
incorrect or missing. Verify the API key (token) in your{' '}
|
||||
<InlineLink href={`/project/${ref}/storage/analytics`}>Iceberg Bucket</InlineLink>.
|
||||
Alternatively, you can also verify the token value in your{' '}
|
||||
<InlineLink href={`/project/${ref}/integrations/iceberg_wrapper/wrappers`}>
|
||||
wrapper's settings
|
||||
</InlineLink>{' '}
|
||||
or in <InlineLink href={`/project/${ref}/integrations/vault/overview`}>Vault</InlineLink>.
|
||||
</p>
|
||||
<ExpandError error={error} />
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
const GeneralError = ({ error }: { error: ResponseError }) => {
|
||||
const { filters } = useTableFilter()
|
||||
|
||||
@@ -183,3 +216,19 @@ const GeneralError = ({ error }: { error: ResponseError }) => {
|
||||
</AlertError>
|
||||
)
|
||||
}
|
||||
|
||||
const ExpandError = ({ error }: { error: ResponseError }) => {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="mt-2 group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
|
||||
<div className="flex items-center gap-x-2 w-full cursor-pointer">
|
||||
<span className="font-mono uppercase tracking-tight">View error</span>
|
||||
<ChevronDown className="transition-transform" size={14} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<code className="text-code-inline">{error.message}</code>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const LABELS: Record<string, string> = {
|
||||
export const DESCRIPTIONS: Record<string, string> = {
|
||||
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.',
|
||||
vault_token: 'Corresponds to the secret or service role key.',
|
||||
warehouse: 'Matches the name of this bucket.',
|
||||
's3.endpoint': '',
|
||||
catalog_uri: '',
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
|
||||
import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types'
|
||||
import { ScaffoldSection } from '@/components/layouts/Scaffold'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { isLessThan } from '@/lib/semver'
|
||||
|
||||
export const ExtensionNotInstalled = ({
|
||||
bucketName,
|
||||
projectRef,
|
||||
wrapperMeta,
|
||||
wrappersExtension,
|
||||
}: {
|
||||
bucketName?: string
|
||||
projectRef: string
|
||||
wrapperMeta: WrapperMeta
|
||||
wrappersExtension: DatabaseExtension
|
||||
}) => {
|
||||
const databaseNeedsUpgrading = isLessThan(
|
||||
wrappersExtension?.default_version ?? '',
|
||||
wrapperMeta?.minimumExtensionVersion ?? ''
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing required extension">
|
||||
<p>
|
||||
The Wrappers extension is required in order to query analytics tables.{' '}
|
||||
{databaseNeedsUpgrading &&
|
||||
'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>
|
||||
<Button variant="default" asChild className="mt-2" onClick={() => {}}>
|
||||
<Link
|
||||
href={
|
||||
databaseNeedsUpgrading
|
||||
? `/project/${projectRef}/settings/infrastructure`
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'}
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExtensionNeedsUpgrade = ({
|
||||
bucketName,
|
||||
projectRef,
|
||||
wrapperMeta,
|
||||
wrappersExtension,
|
||||
}: {
|
||||
bucketName?: string
|
||||
projectRef: string
|
||||
wrapperMeta: WrapperMeta
|
||||
wrappersExtension: DatabaseExtension
|
||||
}) => {
|
||||
// [Joshen] Default version is what's on the DB, so if the installed version is already the default version
|
||||
// but still doesnt meet the minimum extension version, then DB upgrade is required
|
||||
const databaseNeedsUpgrading =
|
||||
wrappersExtension?.installed_version === wrappersExtension?.default_version
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Outdated extension version">
|
||||
<p>
|
||||
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
|
||||
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
|
||||
{wrappersExtension?.installed_version} installed. Please{' '}
|
||||
{databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension
|
||||
by disabling and enabling the Wrappers extension.
|
||||
</p>
|
||||
<p>
|
||||
Before reinstalling the wrapper extension, you must first remove all existing wrappers.
|
||||
Afterward, you can recreate the wrappers.
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link
|
||||
href={
|
||||
databaseNeedsUpgrading
|
||||
? `/project/${projectRef}/settings/infrastructure`
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'}
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => {
|
||||
const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
|
||||
useIcebergWrapperCreateMutation()
|
||||
|
||||
const onSetupWrapper = async () => {
|
||||
if (!bucketName) return console.error('Bucket name is required')
|
||||
await createIcebergWrapper({ bucketName })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing integration">
|
||||
<p>The Iceberg Wrapper integration is required in order to query analytics tables.</p>
|
||||
<Button variant="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}>
|
||||
Install wrapper
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+38
@@ -1,8 +1,11 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Card } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './AnalyticsBucketDetails.constants'
|
||||
import { CopyEnvButton } from './CopyEnvButton'
|
||||
import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
|
||||
import { UpdateCatalogTokenDialog } from './UpdateCatalogTokenDialog'
|
||||
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
|
||||
import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types'
|
||||
@@ -14,9 +17,16 @@ import {
|
||||
ScaffoldSectionTitle,
|
||||
} from '@/components/layouts/Scaffold'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { useVaultSecretDecryptedValueQuery } from '@/data/vault/vault-secret-decrypted-value-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string }) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
|
||||
|
||||
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
|
||||
const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
|
||||
|
||||
@@ -24,6 +34,23 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string
|
||||
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketName })
|
||||
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
|
||||
|
||||
const { data: decryptedToken, isSuccess: isSuccessVaultDecrypt } =
|
||||
useVaultSecretDecryptedValueQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id: wrapperValues?.vault_token,
|
||||
},
|
||||
{ enabled: canReadAPIKeys }
|
||||
)
|
||||
|
||||
const { data: apiKeysData, isSuccess: isSuccessApiKeys } = useAPIKeys(
|
||||
{ projectRef: project?.ref, reveal: true },
|
||||
{ enabled: canReadAPIKeys }
|
||||
)
|
||||
|
||||
const isTokenValid = apiKeysData?.allSecretKeys.some((x) => x.api_key === decryptedToken)
|
||||
|
||||
if (!wrapperInstance) return null
|
||||
|
||||
return (
|
||||
@@ -48,6 +75,17 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string
|
||||
/>
|
||||
</ScaffoldHeader>
|
||||
|
||||
{isSuccessApiKeys && isSuccessVaultDecrypt && !isTokenValid && (
|
||||
<Admonition type="warning" title="Catalog token is no longer valid" className="mb-4">
|
||||
<p>
|
||||
The Iceberg wrapper's catalog token doesn't match with any of your project's API keys,
|
||||
and hence authorization will fail when connecting to your bucket. Update the catalog
|
||||
token to use any of your project's API keys.
|
||||
</p>
|
||||
{canReadAPIKeys && <UpdateCatalogTokenDialog vaultTokenId={wrapperValues?.vault_token} />}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden && wrapperValues[option.name])
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from 'ui'
|
||||
import { Admonition, ShimmeringLoader } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { fdwKeys } from '@/data/fdw/keys'
|
||||
import { vaultSecretsKeys } from '@/data/vault/keys'
|
||||
import { useVaultSecretUpdateMutation } from '@/data/vault/vault-secret-update-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
export const UpdateCatalogTokenDialog = ({ vaultTokenId }: { vaultTokenId?: string }) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedKey, setSelectedKey] = useState<string>()
|
||||
|
||||
const { data: apiKeysData, isPending } = useAPIKeys(
|
||||
{ projectRef: ref, reveal: true },
|
||||
{ enabled: canReadAPIKeys }
|
||||
)
|
||||
const { allSecretKeys } = apiKeysData ?? {}
|
||||
|
||||
const { mutate: updateVaultSecret, isPending: isUpdating } = useVaultSecretUpdateMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: fdwKeys.list(ref) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: vaultSecretsKeys.getDecryptedValue(ref, vaultTokenId),
|
||||
}),
|
||||
])
|
||||
|
||||
toast.success('Successfully updated catalog token!')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
if (!vaultTokenId) return toast.error('ID of catalog token is missing')
|
||||
|
||||
updateVaultSecret({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id: vaultTokenId,
|
||||
secret: selectedKey,
|
||||
skipClearCache: true,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allSecretKeys?.length) setSelectedKey(allSecretKeys[0].api_key)
|
||||
}, [allSecretKeys])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" className="mt-1">
|
||||
Update catalog token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Iceberg wrapper catalog token</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
{allSecretKeys?.length === 0 && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Project has no API secret keys"
|
||||
className="rounded-none border-x-0 border-t-0"
|
||||
>
|
||||
<p>
|
||||
Create an API secret key from your{' '}
|
||||
<InlineLink href={`/project/${ref}/settings/api-keys`}>project's settings</InlineLink>{' '}
|
||||
first
|
||||
</p>
|
||||
</Admonition>
|
||||
)}
|
||||
<DialogSection>
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
label="Select a secret key to use as your catalog token"
|
||||
>
|
||||
{isPending ? (
|
||||
<ShimmeringLoader className="py-4" />
|
||||
) : (
|
||||
<Select value={selectedKey} onValueChange={setSelectedKey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{apiKeysData?.allSecretKeys.map((x) => (
|
||||
<SelectItem key={x.id} value={x.api_key}>
|
||||
{x.prefix} •••
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button variant="default" disabled={isUpdating} onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!selectedKey}
|
||||
loading={isUpdating}
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
Update token
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+16
-212
@@ -1,18 +1,17 @@
|
||||
import { useParams } from 'common'
|
||||
import { uniq } from 'lodash'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
import { EmptyStatePresentational } from 'ui-patterns'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal'
|
||||
import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket'
|
||||
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
|
||||
import { ExtensionNeedsUpgrade, ExtensionNotInstalled, WrapperMissing } from './BucketCallouts'
|
||||
import { BucketHeader } from './BucketHeader'
|
||||
import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
|
||||
import { NamespaceWithTables } from './NamespaceWithTables'
|
||||
@@ -31,17 +30,9 @@ import {
|
||||
ScaffoldSectionTitle,
|
||||
} from '@/components/layouts/Scaffold'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import {
|
||||
DatabaseExtension,
|
||||
useDatabaseExtensionsQuery,
|
||||
} from '@/data/database-extensions/database-extensions-query'
|
||||
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
|
||||
import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query'
|
||||
import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const AnalyticBucketDetails = () => {
|
||||
const router = useRouter()
|
||||
@@ -66,30 +57,14 @@ export const AnalyticBucketDetails = () => {
|
||||
const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0)
|
||||
const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0)
|
||||
|
||||
const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation()
|
||||
|
||||
const {
|
||||
publication,
|
||||
pipeline,
|
||||
icebergWrapper: wrapperInstance,
|
||||
isLoadingWrapperInstance,
|
||||
} = useAnalyticsBucketAssociatedEntities({
|
||||
projectRef,
|
||||
bucketId: bucket?.name,
|
||||
})
|
||||
const { data, isSuccess: isSuccessPipelineStatus } = useReplicationPipelineStatusQuery(
|
||||
{ projectRef, pipelineId: pipeline?.id },
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (data?.status.name !== 'started') return 4000
|
||||
else return false
|
||||
},
|
||||
}
|
||||
)
|
||||
const pipelineStatus = data?.status.name
|
||||
const isPipelineRunning = pipelineStatus === 'started'
|
||||
const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '')
|
||||
|
||||
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
|
||||
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
|
||||
@@ -224,69 +199,20 @@ export const AnalyticBucketDetails = () => {
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!!pipeline && !!isSuccessPipelineStatus && !isPipelineRunning && (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="horizontal"
|
||||
className="[&>div]:pl-10 [&>div]:translate-y-[-3px]"
|
||||
childProps={{ title: { className: 'block capitalize-sentence' } }}
|
||||
showIcon={isPipelineStopped}
|
||||
title={
|
||||
isPipelineStopped
|
||||
? `Replication on the bucket has ${pipelineStatus}`
|
||||
: `${pipelineStatus} replication on the bucket...`
|
||||
}
|
||||
description={
|
||||
isPipelineStopped
|
||||
? 'Data changes from Postgres tables is currently not streaming to their corresponding analytics bucket table'
|
||||
: 'Data changes from Postgres tables will resume streaming once pipeline has started'
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild variant="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/replication/${pipeline.replicator_id}`}
|
||||
>
|
||||
View pipeline
|
||||
</Link>
|
||||
</Button>
|
||||
{isPipelineStopped && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={isStartingPipeline}
|
||||
onClick={async () => {
|
||||
if (projectRef) {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
}
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!isPipelineStopped && (
|
||||
<Loader2 size={18} className="absolute top-1.5 left-[3px] animate-spin" />
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-10">
|
||||
{namespaces.map(({ namespace, schema, tables }) => (
|
||||
<NamespaceWithTables
|
||||
key={namespace}
|
||||
namespace={namespace}
|
||||
sourceType="direct"
|
||||
schema={schema}
|
||||
tables={tables as any}
|
||||
wrapperValues={wrapperValues}
|
||||
pollIntervalNamespaceTables={pollIntervalNamespaceTables}
|
||||
setPollIntervalNamespaceTables={setPollIntervalNamespaceTables}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-col gap-y-10">
|
||||
{namespaces.map(({ namespace, schema, tables }) => (
|
||||
<NamespaceWithTables
|
||||
key={namespace}
|
||||
namespace={namespace}
|
||||
sourceType="direct"
|
||||
schema={schema}
|
||||
tables={tables as any}
|
||||
wrapperValues={wrapperValues}
|
||||
pollIntervalNamespaceTables={pollIntervalNamespaceTables}
|
||||
setPollIntervalNamespaceTables={setPollIntervalNamespaceTables}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
|
||||
@@ -329,125 +255,3 @@ export const AnalyticBucketDetails = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionNotInstalled = ({
|
||||
bucketName,
|
||||
projectRef,
|
||||
wrapperMeta,
|
||||
wrappersExtension,
|
||||
}: {
|
||||
bucketName?: string
|
||||
projectRef: string
|
||||
wrapperMeta: WrapperMeta
|
||||
wrappersExtension: DatabaseExtension
|
||||
}) => {
|
||||
const databaseNeedsUpgrading =
|
||||
(wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing required extension">
|
||||
<p>
|
||||
The Wrappers extension is required in order to query analytics tables.{' '}
|
||||
{databaseNeedsUpgrading &&
|
||||
'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>
|
||||
<Button variant="default" asChild className="mt-2" onClick={() => {}}>
|
||||
<Link
|
||||
href={
|
||||
databaseNeedsUpgrading
|
||||
? `/project/${projectRef}/settings/infrastructure`
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'}
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionNeedsUpgrade = ({
|
||||
bucketName,
|
||||
projectRef,
|
||||
wrapperMeta,
|
||||
wrappersExtension,
|
||||
}: {
|
||||
bucketName?: string
|
||||
projectRef: string
|
||||
wrapperMeta: WrapperMeta
|
||||
wrappersExtension: DatabaseExtension
|
||||
}) => {
|
||||
// [Joshen] Default version is what's on the DB, so if the installed version is already the default version
|
||||
// but still doesnt meet the minimum extension version, then DB upgrade is required
|
||||
const databaseNeedsUpgrading =
|
||||
wrappersExtension?.installed_version === wrappersExtension?.default_version
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Outdated extension version">
|
||||
<p>
|
||||
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
|
||||
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
|
||||
{wrappersExtension?.installed_version} installed. Please{' '}
|
||||
{databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension
|
||||
by disabling and enabling the Wrappers extension.
|
||||
</p>
|
||||
<p>
|
||||
Before reinstalling the wrapper extension, you must first remove all existing wrappers.
|
||||
Afterward, you can recreate the wrappers.
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link
|
||||
href={
|
||||
databaseNeedsUpgrading
|
||||
? `/project/${projectRef}/settings/infrastructure`
|
||||
: `/project/${projectRef}/database/extensions?filter=wrappers`
|
||||
}
|
||||
>
|
||||
{databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'}
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const WrapperMissing = ({ bucketName }: { bucketName?: string }) => {
|
||||
const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
|
||||
useIcebergWrapperCreateMutation()
|
||||
|
||||
const onSetupWrapper = async () => {
|
||||
if (!bucketName) return console.error('Bucket name is required')
|
||||
await createIcebergWrapper({ bucketName })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Admonition type="warning" title="Missing integration">
|
||||
<p>The Iceberg Wrapper integration is required in order to query analytics tables.</p>
|
||||
<Button variant="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}>
|
||||
Install wrapper
|
||||
</Button>
|
||||
</Admonition>
|
||||
</ScaffoldSection>
|
||||
<SimpleConfigurationDetails bucketName={bucketName} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type VaultSecretUpdateVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
id: string
|
||||
skipClearCache?: boolean
|
||||
} & Partial<VaultSecret>
|
||||
|
||||
export async function updateVaultSecret({
|
||||
@@ -40,9 +41,15 @@ export const useVaultSecretUpdateMutation = ({
|
||||
return useMutation<VaultSecretUpdateData, ResponseError, VaultSecretUpdateVariables>({
|
||||
mutationFn: (vars) => updateVaultSecret(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { id, projectRef } = variables
|
||||
const { id, projectRef, skipClearCache = false } = variables
|
||||
await Promise.all([
|
||||
queryClient.removeQueries({ queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id) }),
|
||||
!skipClearCache
|
||||
? queryClient.removeQueries({
|
||||
queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id),
|
||||
})
|
||||
: queryClient.invalidateQueries({
|
||||
queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: vaultSecretsKeys.list(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
Reference in new issue
Block a user