| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- import { useQueryClient } from '@tanstack/react-query'
- import { useParams } from 'common'
- import { MoreVertical, Plus, Search, X } from 'lucide-react'
- import { parseAsStringEnum, useQueryState } from 'nuqs'
- import { useEffect, useMemo, useRef, useState } from 'react'
- import {
- Button,
- Card,
- CardContent,
- cn,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- } from 'ui'
- import { GenericSkeletonLoader } from 'ui-patterns'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
- import { DestinationPanel } from './DestinationPanel/DestinationPanel'
- import { DestinationType } from './DestinationPanel/DestinationPanel.types'
- import { DestinationRow } from './DestinationRow'
- import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog'
- import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
- import {
- useIsETLBigQueryPrivateAlpha,
- useIsETLDucklakePrivateAlpha,
- useIsETLIcebergPrivateAlpha,
- } from './useIsETLPrivateAlpha'
- import { AlertError } from '@/components/ui/AlertError'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { Shortcut } from '@/components/ui/Shortcut'
- import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
- import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
- import { replicationKeys } from '@/data/replication/keys'
- import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
- import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
- import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { DOCS_URL } from '@/lib/constants'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- export const Destinations = () => {
- const queryClient = useQueryClient()
- const { ref: projectRef } = useParams()
- const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
- const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
- const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
- const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
- const newDestinationDefaultType = infrastructureReadReplicas
- ? 'Read Replica'
- : etlEnableBigQuery
- ? 'BigQuery'
- : etlEnableIceberg
- ? 'Analytics Bucket'
- : etlEnableDucklake
- ? 'DuckLake'
- : null
- const prefetchedRef = useRef(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const [filterString, setFilterString] = useState<string>('')
- const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
- const [showDisableExternalReplicationDialog, setShowDisableExternalReplicationDialog] =
- useState(false)
- const [_, setDestinationType] = useQueryState(
- 'destinationType',
- parseAsStringEnum<DestinationType>([
- 'Read Replica',
- 'BigQuery',
- 'Analytics Bucket',
- 'DuckLake',
- ]).withOptions({
- history: 'push',
- clearOnDefault: true,
- })
- )
- const {
- data: databases = [],
- error: databasesError,
- isPending: isDatabasesLoading,
- isError: isDatabasesError,
- isSuccess: isDatabasesSuccess,
- } = useReadReplicasQuery({ projectRef }, { refetchInterval: statusRefetchInterval })
- const readReplicas = databases.filter((x) => x.identifier !== projectRef)
- const hasReplicas = isDatabasesSuccess && readReplicas.length > 0
- const filteredReplicas =
- filterString.length === 0
- ? readReplicas
- : readReplicas.filter((replica) => replica.identifier.includes(filterString.toLowerCase()))
- const {
- data: destinationsData,
- error: destinationsError,
- isPending: isDestinationsLoading,
- isError: isDestinationsError,
- isSuccess: isDestinationsSuccess,
- } = useReplicationDestinationsQuery({
- projectRef,
- })
- const destinations = destinationsData?.destinations ?? []
- const hasDestinations = isDestinationsSuccess && destinationsData?.destinations.length > 0
- const filteredDestinations =
- filterString.length === 0
- ? (destinations ?? [])
- : (destinations ?? []).filter((destination) =>
- destination.name.toLowerCase().includes(filterString.toLowerCase())
- )
- const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
- projectRef,
- })
- const pipelines = pipelinesData?.pipelines ?? []
- const { data: sourcesData, isSuccess: isSourcesSuccess } = useReplicationSourcesQuery({
- projectRef,
- })
- const externalReplicationSource = useMemo(
- () => sourcesData?.sources.find((source) => source.name === projectRef),
- [projectRef, sourcesData?.sources]
- )
- const canDisableExternalReplication =
- isSourcesSuccess &&
- isDestinationsSuccess &&
- isPipelinesSuccess &&
- !!externalReplicationSource &&
- destinations.length === 0 &&
- pipelines.length === 0
- const isLoading = isDestinationsLoading || isDatabasesLoading
- const hasErrorsFetchingData = isDestinationsError || isDatabasesError
- const openDestinationPanel = () => {
- if (!newDestinationDefaultType) return
- setDestinationType(newDestinationDefaultType)
- }
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
- () => {
- searchInputRef.current?.focus()
- searchInputRef.current?.select()
- },
- { label: 'Search destinations' }
- )
- useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setFilterString(''))
- useEffect(() => {
- if (
- projectRef &&
- !prefetchedRef.current &&
- pipelinesData?.pipelines &&
- pipelinesData.pipelines.length > 0 &&
- isPipelinesSuccess
- ) {
- prefetchedRef.current = true
- pipelinesData.pipelines.forEach((p) => {
- if (!p?.id) return
- queryClient.prefetchQuery({
- queryKey: replicationKeys.pipelinesVersion(projectRef, p.id),
- queryFn: ({ signal }) =>
- fetchReplicationPipelineVersion({ projectRef, pipelineId: p.id }, signal),
- staleTime: Infinity,
- })
- })
- }
- }, [projectRef, pipelinesData?.pipelines, isPipelinesSuccess, queryClient])
- useEffect(() => {
- if (!isDatabasesSuccess) return
- const pollReplicas = async () => {
- const fixedStatuses = [
- REPLICA_STATUS.ACTIVE_HEALTHY,
- REPLICA_STATUS.ACTIVE_UNHEALTHY,
- REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
- ]
- const replicasInTransition = readReplicas.filter((db) => !fixedStatuses.includes(db.status))
- const hasTransientStatus = replicasInTransition.length > 0
- // If all replicas are active healthy, stop fetching statuses
- if (!hasTransientStatus) setStatusRefetchInterval(false)
- }
- pollReplicas()
- }, [isDatabasesSuccess, readReplicas])
- return (
- <>
- <div className="mb-4">
- <div className="flex items-center justify-between">
- <div className="flex items-center">
- <Input
- ref={searchInputRef}
- placeholder="Filter destinations"
- size="tiny"
- icon={<Search />}
- value={filterString}
- className="w-full lg:w-52"
- onChange={(e) => setFilterString(e.target.value)}
- actions={
- filterString.length > 0 && (
- <Button
- type="text"
- icon={<X />}
- className="p-0 h-5 w-5"
- onClick={() => setFilterString('')}
- />
- )
- }
- />
- </div>
- <div className="flex items-center gap-x-2">
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
- label="Add destination"
- onTrigger={openDestinationPanel}
- options={{ enabled: !!newDestinationDefaultType }}
- side="bottom"
- >
- <Button
- type="default"
- icon={<Plus />}
- disabled={!newDestinationDefaultType}
- onClick={openDestinationPanel}
- >
- Add destination
- </Button>
- </Shortcut>
- <DocsButton href={`${DOCS_URL}/guides/database/replication`} />
- {canDisableExternalReplication && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="default" icon={<MoreVertical />} className="w-7" />
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-52">
- <DropdownMenuItem onClick={() => setShowDisableExternalReplicationDialog(true)}>
- Disable external replication
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
- </div>
- </div>
- <div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
- {hasErrorsFetchingData && (
- <AlertError
- error={destinationsError || databasesError}
- subject="Failed to retrieve destinations"
- />
- )}
- {isLoading ? (
- <GenericSkeletonLoader />
- ) : hasReplicas || hasDestinations ? (
- <Card>
- <CardContent className="p-0">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead key="type" className="w-[20px]" />
- <TableHead key="name" className="w-[250px]">
- Name
- </TableHead>
- <TableHead key="status" className="w-[150px]">
- Status
- </TableHead>
- <TableHead key="lag" className="w-[80px]">
- Lag
- </TableHead>
- <TableHead key="publication">Publication</TableHead>
- <TableHead key="actions" />
- </TableRow>
- </TableHeader>
- <TableBody>
- {filteredReplicas.map((replica) => {
- return (
- <ReadReplicaRow
- key={replica.identifier}
- replica={replica}
- onUpdateReplica={() => setStatusRefetchInterval(5000)}
- />
- )
- })}
- {filteredDestinations.map((destination) => (
- <DestinationRow key={destination.id} destinationId={destination.id} />
- ))}
- {!isLoading &&
- filteredDestinations.length === 0 &&
- filteredReplicas.length === 0 &&
- (hasReplicas || hasDestinations) && (
- <TableRow>
- <TableCell colSpan={5}>
- <p>No results found</p>
- <p className="text-foreground-light">
- Your search for "{filterString}" did not return any results
- </p>
- </TableCell>
- </TableRow>
- )}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- ) : (
- !isLoading &&
- !hasErrorsFetchingData && (
- <div
- className={cn(
- 'w-full',
- 'border border-dashed bg-surface-100 border-overlay',
- 'flex flex-col px-16 rounded-lg justify-center items-center py-8 mt-4'
- )}
- >
- <h4>Replication keeps your data in sync across systems</h4>
- <p className="text-foreground-light text-sm text-balance text-center mt-1">
- Deploy read replicas for lower latency and better resource management, or capture
- database changes to external platforms for real-time data pipelines.
- </p>
- <Button
- icon={<Plus />}
- disabled={!newDestinationDefaultType}
- onClick={openDestinationPanel}
- className="mt-4"
- >
- Add destination
- </Button>
- </div>
- )
- )}
- </div>
- <DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
- <DisableExternalReplicationDialog
- open={showDisableExternalReplicationDialog}
- setOpen={setShowDisableExternalReplicationDialog}
- />
- </>
- )
- }
|