Destinations.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import { useQueryClient } from '@tanstack/react-query'
  2. import { useParams } from 'common'
  3. import { MoreVertical, Plus, Search, X } from 'lucide-react'
  4. import { parseAsStringEnum, useQueryState } from 'nuqs'
  5. import { useEffect, useMemo, useRef, useState } from 'react'
  6. import {
  7. Button,
  8. Card,
  9. CardContent,
  10. cn,
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuItem,
  14. DropdownMenuTrigger,
  15. Table,
  16. TableBody,
  17. TableCell,
  18. TableHead,
  19. TableHeader,
  20. TableRow,
  21. } from 'ui'
  22. import { GenericSkeletonLoader } from 'ui-patterns'
  23. import { Input } from 'ui-patterns/DataInputs/Input'
  24. import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
  25. import { DestinationPanel } from './DestinationPanel/DestinationPanel'
  26. import { DestinationType } from './DestinationPanel/DestinationPanel.types'
  27. import { DestinationRow } from './DestinationRow'
  28. import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog'
  29. import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
  30. import {
  31. useIsETLBigQueryPrivateAlpha,
  32. useIsETLDucklakePrivateAlpha,
  33. useIsETLIcebergPrivateAlpha,
  34. } from './useIsETLPrivateAlpha'
  35. import { AlertError } from '@/components/ui/AlertError'
  36. import { DocsButton } from '@/components/ui/DocsButton'
  37. import { Shortcut } from '@/components/ui/Shortcut'
  38. import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  39. import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
  40. import { replicationKeys } from '@/data/replication/keys'
  41. import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
  42. import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
  43. import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
  44. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  45. import { DOCS_URL } from '@/lib/constants'
  46. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  47. import { useShortcut } from '@/state/shortcuts/useShortcut'
  48. export const Destinations = () => {
  49. const queryClient = useQueryClient()
  50. const { ref: projectRef } = useParams()
  51. const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
  52. const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
  53. const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
  54. const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
  55. const newDestinationDefaultType = infrastructureReadReplicas
  56. ? 'Read Replica'
  57. : etlEnableBigQuery
  58. ? 'BigQuery'
  59. : etlEnableIceberg
  60. ? 'Analytics Bucket'
  61. : etlEnableDucklake
  62. ? 'DuckLake'
  63. : null
  64. const prefetchedRef = useRef(false)
  65. const searchInputRef = useRef<HTMLInputElement>(null)
  66. const [filterString, setFilterString] = useState<string>('')
  67. const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
  68. const [showDisableExternalReplicationDialog, setShowDisableExternalReplicationDialog] =
  69. useState(false)
  70. const [_, setDestinationType] = useQueryState(
  71. 'destinationType',
  72. parseAsStringEnum<DestinationType>([
  73. 'Read Replica',
  74. 'BigQuery',
  75. 'Analytics Bucket',
  76. 'DuckLake',
  77. ]).withOptions({
  78. history: 'push',
  79. clearOnDefault: true,
  80. })
  81. )
  82. const {
  83. data: databases = [],
  84. error: databasesError,
  85. isPending: isDatabasesLoading,
  86. isError: isDatabasesError,
  87. isSuccess: isDatabasesSuccess,
  88. } = useReadReplicasQuery({ projectRef }, { refetchInterval: statusRefetchInterval })
  89. const readReplicas = databases.filter((x) => x.identifier !== projectRef)
  90. const hasReplicas = isDatabasesSuccess && readReplicas.length > 0
  91. const filteredReplicas =
  92. filterString.length === 0
  93. ? readReplicas
  94. : readReplicas.filter((replica) => replica.identifier.includes(filterString.toLowerCase()))
  95. const {
  96. data: destinationsData,
  97. error: destinationsError,
  98. isPending: isDestinationsLoading,
  99. isError: isDestinationsError,
  100. isSuccess: isDestinationsSuccess,
  101. } = useReplicationDestinationsQuery({
  102. projectRef,
  103. })
  104. const destinations = destinationsData?.destinations ?? []
  105. const hasDestinations = isDestinationsSuccess && destinationsData?.destinations.length > 0
  106. const filteredDestinations =
  107. filterString.length === 0
  108. ? (destinations ?? [])
  109. : (destinations ?? []).filter((destination) =>
  110. destination.name.toLowerCase().includes(filterString.toLowerCase())
  111. )
  112. const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
  113. projectRef,
  114. })
  115. const pipelines = pipelinesData?.pipelines ?? []
  116. const { data: sourcesData, isSuccess: isSourcesSuccess } = useReplicationSourcesQuery({
  117. projectRef,
  118. })
  119. const externalReplicationSource = useMemo(
  120. () => sourcesData?.sources.find((source) => source.name === projectRef),
  121. [projectRef, sourcesData?.sources]
  122. )
  123. const canDisableExternalReplication =
  124. isSourcesSuccess &&
  125. isDestinationsSuccess &&
  126. isPipelinesSuccess &&
  127. !!externalReplicationSource &&
  128. destinations.length === 0 &&
  129. pipelines.length === 0
  130. const isLoading = isDestinationsLoading || isDatabasesLoading
  131. const hasErrorsFetchingData = isDestinationsError || isDatabasesError
  132. const openDestinationPanel = () => {
  133. if (!newDestinationDefaultType) return
  134. setDestinationType(newDestinationDefaultType)
  135. }
  136. useShortcut(
  137. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  138. () => {
  139. searchInputRef.current?.focus()
  140. searchInputRef.current?.select()
  141. },
  142. { label: 'Search destinations' }
  143. )
  144. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setFilterString(''))
  145. useEffect(() => {
  146. if (
  147. projectRef &&
  148. !prefetchedRef.current &&
  149. pipelinesData?.pipelines &&
  150. pipelinesData.pipelines.length > 0 &&
  151. isPipelinesSuccess
  152. ) {
  153. prefetchedRef.current = true
  154. pipelinesData.pipelines.forEach((p) => {
  155. if (!p?.id) return
  156. queryClient.prefetchQuery({
  157. queryKey: replicationKeys.pipelinesVersion(projectRef, p.id),
  158. queryFn: ({ signal }) =>
  159. fetchReplicationPipelineVersion({ projectRef, pipelineId: p.id }, signal),
  160. staleTime: Infinity,
  161. })
  162. })
  163. }
  164. }, [projectRef, pipelinesData?.pipelines, isPipelinesSuccess, queryClient])
  165. useEffect(() => {
  166. if (!isDatabasesSuccess) return
  167. const pollReplicas = async () => {
  168. const fixedStatuses = [
  169. REPLICA_STATUS.ACTIVE_HEALTHY,
  170. REPLICA_STATUS.ACTIVE_UNHEALTHY,
  171. REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
  172. ]
  173. const replicasInTransition = readReplicas.filter((db) => !fixedStatuses.includes(db.status))
  174. const hasTransientStatus = replicasInTransition.length > 0
  175. // If all replicas are active healthy, stop fetching statuses
  176. if (!hasTransientStatus) setStatusRefetchInterval(false)
  177. }
  178. pollReplicas()
  179. }, [isDatabasesSuccess, readReplicas])
  180. return (
  181. <>
  182. <div className="mb-4">
  183. <div className="flex items-center justify-between">
  184. <div className="flex items-center">
  185. <Input
  186. ref={searchInputRef}
  187. placeholder="Filter destinations"
  188. size="tiny"
  189. icon={<Search />}
  190. value={filterString}
  191. className="w-full lg:w-52"
  192. onChange={(e) => setFilterString(e.target.value)}
  193. actions={
  194. filterString.length > 0 && (
  195. <Button
  196. type="text"
  197. icon={<X />}
  198. className="p-0 h-5 w-5"
  199. onClick={() => setFilterString('')}
  200. />
  201. )
  202. }
  203. />
  204. </div>
  205. <div className="flex items-center gap-x-2">
  206. <Shortcut
  207. id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
  208. label="Add destination"
  209. onTrigger={openDestinationPanel}
  210. options={{ enabled: !!newDestinationDefaultType }}
  211. side="bottom"
  212. >
  213. <Button
  214. type="default"
  215. icon={<Plus />}
  216. disabled={!newDestinationDefaultType}
  217. onClick={openDestinationPanel}
  218. >
  219. Add destination
  220. </Button>
  221. </Shortcut>
  222. <DocsButton href={`${DOCS_URL}/guides/database/replication`} />
  223. {canDisableExternalReplication && (
  224. <DropdownMenu>
  225. <DropdownMenuTrigger asChild>
  226. <Button type="default" icon={<MoreVertical />} className="w-7" />
  227. </DropdownMenuTrigger>
  228. <DropdownMenuContent align="end" className="w-52">
  229. <DropdownMenuItem onClick={() => setShowDisableExternalReplicationDialog(true)}>
  230. Disable external replication
  231. </DropdownMenuItem>
  232. </DropdownMenuContent>
  233. </DropdownMenu>
  234. )}
  235. </div>
  236. </div>
  237. </div>
  238. <div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
  239. {hasErrorsFetchingData && (
  240. <AlertError
  241. error={destinationsError || databasesError}
  242. subject="Failed to retrieve destinations"
  243. />
  244. )}
  245. {isLoading ? (
  246. <GenericSkeletonLoader />
  247. ) : hasReplicas || hasDestinations ? (
  248. <Card>
  249. <CardContent className="p-0">
  250. <Table>
  251. <TableHeader>
  252. <TableRow>
  253. <TableHead key="type" className="w-[20px]" />
  254. <TableHead key="name" className="w-[250px]">
  255. Name
  256. </TableHead>
  257. <TableHead key="status" className="w-[150px]">
  258. Status
  259. </TableHead>
  260. <TableHead key="lag" className="w-[80px]">
  261. Lag
  262. </TableHead>
  263. <TableHead key="publication">Publication</TableHead>
  264. <TableHead key="actions" />
  265. </TableRow>
  266. </TableHeader>
  267. <TableBody>
  268. {filteredReplicas.map((replica) => {
  269. return (
  270. <ReadReplicaRow
  271. key={replica.identifier}
  272. replica={replica}
  273. onUpdateReplica={() => setStatusRefetchInterval(5000)}
  274. />
  275. )
  276. })}
  277. {filteredDestinations.map((destination) => (
  278. <DestinationRow key={destination.id} destinationId={destination.id} />
  279. ))}
  280. {!isLoading &&
  281. filteredDestinations.length === 0 &&
  282. filteredReplicas.length === 0 &&
  283. (hasReplicas || hasDestinations) && (
  284. <TableRow>
  285. <TableCell colSpan={5}>
  286. <p>No results found</p>
  287. <p className="text-foreground-light">
  288. Your search for "{filterString}" did not return any results
  289. </p>
  290. </TableCell>
  291. </TableRow>
  292. )}
  293. </TableBody>
  294. </Table>
  295. </CardContent>
  296. </Card>
  297. ) : (
  298. !isLoading &&
  299. !hasErrorsFetchingData && (
  300. <div
  301. className={cn(
  302. 'w-full',
  303. 'border border-dashed bg-surface-100 border-overlay',
  304. 'flex flex-col px-16 rounded-lg justify-center items-center py-8 mt-4'
  305. )}
  306. >
  307. <h4>Replication keeps your data in sync across systems</h4>
  308. <p className="text-foreground-light text-sm text-balance text-center mt-1">
  309. Deploy read replicas for lower latency and better resource management, or capture
  310. database changes to external platforms for real-time data pipelines.
  311. </p>
  312. <Button
  313. icon={<Plus />}
  314. disabled={!newDestinationDefaultType}
  315. onClick={openDestinationPanel}
  316. className="mt-4"
  317. >
  318. Add destination
  319. </Button>
  320. </div>
  321. )
  322. )}
  323. </div>
  324. <DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
  325. <DisableExternalReplicationDialog
  326. open={showDisableExternalReplicationDialog}
  327. setOpen={setShowDisableExternalReplicationDialog}
  328. />
  329. </>
  330. )
  331. }