index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import { useParams } from 'common'
  2. import { uniq } from 'lodash'
  3. import { Loader2 } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useRouter } from 'next/router'
  6. import { parseAsBoolean, useQueryState } from 'nuqs'
  7. import { useEffect, useMemo, useState } from 'react'
  8. import { Button, Card, CardContent } from 'ui'
  9. import { EmptyStatePresentational } from 'ui-patterns'
  10. import { Admonition } from 'ui-patterns/admonition'
  11. import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader'
  12. import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal'
  13. import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket'
  14. import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
  15. import { BucketHeader } from './BucketHeader'
  16. import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
  17. import { NamespaceWithTables } from './NamespaceWithTables'
  18. import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
  19. import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
  20. import { useIcebergWrapperExtension } from './useIcebergWrapper'
  21. import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
  22. import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types'
  23. import {
  24. convertKVStringArrayToJson,
  25. formatWrapperTables,
  26. } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
  27. import {
  28. ScaffoldContainer,
  29. ScaffoldSection,
  30. ScaffoldSectionTitle,
  31. } from '@/components/layouts/Scaffold'
  32. import AlertError from '@/components/ui/AlertError'
  33. import { InlineLink } from '@/components/ui/InlineLink'
  34. import {
  35. DatabaseExtension,
  36. useDatabaseExtensionsQuery,
  37. } from '@/data/database-extensions/database-extensions-query'
  38. import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
  39. import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
  40. import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query'
  41. import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation'
  42. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  43. import { DOCS_URL } from '@/lib/constants'
  44. export const AnalyticBucketDetails = () => {
  45. const router = useRouter()
  46. const { ref: projectRef } = useParams()
  47. const { data: project } = useSelectedProjectQuery()
  48. const { state: extensionState } = useIcebergWrapperExtension()
  49. const {
  50. data: bucket,
  51. error: bucketError,
  52. isSuccess: isSuccessBucket,
  53. isError: isErrorBucket,
  54. } = useSelectedAnalyticsBucket()
  55. const [showDeleteModal, setShowDeleteModal] = useQueryState(
  56. 'delete',
  57. parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
  58. )
  59. // [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after
  60. // updating connected tables until namespaces are updated
  61. // Namespace would just be the schema (Which is currently limited to public)
  62. // Wrapper table would be {schema}_{table}_changelog
  63. const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0)
  64. const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0)
  65. const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation()
  66. const {
  67. publication,
  68. pipeline,
  69. icebergWrapper: wrapperInstance,
  70. isLoadingWrapperInstance,
  71. } = useAnalyticsBucketAssociatedEntities({
  72. projectRef,
  73. bucketId: bucket?.name,
  74. })
  75. const { data, isSuccess: isSuccessPipelineStatus } = useReplicationPipelineStatusQuery(
  76. { projectRef, pipelineId: pipeline?.id },
  77. {
  78. refetchInterval: (query) => {
  79. const data = query.state.data
  80. if (data?.status.name !== 'started') return 4000
  81. else return false
  82. },
  83. }
  84. )
  85. const pipelineStatus = data?.status.name
  86. const isPipelineRunning = pipelineStatus === 'started'
  87. const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '')
  88. const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
  89. const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
  90. const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
  91. const state = isLoadingWrapperInstance
  92. ? 'loading'
  93. : extensionState === 'installed'
  94. ? wrapperInstance
  95. ? 'added'
  96. : 'missing'
  97. : extensionState
  98. const wrapperTables = useMemo(() => {
  99. if (!wrapperInstance) return []
  100. return formatWrapperTables(wrapperInstance, wrapperMeta!)
  101. }, [wrapperInstance, wrapperMeta])
  102. const { data: extensionsData } = useDatabaseExtensionsQuery({
  103. projectRef: project?.ref,
  104. connectionString: project?.connectionString,
  105. })
  106. const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
  107. const {
  108. data: namespacesData = [],
  109. isPending: isLoadingNamespaces,
  110. isSuccess: isSuccessNamespaces,
  111. } = useIcebergNamespacesQuery(
  112. {
  113. projectRef,
  114. warehouse: wrapperValues.warehouse,
  115. },
  116. {
  117. refetchInterval: (query) => {
  118. const data = query.state.data
  119. if (pollIntervalNamespaces === 0) return false
  120. const publicationTableSchemas = publication?.tables.map((x) => x.schema) ?? []
  121. const isSynced = !publicationTableSchemas.some((x) => !data?.includes(x))
  122. if (isSynced) {
  123. setPollIntervalNamespaces(0)
  124. return false
  125. }
  126. return pollIntervalNamespaces
  127. },
  128. }
  129. )
  130. const publicationTableSchemas = (publication?.tables ?? []).map((x) => x.schema)
  131. const isSyncedPublicationTableSchemasAndNamespaces = !publicationTableSchemas.some(
  132. (x) => !namespacesData.includes(x)
  133. )
  134. const isPollingForData = pollIntervalNamespaces > 0 || pollIntervalNamespaceTables > 0
  135. const namespaces = useMemo(() => {
  136. const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[]
  137. const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])])
  138. return namespaces.map((namespace) => {
  139. const tables = wrapperTables.filter((t) => t.table.split('.')[0] === namespace)
  140. const schema = tables[0]?.schema
  141. return {
  142. namespace: namespace,
  143. schema: schema,
  144. tables: tables,
  145. }
  146. })
  147. }, [wrapperTables, namespacesData])
  148. useEffect(() => {
  149. if (isSuccessNamespaces && !isSyncedPublicationTableSchemasAndNamespaces) {
  150. setPollIntervalNamespaces(4000)
  151. }
  152. }, [isSuccessNamespaces, isSyncedPublicationTableSchemasAndNamespaces])
  153. return (
  154. <>
  155. {isErrorBucket ? (
  156. <ScaffoldContainer bottomPadding>
  157. <ScaffoldSection isFullWidth>
  158. <AlertError subject="Failed to fetch analytics buckets" error={bucketError} />
  159. </ScaffoldSection>
  160. </ScaffoldContainer>
  161. ) : (
  162. <ScaffoldContainer bottomPadding>
  163. {state === 'loading' ? (
  164. <ScaffoldSection isFullWidth>
  165. <BucketHeader showActions={false} />
  166. <GenericTableLoader />
  167. </ScaffoldSection>
  168. ) : state === 'not-installed' ? (
  169. <ExtensionNotInstalled
  170. bucketName={bucket?.name}
  171. projectRef={project?.ref!}
  172. wrapperMeta={wrapperMeta}
  173. wrappersExtension={wrappersExtension!}
  174. />
  175. ) : state === 'needs-upgrade' ? (
  176. <ExtensionNeedsUpgrade
  177. bucketName={bucket?.name}
  178. projectRef={project?.ref!}
  179. wrapperMeta={wrapperMeta}
  180. wrappersExtension={wrappersExtension!}
  181. />
  182. ) : state === 'missing' ? (
  183. <WrapperMissing bucketName={bucket?.name} />
  184. ) : state === 'added' && wrapperInstance ? (
  185. <>
  186. <ScaffoldSection isFullWidth>
  187. <BucketHeader />
  188. {isLoadingNamespaces || isLoadingWrapperInstance ? (
  189. <GenericTableLoader headers={['Name']} />
  190. ) : namespaces.length === 0 ? (
  191. <>
  192. {HIDE_REPLICATION_USER_FLOW ? (
  193. <CreateTableInstructions />
  194. ) : isPollingForData ? (
  195. <EmptyStatePresentational
  196. icon={
  197. <Loader2
  198. size={24}
  199. strokeWidth={1.5}
  200. className="animate-spin text-foreground-muted"
  201. />
  202. }
  203. title="Connecting table(s) to bucket"
  204. description="Tables will be shown here once the connection is complete"
  205. />
  206. ) : null}
  207. </>
  208. ) : (
  209. <>
  210. {!!pipeline && !!isSuccessPipelineStatus && !isPipelineRunning && (
  211. <Admonition
  212. type="note"
  213. layout="horizontal"
  214. className="[&>div]:pl-10 [&>div]:translate-y-[-3px]"
  215. childProps={{ title: { className: 'block capitalize-sentence' } }}
  216. showIcon={isPipelineStopped}
  217. title={
  218. isPipelineStopped
  219. ? `Replication on the bucket has ${pipelineStatus}`
  220. : `${pipelineStatus} replication on the bucket...`
  221. }
  222. description={
  223. isPipelineStopped
  224. ? 'Data changes from Postgres tables is currently not streaming to their corresponding analytics bucket table'
  225. : 'Data changes from Postgres tables will resume streaming once pipeline has started'
  226. }
  227. actions={
  228. <div className="flex items-center gap-x-2">
  229. <Button asChild type="default">
  230. <Link
  231. href={`/project/${projectRef}/database/replication/${pipeline.replicator_id}`}
  232. >
  233. View replication
  234. </Link>
  235. </Button>
  236. {isPipelineStopped && (
  237. <Button
  238. type="default"
  239. loading={isStartingPipeline}
  240. onClick={async () => {
  241. if (projectRef) {
  242. await startPipeline({ projectRef, pipelineId: pipeline.id })
  243. }
  244. }}
  245. >
  246. Restart
  247. </Button>
  248. )}
  249. </div>
  250. }
  251. >
  252. {!isPipelineStopped && (
  253. <Loader2 size={18} className="absolute top-1.5 left-[3px] animate-spin" />
  254. )}
  255. </Admonition>
  256. )}
  257. <div className="flex flex-col gap-y-10">
  258. {namespaces.map(({ namespace, schema, tables }) => (
  259. <NamespaceWithTables
  260. key={namespace}
  261. namespace={namespace}
  262. sourceType="direct"
  263. schema={schema}
  264. tables={tables as any}
  265. wrapperValues={wrapperValues}
  266. pollIntervalNamespaceTables={pollIntervalNamespaceTables}
  267. setPollIntervalNamespaceTables={setPollIntervalNamespaceTables}
  268. />
  269. ))}
  270. </div>
  271. </>
  272. )}
  273. </ScaffoldSection>
  274. <SimpleConfigurationDetails bucketName={bucket?.name} />
  275. </>
  276. ) : null}
  277. <ScaffoldSection isFullWidth className="flex flex-col gap-y-4">
  278. <header>
  279. <ScaffoldSectionTitle>Manage</ScaffoldSectionTitle>
  280. </header>
  281. <Card>
  282. <CardContent className="flex flex-col md:flex-row md:justify-between gap-y-4 gap-x-8 md:items-center">
  283. <div className="flex flex-col">
  284. <h3>Delete bucket</h3>
  285. <p className="text-sm text-foreground-lighter">
  286. This will also delete any data in your bucket. Make sure you have a backup if
  287. you want to keep your data.
  288. </p>
  289. </div>
  290. <Button
  291. type="danger"
  292. disabled={!bucket?.name || !isSuccessBucket}
  293. onClick={() => setShowDeleteModal(true)}
  294. >
  295. Delete bucket
  296. </Button>
  297. </CardContent>
  298. </Card>
  299. </ScaffoldSection>
  300. </ScaffoldContainer>
  301. )}
  302. <DeleteAnalyticsBucketModal
  303. visible={showDeleteModal}
  304. bucketId={bucket?.name}
  305. onClose={() => setShowDeleteModal(false)}
  306. onSuccess={() => router.push(`/project/${projectRef}/storage/analytics`)}
  307. />
  308. </>
  309. )
  310. }
  311. const ExtensionNotInstalled = ({
  312. bucketName,
  313. projectRef,
  314. wrapperMeta,
  315. wrappersExtension,
  316. }: {
  317. bucketName?: string
  318. projectRef: string
  319. wrapperMeta: WrapperMeta
  320. wrappersExtension: DatabaseExtension
  321. }) => {
  322. const databaseNeedsUpgrading =
  323. (wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '')
  324. return (
  325. <>
  326. <ScaffoldSection isFullWidth>
  327. <Admonition type="warning" title="Missing required extension">
  328. <p>
  329. The Wrappers extension is required in order to query analytics tables.{' '}
  330. {databaseNeedsUpgrading &&
  331. 'Please first upgrade your database and then install the extension.'}{' '}
  332. <InlineLink
  333. href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}
  334. target="_blank"
  335. rel="noreferrer"
  336. className="text-foreground-lighter hover:text-foreground transition-colors"
  337. >
  338. Learn more
  339. </InlineLink>
  340. </p>
  341. <Button type="default" asChild className="mt-2" onClick={() => {}}>
  342. <Link
  343. href={
  344. databaseNeedsUpgrading
  345. ? `/project/${projectRef}/settings/infrastructure`
  346. : `/project/${projectRef}/database/extensions?filter=wrappers`
  347. }
  348. >
  349. {databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'}
  350. </Link>
  351. </Button>
  352. </Admonition>
  353. </ScaffoldSection>
  354. <SimpleConfigurationDetails bucketName={bucketName} />
  355. </>
  356. )
  357. }
  358. const ExtensionNeedsUpgrade = ({
  359. bucketName,
  360. projectRef,
  361. wrapperMeta,
  362. wrappersExtension,
  363. }: {
  364. bucketName?: string
  365. projectRef: string
  366. wrapperMeta: WrapperMeta
  367. wrappersExtension: DatabaseExtension
  368. }) => {
  369. // [Joshen] Default version is what's on the DB, so if the installed version is already the default version
  370. // but still doesnt meet the minimum extension version, then DB upgrade is required
  371. const databaseNeedsUpgrading =
  372. wrappersExtension?.installed_version === wrappersExtension?.default_version
  373. return (
  374. <>
  375. <ScaffoldSection isFullWidth>
  376. <Admonition type="warning" title="Outdated extension version">
  377. <p>
  378. The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
  379. {wrapperMeta.minimumExtensionVersion}. You have version{' '}
  380. {wrappersExtension?.installed_version} installed. Please{' '}
  381. {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension
  382. by disabling and enabling the Wrappers extension.
  383. </p>
  384. <p>
  385. Before reinstalling the wrapper extension, you must first remove all existing wrappers.
  386. Afterward, you can recreate the wrappers.
  387. </p>
  388. <Button asChild type="default">
  389. <Link
  390. href={
  391. databaseNeedsUpgrading
  392. ? `/project/${projectRef}/settings/infrastructure`
  393. : `/project/${projectRef}/database/extensions?filter=wrappers`
  394. }
  395. >
  396. {databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'}
  397. </Link>
  398. </Button>
  399. </Admonition>
  400. </ScaffoldSection>
  401. <SimpleConfigurationDetails bucketName={bucketName} />
  402. </>
  403. )
  404. }
  405. const WrapperMissing = ({ bucketName }: { bucketName?: string }) => {
  406. const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
  407. useIcebergWrapperCreateMutation()
  408. const onSetupWrapper = async () => {
  409. if (!bucketName) return console.error('Bucket name is required')
  410. await createIcebergWrapper({ bucketName })
  411. }
  412. return (
  413. <>
  414. <ScaffoldSection isFullWidth>
  415. <Admonition type="warning" title="Missing integration">
  416. <p>The Iceberg Wrapper integration is required in order to query analytics tables.</p>
  417. <Button type="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}>
  418. Install wrapper
  419. </Button>
  420. </Admonition>
  421. </ScaffoldSection>
  422. <SimpleConfigurationDetails bucketName={bucketName} />
  423. </>
  424. )
  425. }