| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- import { useParams } from 'common'
- import { ChevronRight, Info, Loader2, MoreVertical, Plus, RefreshCw, Trash } from 'lucide-react'
- import { useEffect, useMemo, useState } from 'react'
- import { toast } from 'sonner'
- import {
- Button,
- Card,
- CardHeader,
- CardTitle,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- LoadingLine,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
- import { HIDE_REPLICATION_USER_FLOW } from '../AnalyticsBucketDetails.constants'
- import { getNamespaceTableNameFromPostgresTableName } from '../AnalyticsBucketDetails.utils'
- import { InitializeForeignSchemaDialog } from '../InitializeForeignSchemaDialog'
- import { UpdateForeignSchemaDialog } from '../UpdateForeignSchemaDialog'
- import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
- import { TableRowComponent } from './TableRowComponent'
- import { FormattedWrapperTable } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
- import { ImportForeignSchemaDialog } from '@/components/interfaces/Storage/ImportForeignSchemaDialog'
- import { useFDWDropForeignTableMutation } from '@/data/fdw/fdw-drop-foreign-table-mutation'
- import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
- import { useIcebergNamespaceDeleteMutation } from '@/data/storage/iceberg-namespace-delete-mutation'
- import { useIcebergNamespaceTableDeleteMutation } from '@/data/storage/iceberg-namespace-table-delete-mutation'
- import { useIcebergNamespaceTablesQuery } from '@/data/storage/iceberg-namespace-tables-query'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { BASE_PATH } from '@/lib/constants'
- type NamespaceWithTablesProps = {
- namespace: string
- sourceType: 'replication' | 'direct'
- schema: string
- tables: (FormattedWrapperTable & { id: number })[]
- wrapperValues: Record<string, string>
- pollIntervalNamespaceTables: number
- setPollIntervalNamespaceTables: (value: number) => void
- }
- export const NamespaceWithTables = ({
- namespace,
- sourceType = 'direct',
- schema,
- tables,
- wrapperValues,
- pollIntervalNamespaceTables,
- setPollIntervalNamespaceTables,
- }: NamespaceWithTablesProps) => {
- const { ref: projectRef, bucketId } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
- const [showConfirmDeleteNamespace, setShowConfirmDeleteNamespace] = useState(false)
- const [isDeletingNamespace, setIsDeletingNamespace] = useState(false)
- const { publication, icebergWrapper } = useAnalyticsBucketAssociatedEntities({
- projectRef,
- bucketId,
- })
- const {
- data: tablesData = [],
- isPending: isLoadingNamespaceTables,
- isSuccess: isSuccessNamespaceTables,
- } = useIcebergNamespaceTablesQuery(
- {
- warehouse: wrapperValues.warehouse,
- namespace: namespace,
- projectRef,
- },
- {
- refetchInterval: (query) => {
- const data = query.state.data ?? []
- if (pollIntervalNamespaceTables === 0) return false
- const publicationTables = publication?.tables ?? []
- const isSynced = !publicationTables.some(
- (x) => !data.includes(getNamespaceTableNameFromPostgresTableName(x))
- )
- if (isSynced) {
- setPollIntervalNamespaceTables(0)
- return false
- }
- return pollIntervalNamespaceTables
- },
- }
- )
- const connectedForeignTablesForNamespace = (icebergWrapper?.tables ?? []).filter((x) =>
- x.options[0].startsWith(`table=${namespace}.`)
- )
- const tablesWithConnectedForeignTables = connectedForeignTablesForNamespace.reduce((a, b) => {
- const table = b.options[0].split(`table=${namespace}.`)[1]
- a.add(table)
- return a
- }, new Set<string>())
- const unconnectedTables = tablesData.filter((x) => !tablesWithConnectedForeignTables.has(x))
- const hasUnconnectedForeignTablesForNamespace = unconnectedTables.length > 0
- const publicationTables = publication?.tables ?? []
- const publicationTablesNotSyncedToNamespaceTables = publicationTables.filter(
- (x) => !tablesData.includes(getNamespaceTableNameFromPostgresTableName(x))
- )
- const isSyncedPublicationTablesAndNamespaceTables =
- publicationTablesNotSyncedToNamespaceTables.length === 0
- const { mutateAsync: importForeignSchema, isPending: isImportingForeignSchema } =
- useFDWImportForeignSchemaMutation()
- const { mutateAsync: deleteNamespace } = useIcebergNamespaceDeleteMutation()
- const { mutateAsync: dropForeignTable } = useFDWDropForeignTableMutation()
- const { mutateAsync: deleteNamespaceTable } = useIcebergNamespaceTableDeleteMutation()
- const rescanNamespace = async () => {
- if (!icebergWrapper) return console.error('Iceberg wrapper cannot be found')
- await importForeignSchema({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- serverName: icebergWrapper.server_name,
- sourceSchema: namespace,
- targetSchema: schema,
- })
- }
- const missingTables = useMemo(() => {
- return (tablesData || []).filter(
- (t) => !tables.find((table) => table.table.split('.')[1] === t)
- )
- }, [tablesData, tables])
- // Get all tables (connected + missing) for display
- const allTables = useMemo(() => {
- const connectedTableNames = tables.map((table) => table.table.split('.')[1])
- const allTableNames = [...new Set([...connectedTableNames, ...missingTables])]
- return allTableNames.map((tableName) => ({
- id: tables.find((t) => t.table_name === tableName)?.id ?? 0,
- name: tableName,
- isConnected: connectedTableNames.includes(tableName),
- }))
- }, [tables, missingTables])
- // Determine if schema is valid (no clashes with Postgres schema)
- // TODO: Replace with actual clash check logic
- const validSchema = useMemo(() => {
- // If schema exists and has tables, it's always valid
- if (schema && tables.length > 0) return true
- // For uploaded namespaces without tables, check for clashes against incoming schema (namespace name)
- // TODO: Replace with actual clash check against Postgres schema
- const hasClashes = false // Mock: no clashes for now
- // Show incoming schema if no clashes (even without tables)
- return !hasClashes
- }, [schema, tables.length])
- const displaySchema = useMemo(() => {
- // If we have a target schema, use it, otherwise show the incoming schema (namespace name)
- if (schema) return schema
- return `fdw_analytics_${namespace.replaceAll('-', '_')}`
- }, [schema, namespace])
- const onConfirmDeleteNamespace = async () => {
- if (!bucketId) return console.error('Bucket ID is required')
- try {
- setIsDeletingNamespace(true)
- // [Joshen] Delete all namespace tables
- await Promise.all(
- allTables.map((table) =>
- deleteNamespaceTable({
- projectRef,
- warehouse: bucketId,
- namespace,
- table: table.name,
- })
- )
- )
- // Delete all foreign tables that corresponding to the namespace tables
- await Promise.all(
- tables.map((table) =>
- dropForeignTable({
- projectRef,
- connectionString: project?.connectionString,
- schemaName: table.schema_name,
- tableName: table.table_name,
- })
- )
- )
- await deleteNamespace({ projectRef, warehouse: bucketId, namespace })
- toast.success(`Successfully deleted namespace "${namespace}"`)
- setShowConfirmDeleteNamespace(false)
- } catch (error: any) {
- toast.error(`Failed to delete namespace: ${error.message}`)
- } finally {
- setIsDeletingNamespace(false)
- }
- }
- useEffect(() => {
- if (isSuccessNamespaceTables && !isSyncedPublicationTablesAndNamespaceTables) {
- setPollIntervalNamespaceTables(4000)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSuccessNamespaceTables, isSyncedPublicationTablesAndNamespaceTables])
- return (
- <Card>
- <CardHeader className="flex flex-row justify-between items-center px-4 py-4 space-y-0">
- <CardTitle className="text-sm font-normal font-sans normal-case leading-none flex flex-row items-center gap-x-1">
- <div className="flex flex-row items-center gap-x-3 text-foreground">
- <img
- src={`${BASE_PATH}/img/icons/iceberg-icon.svg`}
- alt="Apache Iceberg icon"
- className="w-5 h-5"
- />
- <div className="flex flex-col gap-y-0.5">
- <p className="text-xs font-mono uppercase text-foreground-lighter">
- Iceberg namespace
- </p>
- <p className="text-sm">{namespace}</p>
- </div>
- </div>
- {!HIDE_REPLICATION_USER_FLOW && validSchema && (
- <>
- <ChevronRight size={12} className="text-foreground-muted" />
- <Tooltip>
- <TooltipTrigger
- asChild
- className={tables.length === 0 ? `flex flex-row items-center gap-x-1` : undefined}
- >
- <span
- className={
- tables.length > 0
- ? `text-foreground`
- : `text-foreground-muted flex flex-row items-center gap-x-1`
- }
- >
- {displaySchema}
- {tables.length === 0 && <Info size={12} />}
- </span>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- <p>Postgres schema{tables.length === 0 && ' that will be created'}</p>
- </TooltipContent>
- </Tooltip>
- </>
- )}
- </CardTitle>
- <div className="flex flex-row gap-x-6">
- {pollIntervalNamespaceTables > 0 && (
- <Tooltip>
- <TooltipTrigger>
- <div className="flex items-center gap-x-2 text-foreground-lighter">
- <Loader2 size={14} className="animate-spin" />
- <p className="text-sm">
- Connecting {publicationTablesNotSyncedToNamespaceTables.length} table
- {publicationTablesNotSyncedToNamespaceTables.length > 1 ? 's' : ''}
- </p>
- </div>
- </TooltipTrigger>
- <TooltipContent side="bottom" align="end">
- <p className="mb-1">Waiting for namespace table to be created for:</p>
- <ul className="list-disc pl-6">
- {publicationTablesNotSyncedToNamespaceTables.map((x) => {
- const value = `${x.schema}.${x.name}`
- return <li key={value}>{value}</li>
- })}
- </ul>
- </TooltipContent>
- </Tooltip>
- )}
- <div className="flex items-center gap-x-2">
- {HIDE_REPLICATION_USER_FLOW ? (
- <>
- {/* Is this just the import foreign schema dialog then? */}
- {connectedForeignTablesForNamespace.length === 0 ? (
- <InitializeForeignSchemaDialog namespace={namespace} />
- ) : hasUnconnectedForeignTablesForNamespace ? (
- <UpdateForeignSchemaDialog namespace={namespace} tables={unconnectedTables} />
- ) : null}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="default" className="w-7" icon={<MoreVertical />} />
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-fit min-w-[180px]">
- <DropdownMenuItem
- className="flex items-center gap-x-2"
- onClick={() => setShowConfirmDeleteNamespace(true)}
- >
- <Trash size={12} className="text-foreground-lighter" />
- <p>Delete namespace</p>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </>
- ) : missingTables.length > 0 ? (
- <Button
- type={schema ? 'default' : 'warning'}
- size="tiny"
- icon={schema ? <RefreshCw /> : <Plus size={14} />}
- onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
- loading={isImportingForeignSchema || isLoadingNamespaceTables}
- >
- {schema ? 'Sync tables' : `Connect to table${missingTables.length > 1 ? 's' : ''}`}
- </Button>
- ) : null}
- </div>
- </div>
- </CardHeader>
- {pollIntervalNamespaceTables > 0 && <LoadingLine loading />}
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className={allTables.length === 0 ? 'text-foreground-muted' : undefined}>
- <span className="pl-8">Table name</span>
- </TableHead>
- {!!publication && (
- <TableHead className={allTables.length === 0 ? 'hidden' : undefined}>
- Replication Status
- </TableHead>
- )}
- <TableHead />
- </TableRow>
- </TableHeader>
- <TableBody>
- {allTables.length === 0 ? (
- <TableRow className="[&>td]:hover:bg-inherit">
- <TableCell colSpan={3}>
- <p className="text-sm text-foreground">No tables yet</p>
- <p className="text-sm text-foreground-lighter">
- {sourceType === 'direct'
- ? ' Publish an analytics table from your Iceberg client'
- : 'Connect a table from your database'}
- </p>
- </TableCell>
- </TableRow>
- ) : (
- allTables.map((table) => (
- <TableRowComponent
- key={table.name}
- table={table}
- namespace={namespace}
- schema={displaySchema}
- />
- ))
- )}
- </TableBody>
- </Table>
- <ImportForeignSchemaDialog
- namespace={namespace}
- circumstance="clash"
- visible={importForeignSchemaShown}
- onClose={() => setImportForeignSchemaShown(false)}
- />
- <ConfirmationModal
- size="medium"
- variant="warning"
- loading={isDeletingNamespace}
- title={`Confirm to delete "${namespace}"`}
- description="This action cannot be undone."
- visible={showConfirmDeleteNamespace}
- onCancel={() => setShowConfirmDeleteNamespace(false)}
- onConfirm={() => onConfirmDeleteNamespace()}
- >
- <p className="text-sm">
- This will remove all Iceberg tables under the namespace, as well as any associated foreign
- tables. Are you sure?
- </p>
- </ConfirmationModal>
- </Card>
- )
- }
|