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