TableRowComponent.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import { useParams } from 'common'
  2. import { SqlEditor, TableEditor } from 'icons'
  3. import { uniq } from 'lodash'
  4. import { Eye, Loader2, MoreVertical, Pause, Play, Table2, Trash } from 'lucide-react'
  5. import Link from 'next/link'
  6. import { useMemo, useState } from 'react'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. cn,
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuItem,
  14. DropdownMenuSeparator,
  15. DropdownMenuTrigger,
  16. TableCell,
  17. TableRow,
  18. Tooltip,
  19. TooltipContent,
  20. TooltipTrigger,
  21. } from 'ui'
  22. import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
  23. import { HIDE_REPLICATION_USER_FLOW } from '../AnalyticsBucketDetails.constants'
  24. import {
  25. getAnalyticsBucketFDWServerName,
  26. getNamespaceTableNameFromPostgresTableName,
  27. } from '../AnalyticsBucketDetails.utils'
  28. import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
  29. import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance'
  30. import { InsertDataDialog } from './InsertDataDialog'
  31. import { inferPostgresTableFromNamespaceTable } from './NamespaceWithTables.utils'
  32. import {
  33. convertKVStringArrayToJson,
  34. formatWrapperTables,
  35. } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
  36. import { getDecryptedParameters } from '@/components/interfaces/Storage/Storage.utils'
  37. import { DotPing } from '@/components/ui/DotPing'
  38. import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
  39. import { useFDWDropForeignTableMutation } from '@/data/fdw/fdw-drop-foreign-table-mutation'
  40. import { useFDWUpdateMutation } from '@/data/fdw/fdw-update-mutation'
  41. import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
  42. import { useUpdatePublicationMutation } from '@/data/replication/publication-update-mutation'
  43. import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
  44. import { useReplicationTablesQuery } from '@/data/replication/tables-query'
  45. import { useIcebergNamespaceTableDeleteMutation } from '@/data/storage/iceberg-namespace-table-delete-mutation'
  46. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  47. interface TableRowComponentProps {
  48. table: { id: number; name: string; isConnected: boolean }
  49. schema: string
  50. namespace: string
  51. }
  52. export const TableRowComponent = ({ table, schema, namespace }: TableRowComponentProps) => {
  53. const { ref: projectRef, bucketId } = useParams()
  54. const { data: project } = useSelectedProjectQuery()
  55. const [showStopReplicationModal, setShowStopReplicationModal] = useState(false)
  56. const [showStartReplicationModal, setShowStartReplicationModal] = useState(false)
  57. const [showRemoveTableModal, setShowRemoveTableModal] = useState(false)
  58. const [isUpdatingReplication, setIsUpdatingReplication] = useState(false)
  59. const [isRemovingTable, setIsRemovingTable] = useState(false)
  60. const { sourceId, publication, pipeline, icebergWrapper } = useAnalyticsBucketAssociatedEntities({
  61. projectRef,
  62. bucketId,
  63. })
  64. const { data, isPending: isLoadingPipelineStatus } = useReplicationPipelineStatusQuery({
  65. projectRef,
  66. pipelineId: pipeline?.id,
  67. })
  68. const pipelineStatus = data?.status.name
  69. const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
  70. const { data: wrapperInstance, meta: wrapperMeta } = useAnalyticsBucketWrapperInstance({
  71. bucketId: bucketId,
  72. })
  73. const { mutateAsync: updateFDW } = useFDWUpdateMutation()
  74. const { mutateAsync: dropForeignTable } = useFDWDropForeignTableMutation()
  75. const { mutateAsync: deleteNamespaceTable, isPending: isDeletingNamespaceTable } =
  76. useIcebergNamespaceTableDeleteMutation({ onError: () => {} })
  77. const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
  78. const { mutateAsync: startPipeline } = useStartPipelineMutation()
  79. const inferredPostgresTable = inferPostgresTableFromNamespaceTable({
  80. publication,
  81. tableName: table.name,
  82. })
  83. const isTableUnderReplicationPublication = !!inferredPostgresTable
  84. const hasReplication = !!pipeline && !!publication
  85. const isPipelineRunning = pipelineStatus === 'started'
  86. const isReplicating = isTableUnderReplicationPublication && isPipelineRunning
  87. // [Joshen] Considers both the replication pipeline status + if the table is in the replication publication
  88. const replicationStatusLabel = useMemo(() => {
  89. if (hasReplication) {
  90. if (isLoadingPipelineStatus) {
  91. return 'Checking'
  92. } else if (!isPipelineRunning) {
  93. return '-'
  94. } else if (isTableUnderReplicationPublication) {
  95. return 'Running'
  96. } else {
  97. return 'Disabled'
  98. }
  99. }
  100. }, [
  101. hasReplication,
  102. isLoadingPipelineStatus,
  103. isPipelineRunning,
  104. isTableUnderReplicationPublication,
  105. ])
  106. const onConfirmStopReplication = async () => {
  107. if (!projectRef) return console.error('Project ref is required')
  108. if (!bucketId) return console.error('Bucket ID is required')
  109. if (!sourceId) return toast.error('Source ID is required')
  110. if (!publication) return toast.error('Unable to find existing publication')
  111. if (!pipeline) return toast.error('Unable to find existing pipeline')
  112. try {
  113. setIsUpdatingReplication(true)
  114. // [Joshen ALPHA] Assumption here is that all the namespace tables have _changelog as suffix
  115. // May need to update if that assumption falls short (e.g for those dealing with iceberg APIs directly)
  116. const updatedTables = publication.tables.filter(
  117. (x) => table.name !== getNamespaceTableNameFromPostgresTableName(x)
  118. )
  119. await updatePublication({
  120. projectRef,
  121. sourceId,
  122. publicationName: publication.name,
  123. tables: updatedTables,
  124. })
  125. await startPipeline({ projectRef, pipelineId: pipeline.id })
  126. setShowStopReplicationModal(false)
  127. toast.success('Successfully disabled replication for table! Pipeline is being restarted.')
  128. } catch (error: any) {
  129. toast.error(`Failed to disable replication for table: ${error.message}`)
  130. } finally {
  131. setIsUpdatingReplication(false)
  132. }
  133. }
  134. const onConfirmStartReplication = async () => {
  135. if (!projectRef) return console.error('Project ref is required')
  136. if (!bucketId) return console.error('Bucket ID is required')
  137. if (!sourceId) return toast.error('Source ID is required')
  138. if (!publication) return toast.error('Unable to find existing publication')
  139. if (!pipeline) return toast.error('Unable to find existing pipeline')
  140. // [Joshen ALPHA] This has potential to be flaky - we should see how we can get the table name and schema better
  141. const pgTable = tables?.find(
  142. (t) => getNamespaceTableNameFromPostgresTableName(t) === table.name
  143. )
  144. if (!pgTable) return toast.error('Unable to find corresponding Postgres table')
  145. try {
  146. setIsUpdatingReplication(true)
  147. const updatedTables = publication.tables.concat([
  148. { schema: pgTable.schema, name: pgTable.name },
  149. ])
  150. await updatePublication({
  151. projectRef,
  152. sourceId,
  153. publicationName: publication.name,
  154. tables: updatedTables,
  155. })
  156. await startPipeline({ projectRef, pipelineId: pipeline.id })
  157. setShowStartReplicationModal(false)
  158. toast.success('Successfully enabled replication for table! Pipeline is being restarted.')
  159. } catch (error: any) {
  160. toast.error(`Failed to enable replication for table: ${error.message}`)
  161. } finally {
  162. setIsUpdatingReplication(false)
  163. }
  164. }
  165. // [Joshen] For ETL replication context
  166. const onConfirmRemoveTable = async () => {
  167. if (!bucketId) return console.error('Bucket ID is required')
  168. if (!wrapperInstance || !wrapperMeta) return toast.error('Unable to find wrapper')
  169. try {
  170. setIsRemovingTable(true)
  171. // [Joshen] Update FDW instance only if table is in FDW instance's tables
  172. // e.g for a namespace table that was added outside of the dashboard, it wouldn't be
  173. const isTableInWrapperInstance = wrapperInstance.tables.some((x) => x.name === table.name)
  174. if (isTableInWrapperInstance) {
  175. const serverName = getAnalyticsBucketFDWServerName(bucketId)
  176. const serverOptions = await getDecryptedParameters({
  177. ref: project?.ref,
  178. connectionString: project?.connectionString ?? undefined,
  179. wrapper: wrapperInstance,
  180. wrapperMeta,
  181. })
  182. const formValues: Record<string, string> = {
  183. wrapper_name: wrapperInstance.name,
  184. server_name: wrapperInstance.server_name,
  185. ...serverOptions,
  186. }
  187. const targetSchemas = (formValues['briven_target_schema'] || '')
  188. .split(',')
  189. .map((s) => s.trim())
  190. const wrapperTables = formatWrapperTables(wrapperInstance, wrapperMeta).filter(
  191. (x) => x.table_name !== table.name
  192. )
  193. // [Joshen] Once Ivan's PR goes through, swap these out to just use useFDWDropForeignTableMutation
  194. // https://github.com/briven/briven/pull/40206
  195. await updateFDW({
  196. projectRef: project?.ref,
  197. connectionString: project?.connectionString,
  198. wrapper: wrapperInstance,
  199. wrapperMeta,
  200. formState: {
  201. ...formValues,
  202. server_name: serverName,
  203. briven_target_schema: uniq([...targetSchemas])
  204. .filter(Boolean)
  205. .join(','),
  206. },
  207. tables: wrapperTables,
  208. })
  209. }
  210. const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
  211. await deleteNamespaceTable({
  212. projectRef,
  213. warehouse: wrapperValues.warehouse,
  214. namespace: namespace,
  215. table: table.name,
  216. })
  217. toast.success('Successfully removed table!')
  218. setShowRemoveTableModal(false)
  219. } catch (error: any) {
  220. toast.error(`Failed to remove table: ${error.message}`)
  221. } finally {
  222. setIsRemovingTable(false)
  223. }
  224. }
  225. const connectedForeignTablesInNamespace = (icebergWrapper?.tables ?? []).filter((x) =>
  226. x.options[0].includes(`table=${namespace}.`)
  227. )
  228. const connectedForeignTables = (icebergWrapper?.tables ?? []).filter(
  229. (x) => x.options[0] === `table=${namespace}.${table.name}`
  230. )
  231. // [Joshen] For purely Analytics Bucket context
  232. const onConfirmRemoveNamespaceTable = async () => {
  233. try {
  234. setIsRemovingTable(true)
  235. const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
  236. await deleteNamespaceTable({
  237. projectRef,
  238. warehouse: wrapperValues.warehouse,
  239. namespace: namespace,
  240. table: table.name,
  241. })
  242. await Promise.all(
  243. connectedForeignTables.map((x) =>
  244. dropForeignTable({
  245. projectRef,
  246. connectionString: project?.connectionString,
  247. schemaName: x.schema,
  248. tableName: x.name,
  249. })
  250. )
  251. )
  252. toast.success(`Successfully removed table "${table.name}"!`)
  253. } catch (error: any) {
  254. toast.error(`Failed to remove table: ${error.message}`)
  255. } finally {
  256. setIsRemovingTable(false)
  257. }
  258. }
  259. return (
  260. <>
  261. <TableRow>
  262. <TableCell className="min-w-[120px]">
  263. <div className="flex items-center gap-x-3">
  264. <div className="w-5 flex justify-center items-center">
  265. <Table2 size={16} />
  266. </div>
  267. <p>{table.name}</p>
  268. </div>
  269. </TableCell>
  270. {!HIDE_REPLICATION_USER_FLOW && !!hasReplication && (
  271. <TableCell colSpan={table.isConnected ? 1 : 2} className="min-w-[150px]">
  272. <div className="flex items-center">
  273. <Tooltip>
  274. <TooltipTrigger asChild>
  275. <div className="flex items-center gap-x-2">
  276. {isLoadingPipelineStatus ? (
  277. <Loader2 size={12} className="animate-spin text-foreground-lighter" />
  278. ) : isPipelineRunning ? (
  279. <DotPing
  280. animate={isReplicating}
  281. variant={isReplicating ? 'primary' : 'default'}
  282. />
  283. ) : null}
  284. <span className="text-foreground-lighter capitalize">
  285. {replicationStatusLabel}
  286. </span>
  287. </div>
  288. </TooltipTrigger>
  289. {isPipelineRunning && (
  290. <TooltipContent side="bottom">
  291. {isReplicating
  292. ? `Table data is currently replicating${!!inferredPostgresTable ? ` from ${inferredPostgresTable.schema}.${inferredPostgresTable.name}` : ''}`
  293. : !isTableUnderReplicationPublication
  294. ? 'Replication is disabled for this table'
  295. : undefined}
  296. </TooltipContent>
  297. )}
  298. </Tooltip>
  299. </div>
  300. </TableCell>
  301. )}
  302. {!HIDE_REPLICATION_USER_FLOW && table.isConnected ? (
  303. // [Joshen] These are if there's the context of replication which we're currently not doing
  304. // May need to clean up if we decided to move forward de-coupling replication and Analytics Buckets
  305. <TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
  306. <>
  307. <DropdownMenu>
  308. <DropdownMenuTrigger asChild>
  309. <Button type="default" className="w-7" icon={<MoreVertical />} />
  310. </DropdownMenuTrigger>
  311. <DropdownMenuContent side="bottom" align="end" className="w-fit min-w-[180px]">
  312. {!!publication && (
  313. <>
  314. {!!inferredPostgresTable && (
  315. <DropdownMenuItem asChild className="flex items-center gap-x-2">
  316. <Link
  317. href={`/project/${projectRef}/database/replication/${pipeline?.id}?search=${inferredPostgresTable.schema}.${inferredPostgresTable.name}`}
  318. >
  319. <Eye size={12} className="text-foreground-lighter" />
  320. <p>View replication</p>
  321. </Link>
  322. </DropdownMenuItem>
  323. )}
  324. {isTableUnderReplicationPublication ? (
  325. <DropdownMenuItem
  326. className="flex items-center gap-x-2"
  327. onClick={() => setShowStopReplicationModal(true)}
  328. >
  329. <Pause size={12} className="text-foreground-lighter" />
  330. <p>Disable replication</p>
  331. </DropdownMenuItem>
  332. ) : (
  333. <DropdownMenuItem
  334. className="flex items-center gap-x-2"
  335. onClick={() => setShowStartReplicationModal(true)}
  336. >
  337. <Play size={12} className="text-foreground-lighter" />
  338. <p>Enable replication</p>
  339. </DropdownMenuItem>
  340. )}
  341. <DropdownMenuSeparator />
  342. </>
  343. )}
  344. <DropdownMenuItemTooltip
  345. disabled={isReplicating}
  346. className="flex items-center gap-x-2"
  347. onClick={() => setShowRemoveTableModal(true)}
  348. tooltip={{
  349. content: {
  350. side: 'left',
  351. text: 'Stop replication on this table before removing',
  352. },
  353. }}
  354. >
  355. <Trash size={12} className="text-foreground-lighter" />
  356. <p>Delete table</p>
  357. </DropdownMenuItemTooltip>
  358. </DropdownMenuContent>
  359. </DropdownMenu>
  360. </>
  361. </TableCell>
  362. ) : (
  363. // [Joshen] These are for purely Analytics Bucket context
  364. <TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
  365. <DropdownMenu>
  366. <DropdownMenuTrigger asChild>
  367. <Button
  368. loading={isDeletingNamespaceTable}
  369. type="default"
  370. className="w-7"
  371. icon={<MoreVertical />}
  372. />
  373. </DropdownMenuTrigger>
  374. <DropdownMenuContent align="end" className="w-fit min-w-[180px]">
  375. <DropdownMenuItem
  376. className="flex items-center gap-x-2"
  377. onClick={() => setShowRemoveTableModal(true)}
  378. >
  379. <Trash size={12} className="text-foreground-lighter" />
  380. <p>Delete table</p>
  381. </DropdownMenuItem>
  382. </DropdownMenuContent>
  383. </DropdownMenu>
  384. </TableCell>
  385. )}
  386. </TableRow>
  387. {/* [Joshen] Render each foreign table associated to the namespace table as its own row */}
  388. {connectedForeignTables?.map((x) => (
  389. <TableRow key={x.id}>
  390. <TableCell className="pl-6">
  391. <div className="flex items-center gap-x-2 rounded-sm">
  392. <div className="w-4 h-5 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-2" />
  393. <div
  394. className={cn(
  395. 'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
  396. 'text-warning-600/80 dark:text-yellow-900 bg-yellow-500'
  397. )}
  398. >
  399. F
  400. </div>
  401. <p>
  402. {x.schema}.{x.name}
  403. </p>
  404. </div>
  405. </TableCell>
  406. <TableCell className="flex flex-row justify-end gap-x-2">
  407. <InsertDataDialog table={table.name} fdwTable={x} />
  408. <DropdownMenu>
  409. <DropdownMenuTrigger asChild>
  410. <Button type="default" className="w-7" icon={<MoreVertical />} />
  411. </DropdownMenuTrigger>
  412. <DropdownMenuContent className="w-fit min-w-[180px]" align="end">
  413. <DropdownMenuItem asChild className="flex items-center gap-x-2">
  414. <Link
  415. href={`/project/${projectRef}/sql/new?content=${encodeURIComponent(`select * from ${schema}.${table.name};`)}`}
  416. >
  417. <SqlEditor size={12} className="text-foreground-lighter" />
  418. <p>Query in SQL Editor</p>
  419. </Link>
  420. </DropdownMenuItem>
  421. <DropdownMenuItem asChild className="flex items-center gap-x-2">
  422. <Link href={`/project/${projectRef}/editor/${x.id}?schema=${x.schema}`}>
  423. <TableEditor size={12} className="text-foreground-lighter" />
  424. <p>View in Table Editor</p>
  425. </Link>
  426. </DropdownMenuItem>
  427. </DropdownMenuContent>
  428. </DropdownMenu>
  429. </TableCell>
  430. </TableRow>
  431. ))}
  432. {/* [Joshen] If the iceberg table doesn't have a corresponding namespace table, but the namespace itself already has some tables connected */}
  433. {connectedForeignTablesInNamespace.length > 0 && connectedForeignTables.length === 0 && (
  434. <TableRow>
  435. <TableCell className="pl-6">
  436. <Tooltip>
  437. <TooltipTrigger>
  438. <div className="flex items-center gap-x-2 rounded-sm">
  439. <div className="w-4 h-4 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-1.5" />
  440. <div
  441. className={cn(
  442. 'flex items-center justify-center text-xs h-4 w-4 rounded-[2px]',
  443. 'font-bold border border-dashed border-control text-foreground-lighter'
  444. )}
  445. >
  446. ?
  447. </div>
  448. <p className="text-foreground-lighter">No matching foreign table in schema</p>
  449. </div>
  450. </TooltipTrigger>
  451. <TooltipContent side="right">
  452. Update the schema tables if you'd like to query this table from Postgres
  453. </TooltipContent>
  454. </Tooltip>
  455. </TableCell>
  456. <TableCell className="flex flex-row justify-end"></TableCell>
  457. </TableRow>
  458. )}
  459. <ConfirmationModal
  460. size="medium"
  461. variant="warning"
  462. visible={showStopReplicationModal}
  463. loading={isUpdatingReplication}
  464. title="Confirm to disable replication for table"
  465. confirmLabel="Disable replication"
  466. onCancel={() => setShowStopReplicationModal(false)}
  467. onConfirm={() => onConfirmStopReplication()}
  468. >
  469. <p className="text-sm text-foreground-light">
  470. Data within the "{table.name}" table will stop replicating. However do note that,
  471. re-enabling replication on this table will clear and re-sync all data in it. Are you sure?
  472. </p>
  473. </ConfirmationModal>
  474. <ConfirmationModal
  475. size="medium"
  476. variant="warning"
  477. visible={showStartReplicationModal}
  478. loading={isUpdatingReplication}
  479. title="Enable replication for table"
  480. confirmLabel="Enable replication"
  481. onCancel={() => setShowStartReplicationModal(false)}
  482. onConfirm={() => onConfirmStartReplication()}
  483. >
  484. <p className="text-sm text-foreground-light">
  485. Re-enabling replication on the "{table.name}" table will clear and re-sync all data in it.
  486. Are you sure?
  487. </p>
  488. </ConfirmationModal>
  489. <ConfirmationModal
  490. size="medium"
  491. variant="warning"
  492. visible={showRemoveTableModal}
  493. loading={isRemovingTable}
  494. title={`Confirm to delete table "${table.name}"`}
  495. description="This action cannot be undone."
  496. confirmLabel="Delete table"
  497. onCancel={() => setShowRemoveTableModal(false)}
  498. onConfirm={() => {
  499. if (HIDE_REPLICATION_USER_FLOW) {
  500. onConfirmRemoveNamespaceTable()
  501. } else {
  502. onConfirmRemoveTable()
  503. }
  504. }}
  505. >
  506. <p className="text-sm text-foreground-light">
  507. Data from this Iceberg table will be permanently lost. Are you sure?
  508. </p>
  509. </ConfirmationModal>
  510. </>
  511. )
  512. }