| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- 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 { BucketHeader } from './BucketHeader'
- import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
- import { NamespaceWithTables } from './NamespaceWithTables'
- import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
- import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
- import { useIcebergWrapperExtension } from './useIcebergWrapper'
- import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
- import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types'
- import {
- convertKVStringArrayToJson,
- formatWrapperTables,
- } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
- import {
- ScaffoldContainer,
- ScaffoldSection,
- 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 { 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()
- const { ref: projectRef } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const { state: extensionState } = useIcebergWrapperExtension()
- const {
- data: bucket,
- error: bucketError,
- isSuccess: isSuccessBucket,
- isError: isErrorBucket,
- } = useSelectedAnalyticsBucket()
- const [showDeleteModal, setShowDeleteModal] = useQueryState(
- 'delete',
- parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
- )
- // [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after
- // updating connected tables until namespaces are updated
- // Namespace would just be the schema (Which is currently limited to public)
- // Wrapper table would be {schema}_{table}_changelog
- 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')
- const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
- const state = isLoadingWrapperInstance
- ? 'loading'
- : extensionState === 'installed'
- ? wrapperInstance
- ? 'added'
- : 'missing'
- : extensionState
- const wrapperTables = useMemo(() => {
- if (!wrapperInstance) return []
- return formatWrapperTables(wrapperInstance, wrapperMeta!)
- }, [wrapperInstance, wrapperMeta])
- const { data: extensionsData } = useDatabaseExtensionsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
- const {
- data: namespacesData = [],
- isPending: isLoadingNamespaces,
- isSuccess: isSuccessNamespaces,
- } = useIcebergNamespacesQuery(
- {
- projectRef,
- warehouse: wrapperValues.warehouse,
- },
- {
- refetchInterval: (query) => {
- const data = query.state.data
- if (pollIntervalNamespaces === 0) return false
- const publicationTableSchemas = publication?.tables.map((x) => x.schema) ?? []
- const isSynced = !publicationTableSchemas.some((x) => !data?.includes(x))
- if (isSynced) {
- setPollIntervalNamespaces(0)
- return false
- }
- return pollIntervalNamespaces
- },
- }
- )
- const publicationTableSchemas = (publication?.tables ?? []).map((x) => x.schema)
- const isSyncedPublicationTableSchemasAndNamespaces = !publicationTableSchemas.some(
- (x) => !namespacesData.includes(x)
- )
- const isPollingForData = pollIntervalNamespaces > 0 || pollIntervalNamespaceTables > 0
- const namespaces = useMemo(() => {
- const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[]
- const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])])
- return namespaces.map((namespace) => {
- const tables = wrapperTables.filter((t) => t.table.split('.')[0] === namespace)
- const schema = tables[0]?.schema
- return {
- namespace: namespace,
- schema: schema,
- tables: tables,
- }
- })
- }, [wrapperTables, namespacesData])
- useEffect(() => {
- if (isSuccessNamespaces && !isSyncedPublicationTableSchemasAndNamespaces) {
- setPollIntervalNamespaces(4000)
- }
- }, [isSuccessNamespaces, isSyncedPublicationTableSchemasAndNamespaces])
- return (
- <>
- {isErrorBucket ? (
- <ScaffoldContainer bottomPadding>
- <ScaffoldSection isFullWidth>
- <AlertError subject="Failed to fetch analytics buckets" error={bucketError} />
- </ScaffoldSection>
- </ScaffoldContainer>
- ) : (
- <ScaffoldContainer bottomPadding>
- {state === 'loading' ? (
- <ScaffoldSection isFullWidth>
- <BucketHeader showActions={false} />
- <GenericTableLoader />
- </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!}
- />
- ) : state === 'missing' ? (
- <WrapperMissing bucketName={bucket?.name} />
- ) : state === 'added' && wrapperInstance ? (
- <>
- <ScaffoldSection isFullWidth>
- <BucketHeader />
- {isLoadingNamespaces || isLoadingWrapperInstance ? (
- <GenericTableLoader headers={['Name']} />
- ) : namespaces.length === 0 ? (
- <>
- {HIDE_REPLICATION_USER_FLOW ? (
- <CreateTableInstructions />
- ) : isPollingForData ? (
- <EmptyStatePresentational
- icon={
- <Loader2
- size={24}
- strokeWidth={1.5}
- className="animate-spin text-foreground-muted"
- />
- }
- title="Connecting table(s) to bucket"
- description="Tables will be shown here once the connection is complete"
- />
- ) : 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 type="default">
- <Link
- href={`/project/${projectRef}/database/replication/${pipeline.replicator_id}`}
- >
- View replication
- </Link>
- </Button>
- {isPipelineStopped && (
- <Button
- type="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>
- </>
- )}
- </ScaffoldSection>
- <SimpleConfigurationDetails bucketName={bucket?.name} />
- </>
- ) : null}
- <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>
- <Button
- type="danger"
- disabled={!bucket?.name || !isSuccessBucket}
- onClick={() => setShowDeleteModal(true)}
- >
- Delete bucket
- </Button>
- </CardContent>
- </Card>
- </ScaffoldSection>
- </ScaffoldContainer>
- )}
- <DeleteAnalyticsBucketModal
- visible={showDeleteModal}
- bucketId={bucket?.name}
- onClose={() => setShowDeleteModal(false)}
- onSuccess={() => router.push(`/project/${projectRef}/storage/analytics`)}
- />
- </>
- )
- }
- 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 type="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 type="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 type="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}>
- Install wrapper
- </Button>
- </Admonition>
- </ScaffoldSection>
- <SimpleConfigurationDetails bucketName={bucketName} />
- </>
- )
- }
|