index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { useParams } from 'common'
  2. import { ChevronRight, Info, Loader2, MoreVertical, Plus, RefreshCw, Trash } from 'lucide-react'
  3. import { useEffect, useMemo, useState } from 'react'
  4. import { toast } from 'sonner'
  5. import {
  6. Button,
  7. Card,
  8. CardHeader,
  9. CardTitle,
  10. DropdownMenu,
  11. DropdownMenuContent,
  12. DropdownMenuItem,
  13. DropdownMenuTrigger,
  14. LoadingLine,
  15. Table,
  16. TableBody,
  17. TableCell,
  18. TableHead,
  19. TableHeader,
  20. TableRow,
  21. Tooltip,
  22. TooltipContent,
  23. TooltipTrigger,
  24. } from 'ui'
  25. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  26. import { HIDE_REPLICATION_USER_FLOW } from '../AnalyticsBucketDetails.constants'
  27. import { getNamespaceTableNameFromPostgresTableName } from '../AnalyticsBucketDetails.utils'
  28. import { InitializeForeignSchemaDialog } from '../InitializeForeignSchemaDialog'
  29. import { UpdateForeignSchemaDialog } from '../UpdateForeignSchemaDialog'
  30. import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
  31. import { TableRowComponent } from './TableRowComponent'
  32. import { FormattedWrapperTable } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
  33. import { ImportForeignSchemaDialog } from '@/components/interfaces/Storage/ImportForeignSchemaDialog'
  34. import { useFDWDropForeignTableMutation } from '@/data/fdw/fdw-drop-foreign-table-mutation'
  35. import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
  36. import { useIcebergNamespaceDeleteMutation } from '@/data/storage/iceberg-namespace-delete-mutation'
  37. import { useIcebergNamespaceTableDeleteMutation } from '@/data/storage/iceberg-namespace-table-delete-mutation'
  38. import { useIcebergNamespaceTablesQuery } from '@/data/storage/iceberg-namespace-tables-query'
  39. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  40. import { BASE_PATH } from '@/lib/constants'
  41. type NamespaceWithTablesProps = {
  42. namespace: string
  43. sourceType: 'replication' | 'direct'
  44. schema: string
  45. tables: (FormattedWrapperTable & { id: number })[]
  46. wrapperValues: Record<string, string>
  47. pollIntervalNamespaceTables: number
  48. setPollIntervalNamespaceTables: (value: number) => void
  49. }
  50. export const NamespaceWithTables = ({
  51. namespace,
  52. sourceType = 'direct',
  53. schema,
  54. tables,
  55. wrapperValues,
  56. pollIntervalNamespaceTables,
  57. setPollIntervalNamespaceTables,
  58. }: NamespaceWithTablesProps) => {
  59. const { ref: projectRef, bucketId } = useParams()
  60. const { data: project } = useSelectedProjectQuery()
  61. const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
  62. const [showConfirmDeleteNamespace, setShowConfirmDeleteNamespace] = useState(false)
  63. const [isDeletingNamespace, setIsDeletingNamespace] = useState(false)
  64. const { publication, icebergWrapper } = useAnalyticsBucketAssociatedEntities({
  65. projectRef,
  66. bucketId,
  67. })
  68. const {
  69. data: tablesData = [],
  70. isPending: isLoadingNamespaceTables,
  71. isSuccess: isSuccessNamespaceTables,
  72. } = useIcebergNamespaceTablesQuery(
  73. {
  74. warehouse: wrapperValues.warehouse,
  75. namespace: namespace,
  76. projectRef,
  77. },
  78. {
  79. refetchInterval: (query) => {
  80. const data = query.state.data ?? []
  81. if (pollIntervalNamespaceTables === 0) return false
  82. const publicationTables = publication?.tables ?? []
  83. const isSynced = !publicationTables.some(
  84. (x) => !data.includes(getNamespaceTableNameFromPostgresTableName(x))
  85. )
  86. if (isSynced) {
  87. setPollIntervalNamespaceTables(0)
  88. return false
  89. }
  90. return pollIntervalNamespaceTables
  91. },
  92. }
  93. )
  94. const connectedForeignTablesForNamespace = (icebergWrapper?.tables ?? []).filter((x) =>
  95. x.options[0].startsWith(`table=${namespace}.`)
  96. )
  97. const tablesWithConnectedForeignTables = connectedForeignTablesForNamespace.reduce((a, b) => {
  98. const table = b.options[0].split(`table=${namespace}.`)[1]
  99. a.add(table)
  100. return a
  101. }, new Set<string>())
  102. const unconnectedTables = tablesData.filter((x) => !tablesWithConnectedForeignTables.has(x))
  103. const hasUnconnectedForeignTablesForNamespace = unconnectedTables.length > 0
  104. const publicationTables = publication?.tables ?? []
  105. const publicationTablesNotSyncedToNamespaceTables = publicationTables.filter(
  106. (x) => !tablesData.includes(getNamespaceTableNameFromPostgresTableName(x))
  107. )
  108. const isSyncedPublicationTablesAndNamespaceTables =
  109. publicationTablesNotSyncedToNamespaceTables.length === 0
  110. const { mutateAsync: importForeignSchema, isPending: isImportingForeignSchema } =
  111. useFDWImportForeignSchemaMutation()
  112. const { mutateAsync: deleteNamespace } = useIcebergNamespaceDeleteMutation()
  113. const { mutateAsync: dropForeignTable } = useFDWDropForeignTableMutation()
  114. const { mutateAsync: deleteNamespaceTable } = useIcebergNamespaceTableDeleteMutation()
  115. const rescanNamespace = async () => {
  116. if (!icebergWrapper) return console.error('Iceberg wrapper cannot be found')
  117. await importForeignSchema({
  118. projectRef: project?.ref,
  119. connectionString: project?.connectionString,
  120. serverName: icebergWrapper.server_name,
  121. sourceSchema: namespace,
  122. targetSchema: schema,
  123. })
  124. }
  125. const missingTables = useMemo(() => {
  126. return (tablesData || []).filter(
  127. (t) => !tables.find((table) => table.table.split('.')[1] === t)
  128. )
  129. }, [tablesData, tables])
  130. // Get all tables (connected + missing) for display
  131. const allTables = useMemo(() => {
  132. const connectedTableNames = tables.map((table) => table.table.split('.')[1])
  133. const allTableNames = [...new Set([...connectedTableNames, ...missingTables])]
  134. return allTableNames.map((tableName) => ({
  135. id: tables.find((t) => t.table_name === tableName)?.id ?? 0,
  136. name: tableName,
  137. isConnected: connectedTableNames.includes(tableName),
  138. }))
  139. }, [tables, missingTables])
  140. // Determine if schema is valid (no clashes with Postgres schema)
  141. // TODO: Replace with actual clash check logic
  142. const validSchema = useMemo(() => {
  143. // If schema exists and has tables, it's always valid
  144. if (schema && tables.length > 0) return true
  145. // For uploaded namespaces without tables, check for clashes against incoming schema (namespace name)
  146. // TODO: Replace with actual clash check against Postgres schema
  147. const hasClashes = false // Mock: no clashes for now
  148. // Show incoming schema if no clashes (even without tables)
  149. return !hasClashes
  150. }, [schema, tables.length])
  151. const displaySchema = useMemo(() => {
  152. // If we have a target schema, use it, otherwise show the incoming schema (namespace name)
  153. if (schema) return schema
  154. return `fdw_analytics_${namespace.replaceAll('-', '_')}`
  155. }, [schema, namespace])
  156. const onConfirmDeleteNamespace = async () => {
  157. if (!bucketId) return console.error('Bucket ID is required')
  158. try {
  159. setIsDeletingNamespace(true)
  160. // [Joshen] Delete all namespace tables
  161. await Promise.all(
  162. allTables.map((table) =>
  163. deleteNamespaceTable({
  164. projectRef,
  165. warehouse: bucketId,
  166. namespace,
  167. table: table.name,
  168. })
  169. )
  170. )
  171. // Delete all foreign tables that corresponding to the namespace tables
  172. await Promise.all(
  173. tables.map((table) =>
  174. dropForeignTable({
  175. projectRef,
  176. connectionString: project?.connectionString,
  177. schemaName: table.schema_name,
  178. tableName: table.table_name,
  179. })
  180. )
  181. )
  182. await deleteNamespace({ projectRef, warehouse: bucketId, namespace })
  183. toast.success(`Successfully deleted namespace "${namespace}"`)
  184. setShowConfirmDeleteNamespace(false)
  185. } catch (error: any) {
  186. toast.error(`Failed to delete namespace: ${error.message}`)
  187. } finally {
  188. setIsDeletingNamespace(false)
  189. }
  190. }
  191. useEffect(() => {
  192. if (isSuccessNamespaceTables && !isSyncedPublicationTablesAndNamespaceTables) {
  193. setPollIntervalNamespaceTables(4000)
  194. }
  195. // eslint-disable-next-line react-hooks/exhaustive-deps
  196. }, [isSuccessNamespaceTables, isSyncedPublicationTablesAndNamespaceTables])
  197. return (
  198. <Card>
  199. <CardHeader className="flex flex-row justify-between items-center px-4 py-4 space-y-0">
  200. <CardTitle className="text-sm font-normal font-sans normal-case leading-none flex flex-row items-center gap-x-1">
  201. <div className="flex flex-row items-center gap-x-3 text-foreground">
  202. <img
  203. src={`${BASE_PATH}/img/icons/iceberg-icon.svg`}
  204. alt="Apache Iceberg icon"
  205. className="w-5 h-5"
  206. />
  207. <div className="flex flex-col gap-y-0.5">
  208. <p className="text-xs font-mono uppercase text-foreground-lighter">
  209. Iceberg namespace
  210. </p>
  211. <p className="text-sm">{namespace}</p>
  212. </div>
  213. </div>
  214. {!HIDE_REPLICATION_USER_FLOW && validSchema && (
  215. <>
  216. <ChevronRight size={12} className="text-foreground-muted" />
  217. <Tooltip>
  218. <TooltipTrigger
  219. asChild
  220. className={tables.length === 0 ? `flex flex-row items-center gap-x-1` : undefined}
  221. >
  222. <span
  223. className={
  224. tables.length > 0
  225. ? `text-foreground`
  226. : `text-foreground-muted flex flex-row items-center gap-x-1`
  227. }
  228. >
  229. {displaySchema}
  230. {tables.length === 0 && <Info size={12} />}
  231. </span>
  232. </TooltipTrigger>
  233. <TooltipContent side="bottom">
  234. <p>Postgres schema{tables.length === 0 && ' that will be created'}</p>
  235. </TooltipContent>
  236. </Tooltip>
  237. </>
  238. )}
  239. </CardTitle>
  240. <div className="flex flex-row gap-x-6">
  241. {pollIntervalNamespaceTables > 0 && (
  242. <Tooltip>
  243. <TooltipTrigger>
  244. <div className="flex items-center gap-x-2 text-foreground-lighter">
  245. <Loader2 size={14} className="animate-spin" />
  246. <p className="text-sm">
  247. Connecting {publicationTablesNotSyncedToNamespaceTables.length} table
  248. {publicationTablesNotSyncedToNamespaceTables.length > 1 ? 's' : ''}
  249. </p>
  250. </div>
  251. </TooltipTrigger>
  252. <TooltipContent side="bottom" align="end">
  253. <p className="mb-1">Waiting for namespace table to be created for:</p>
  254. <ul className="list-disc pl-6">
  255. {publicationTablesNotSyncedToNamespaceTables.map((x) => {
  256. const value = `${x.schema}.${x.name}`
  257. return <li key={value}>{value}</li>
  258. })}
  259. </ul>
  260. </TooltipContent>
  261. </Tooltip>
  262. )}
  263. <div className="flex items-center gap-x-2">
  264. {HIDE_REPLICATION_USER_FLOW ? (
  265. <>
  266. {/* Is this just the import foreign schema dialog then? */}
  267. {connectedForeignTablesForNamespace.length === 0 ? (
  268. <InitializeForeignSchemaDialog namespace={namespace} />
  269. ) : hasUnconnectedForeignTablesForNamespace ? (
  270. <UpdateForeignSchemaDialog namespace={namespace} tables={unconnectedTables} />
  271. ) : null}
  272. <DropdownMenu>
  273. <DropdownMenuTrigger asChild>
  274. <Button type="default" className="w-7" icon={<MoreVertical />} />
  275. </DropdownMenuTrigger>
  276. <DropdownMenuContent align="end" className="w-fit min-w-[180px]">
  277. <DropdownMenuItem
  278. className="flex items-center gap-x-2"
  279. onClick={() => setShowConfirmDeleteNamespace(true)}
  280. >
  281. <Trash size={12} className="text-foreground-lighter" />
  282. <p>Delete namespace</p>
  283. </DropdownMenuItem>
  284. </DropdownMenuContent>
  285. </DropdownMenu>
  286. </>
  287. ) : missingTables.length > 0 ? (
  288. <Button
  289. type={schema ? 'default' : 'warning'}
  290. size="tiny"
  291. icon={schema ? <RefreshCw /> : <Plus size={14} />}
  292. onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
  293. loading={isImportingForeignSchema || isLoadingNamespaceTables}
  294. >
  295. {schema ? 'Sync tables' : `Connect to table${missingTables.length > 1 ? 's' : ''}`}
  296. </Button>
  297. ) : null}
  298. </div>
  299. </div>
  300. </CardHeader>
  301. {pollIntervalNamespaceTables > 0 && <LoadingLine loading />}
  302. <Table>
  303. <TableHeader>
  304. <TableRow>
  305. <TableHead className={allTables.length === 0 ? 'text-foreground-muted' : undefined}>
  306. <span className="pl-8">Table name</span>
  307. </TableHead>
  308. {!!publication && (
  309. <TableHead className={allTables.length === 0 ? 'hidden' : undefined}>
  310. Replication Status
  311. </TableHead>
  312. )}
  313. <TableHead />
  314. </TableRow>
  315. </TableHeader>
  316. <TableBody>
  317. {allTables.length === 0 ? (
  318. <TableRow className="[&>td]:hover:bg-inherit">
  319. <TableCell colSpan={3}>
  320. <p className="text-sm text-foreground">No tables yet</p>
  321. <p className="text-sm text-foreground-lighter">
  322. {sourceType === 'direct'
  323. ? ' Publish an analytics table from your Iceberg client'
  324. : 'Connect a table from your database'}
  325. </p>
  326. </TableCell>
  327. </TableRow>
  328. ) : (
  329. allTables.map((table) => (
  330. <TableRowComponent
  331. key={table.name}
  332. table={table}
  333. namespace={namespace}
  334. schema={displaySchema}
  335. />
  336. ))
  337. )}
  338. </TableBody>
  339. </Table>
  340. <ImportForeignSchemaDialog
  341. namespace={namespace}
  342. circumstance="clash"
  343. visible={importForeignSchemaShown}
  344. onClose={() => setImportForeignSchemaShown(false)}
  345. />
  346. <ConfirmationModal
  347. size="medium"
  348. variant="warning"
  349. loading={isDeletingNamespace}
  350. title={`Confirm to delete "${namespace}"`}
  351. description="This action cannot be undone."
  352. visible={showConfirmDeleteNamespace}
  353. onCancel={() => setShowConfirmDeleteNamespace(false)}
  354. onConfirm={() => onConfirmDeleteNamespace()}
  355. >
  356. <p className="text-sm">
  357. This will remove all Iceberg tables under the namespace, as well as any associated foreign
  358. tables. Are you sure?
  359. </p>
  360. </ConfirmationModal>
  361. </Card>
  362. )
  363. }