diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index a23bf75eb81ed..67e66aae6a906 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -182,6 +182,7 @@ export const CreateBranchModal = () => { createBranch({ projectRef, branchName: data.branchName, + is_default: false, ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}), }) } diff --git a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx b/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx index d51bdf2f5d46c..d2830c5468e78 100644 --- a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx @@ -1,13 +1,15 @@ -import Table from 'components/to-be-cleaned/Table' import { motion } from 'framer-motion' -import { BASE_PATH } from 'lib/constants' import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useMemo } from 'react' import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow' import 'reactflow/dist/style.css' -import { Button, Input } from 'ui' + +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import Table from 'components/to-be-cleaned/Table' +import { BASE_PATH } from 'lib/constants' +import { Button, Input_Shadcn_ } from 'ui' import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' const STATIC_NODES = [ @@ -62,6 +64,14 @@ const STATIC_EDGES = [ { id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true }, ] +export const ReplicationComingSoon = () => { + return ( + + + + ) +} + const ReplicationStaticMockup = () => { const nodes = useMemo(() => STATIC_NODES, []) const edges = useMemo(() => STATIC_EDGES, []) @@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => { ) } -const ReplicationComingSoon = () => { - return ( - - - - ) -} - -export default ReplicationComingSoon - const PrimaryNode = ({ data, }: { @@ -251,58 +251,68 @@ const StaticDestinations = () => { return ( <> -
-
-
- } - placeholder="Search..." - /> - -
- Name, - Publication, - Lag, - Status, - , - ]} - className="mt-4" - body={mockRows.map((row, i) => ( - - {row.name} - - - All - {row.tables} tables - - - {row.lag} - - - - {row.status} - - - - - - - ))} - /> +
+
+ + + +
+
+
+ + +
+ +
+
Name, + Publication, + Lag, + Status, + , + ]} + className="mt-4" + body={mockRows.map((row, i) => ( + + {row.name} + + + All + {row.tables} tables + + + {row.lag} + + + + {row.status} + + + + + + + ))} + /> + + + ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index edfab6d923992..04386674eeb7f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -1,26 +1,28 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' +import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' import { ReplicationPipelinesData } from 'data/replication/pipelines-query' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import RowMenu from './RowMenu' -import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus' -import { useParams } from 'common' -import { - ReplicationPipelineStatusData, - useReplicationPipelineStatusQuery, -} from 'data/replication/pipeline-status-query' -import { useState } from 'react' -import { toast } from 'sonner' -import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' -import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' -import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' import DeleteDestination from './DeleteDestination' import DestinationPanel from './DestinationPanel' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus, PipelineStatusName } from './PipelineStatus' +import { RowMenu } from './RowMenu' export type Pipeline = ReplicationPipelinesData['pipelines'][0] -const refreshFrequencyMs: number = 5000 +const refreshFrequencyMs: number = 2000 interface DestinationRowProps { sourceId: number | undefined @@ -32,9 +34,10 @@ interface DestinationRowProps { isLoading: boolean isError: boolean isSuccess: boolean + onSelectPipeline?: (pipelineId: number, destinationName: string) => void } -const DestinationRow = ({ +export const DestinationRow = ({ sourceId, destinationId, destinationName, @@ -44,6 +47,7 @@ const DestinationRow = ({ isLoading: isPipelineLoading, isError: isPipelineError, isSuccess: isPipelineSuccess, + onSelectPipeline, }: DestinationRowProps) => { const { ref: projectRef } = useParams() const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false) @@ -62,76 +66,23 @@ const DestinationRow = ({ }, { refetchInterval: refreshFrequencyMs } ) - const [requestStatus, setRequestStatus] = useState( - PipelineStatusRequestStatus.None - ) - const { mutateAsync: startPipeline } = useStartPipelineMutation() - const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const pipelineStatus = pipelineStatusData?.status - const getStatusName = ( - status: ReplicationPipelineStatusData['status'] | undefined - ): string | undefined => { - if (status && typeof status === 'object' && 'name' in status) { - return status.name - } + const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus() + const requestStatus = pipeline?.id + ? getRequestStatus(pipeline.id) + : PipelineStatusRequestStatus.None - return undefined - } + const { mutateAsync: stopPipeline } = useStopPipelineMutation() + const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({}) + const pipelineStatus = pipelineStatusData?.status const statusName = getStatusName(pipelineStatus) - if ( - (requestStatus === PipelineStatusRequestStatus.EnableRequested && - (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) || - (requestStatus === PipelineStatusRequestStatus.DisableRequested && - (statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED)) - ) { - setRequestStatus(PipelineStatusRequestStatus.None) - } - - const onEnableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await startPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to enable destination') - } - setRequestStatus(PipelineStatusRequestStatus.EnableRequested) - } - const onDisableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await stopPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to disable destination') - } - setRequestStatus(PipelineStatusRequestStatus.DisableRequested) - } - const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({}) const onDeleteClick = async () => { if (!projectRef) { - console.error('Project ref is required') - return + return console.error('Project ref is required') } if (!pipeline) { - toast.error('No pipeline found') - return + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) } try { @@ -140,24 +91,33 @@ const DestinationRow = ({ // so we don't need to call deletePipeline explicitly await deleteDestination({ projectRef, destinationId: destinationId }) } catch (error) { - toast.error('Failed to delete destination') + toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION) } } + useEffect(() => { + if (pipeline?.id) { + updatePipelineStatus(pipeline.id, statusName) + } + }, [pipeline?.id, statusName, updatePipelineStatus]) + return ( <> {isPipelineError && ( - + )} {isPipelineSuccess && ( - - - {isPipelineLoading ? : destinationName} - - {isPipelineLoading ? : type} + { + if (pipeline) onSelectPipeline?.(pipeline.id, destinationName) + }} + > + {isPipelineLoading ? : destinationName} + {isPipelineLoading ? : type} {isPipelineLoading || !pipeline ? ( - + ) : ( + /> )} {isPipelineLoading || !pipeline ? ( - + ) : ( pipeline.config.publication_name )} - setShowDeleteDestinationForm(true)} - onEditClick={() => setShowEditDestinationPanel(true)} - > +
+ setShowDeleteDestinationForm(true)} + onEditClick={() => setShowEditDestinationPanel(true)} + /> +
)} @@ -211,5 +172,3 @@ const DestinationRow = ({ ) } - -export default DestinationRow diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index d1bbfc71b5db9..8d384e7840de9 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,19 +1,26 @@ +import { noop } from 'lodash' +import { Plus, Search } from 'lucide-react' +import { useState } from 'react' + import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { useReplicationDestinationsQuery } from 'data/replication/destinations-query' -import { Plus } from 'lucide-react' -import { Button, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import DestinationRow from './DestinationRow' import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' -import { useState } from 'react' -import NewDestinationPanel from './DestinationPanel' import { useReplicationSourcesQuery } from 'data/replication/sources-query' -import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { Button, cn, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import NewDestinationPanel from './DestinationPanel' +import { DestinationRow } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' -const Destinations = () => { +interface DestinationsProps { + onSelectPipeline?: (pipelineId: number, destinationName: string) => void +} + +export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => { const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false) + const [filterString, setFilterString] = useState('') const { ref: projectRef } = useParams() const { @@ -21,12 +28,11 @@ const Destinations = () => { error: sourcesError, isLoading: isSourcesLoading, isError: isSourcesError, - isSuccess: isSourcesSuccess, } = useReplicationSourcesQuery({ projectRef, }) - let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id const { data: destinationsData, @@ -50,21 +56,44 @@ const Destinations = () => { const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0 + const filteredDestinations = + filterString.length === 0 + ? destinationsData?.destinations ?? [] + : (destinationsData?.destinations ?? []).filter((destination) => + destination.name.toLowerCase().includes(filterString.toLowerCase()) + ) + return ( <> - -
- Destinations +
+
+
+
+ + setFilterString(e.target.value)} + /> +
+
+
+ +
{(isSourcesLoading || isDestinationsLoading) && } {(isSourcesError || isDestinationsError) && ( )} @@ -77,7 +106,7 @@ const Destinations = () => { Publication, , ]} - body={destinationsData.destinations.map((destination) => { + body={filteredDestinations.map((destination) => { const pipeline = pipelinesData?.pipelines.find( (p) => p.destination_id === destination.id ) @@ -93,7 +122,8 @@ const Destinations = () => { isLoading={isPipelinesLoading} isError={isPipelinesError} isSuccess={isPipelinesSuccess} - > + onSelectPipeline={onSelectPipeline} + /> ) })} >
@@ -124,15 +154,22 @@ const Destinations = () => {
) )} - + + + {!isSourcesLoading && + !isDestinationsLoading && + filteredDestinations.length === 0 && + anyDestinations && ( +
+

No destinations match "{filterString}"

+
+ )} setShowNewDestinationPanel(false)} - > + /> ) } - -export default Destinations diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts new file mode 100644 index 0000000000000..d15fe8040cc2a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -0,0 +1,96 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' + +export const PIPELINE_ERROR_MESSAGES = { + RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information', + RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status', + RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status', + RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations', + ENABLE_DESTINATION: 'Failed to enable destination', + DISABLE_DESTINATION: 'Failed to disable destination', + DELETE_DESTINATION: 'Failed to delete destination', + NO_PIPELINE_FOUND: 'No pipeline found', + COPY_TABLE_STATUS: 'Failed to copy table status', +} as const + +export const getStatusName = ( + status: ReplicationPipelineStatusData['status'] | undefined +): ReplicationPipelineStatusData['status']['name'] | undefined => { + if (status && typeof status === 'object' && 'name' in status) { + return status.name + } + return undefined +} + +export const PIPELINE_STATE_MESSAGES = { + enabling: { + title: 'Pipeline Enabling', + message: 'Starting the pipeline. Table replication will resume once enabled.', + badge: 'Enabling', + }, + disabling: { + title: 'Pipeline Disabling', + message: 'Stopping the pipeline. Table replication will be paused once disabled.', + badge: 'Disabling', + }, + failed: { + title: 'Pipeline Failed', + message: 'Replication has encountered an error. Check the logs for details.', + badge: 'Failed', + }, + stopped: { + title: 'Pipeline Stopped', + message: 'Replication is paused. Enable the pipeline to resume data synchronization.', + badge: 'Stopped', + }, + starting: { + title: 'Pipeline Starting', + message: 'Initializing replication. Table status will be available once running.', + badge: 'Starting', + }, + running: { + title: 'Pipeline Running', + message: 'Replication is active and processing data', + badge: 'Running', + }, + unknown: { + title: 'Pipeline Status Unknown', + message: 'Unable to determine replication status. Check the logs for more information.', + badge: 'Unknown', + }, + notRunning: { + title: 'Pipeline Not Running', + message: 'Replication is not active. Enable the pipeline to start data synchronization.', + badge: 'Disabled', + }, +} as const + +export const getPipelineStateMessages = ( + requestStatus: PipelineStatusRequestStatus | undefined, + statusName: string | undefined +) => { + // Always prioritize request status (enabling/disabling) over pipeline status + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { + return PIPELINE_STATE_MESSAGES.enabling + } + + if (requestStatus === PipelineStatusRequestStatus.DisableRequested) { + return PIPELINE_STATE_MESSAGES.disabling + } + + // Only check pipeline status if no request is in progress + switch (statusName) { + case 'failed': + return PIPELINE_STATE_MESSAGES.failed + case 'stopped': + return PIPELINE_STATE_MESSAGES.stopped + case 'starting': + return PIPELINE_STATE_MESSAGES.starting + case 'started': + return PIPELINE_STATE_MESSAGES.running + case 'unknown': + return PIPELINE_STATE_MESSAGES.unknown + default: + return PIPELINE_STATE_MESSAGES.notRunning + } +} diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 6fb360e6d972e..f81a8df536ad1 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -1,28 +1,11 @@ import AlertError from 'components/ui/AlertError' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { cn } from 'ui' -import { ResponseError } from 'types' -import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - Button, - Collapsible_Shadcn_ as Collapsible, - CollapsibleContent_Shadcn_ as CollapsibleContent, - CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, -} from 'ui' -import { useState, useEffect, useRef } from 'react' -import { copyToClipboard } from 'ui' -import { toast } from 'sonner' import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' - -export enum PipelineStatusRequestStatus { - None = 'None', - EnableRequested = 'EnableRequested', - DisableRequested = 'DisableRequested', -} +import { AlertTriangle, Loader2 } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { ResponseError } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' export enum PipelineStatusName { FAILED = 'failed', @@ -41,10 +24,10 @@ interface PipelineStatusProps { isLoading: boolean isError: boolean isSuccess: boolean - requestStatus: PipelineStatusRequestStatus + requestStatus?: PipelineStatusRequestStatus } -const PipelineStatus = ({ +export const PipelineStatus = ({ pipelineStatus, error, isLoading, @@ -52,34 +35,6 @@ const PipelineStatus = ({ isSuccess, requestStatus, }: PipelineStatusProps) => { - const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false) - const errorDetailsRef = useRef(null) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) { - setIsErrorDetailsOpen(false) - } - } - - if (isErrorDetailsOpen) { - document.addEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [isErrorDetailsOpen]) - - const handleCopyToClipboard = async (text: string) => { - try { - await copyToClipboard(text) - toast.success('Error details copied to clipboard') - } catch (error) { - toast.error('Failed to copy error details') - } - } - const isFailedStatus = ( status: ReplicationPipelineStatusData['status'] | undefined ): status is FailedStatus => { @@ -91,144 +46,37 @@ const PipelineStatus = ({ ) } - const isLogLoadingState = (failedStatus: FailedStatus): boolean => { - // Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems - // to be a transient error. In case we find a way to properly handle this in the backend, this hack will not - // be needed anymore. + const renderFailedStatus = () => { return ( - failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') === - true - ) - } - - const renderFailedStatus = (failedStatus: FailedStatus) => { - const hasDetails = - failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined - const isLoadingLogs = isLogLoadingState(failedStatus) - - return ( -
- - - -
- {isLoadingLogs ? ( - - ) : ( - - )} - Failed -
-
- -

- {isLoadingLogs - ? 'Pipeline failed - logs are being retrieved from container' - : 'Pipeline has failed - expand for error details'} -

-
-
-
- {hasDetails && ( - - - - - -
- {isLoadingLogs ? ( -
- -
-
- Pipeline Failed - Loading logs... -
-
- Error logs are being retrieved from the container. This may take a few - moments. -
-
-
- ) : ( - <> -
- - Pipeline Error Details - - {(failedStatus.message || failedStatus.reason) && ( - - )} -
-
- {failedStatus.exit_code !== undefined && ( -
- - Exit Code: - -
- {failedStatus.exit_code} -
-
- )} - {failedStatus.reason && ( -
- Reason: -
-                            {failedStatus.reason}
-                          
-
- )} - {failedStatus.message && ( -
- - Message: - -
-                            {failedStatus.message}
-                          
-
- )} -
- - )} -
-
-
- )} -
+ + +
+ + Failed +
+
+ + {getPipelineStateMessages(requestStatus, 'failed').message} + +
) } // Map backend statuses to UX-friendly display const getStatusConfig = () => { + const statusName = + pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus + ? pipelineStatus.name + : undefined + + // Get consistent tooltip message using the same logic as other components + const stateMessages = getPipelineStateMessages(requestStatus, statusName) + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { return { label: 'Enabling...', dot: , color: 'text-brand-600', - tooltip: 'Pipeline is being enabled and will start shortly', + tooltip: stateMessages.message, } } @@ -237,7 +85,7 @@ const PipelineStatus = ({ label: 'Disabling...', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is being disabled and will stop shortly', + tooltip: stateMessages.message, } } @@ -255,35 +103,35 @@ const PipelineStatus = ({ label: 'Starting', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is initializing and will be ready soon', + tooltip: stateMessages.message, } case PipelineStatusName.STARTED: return { label: 'Running', dot:
, color: 'text-brand-600', - tooltip: 'Pipeline is active and processing data', + tooltip: stateMessages.message, } case PipelineStatusName.STOPPED: return { label: 'Stopped', dot:
, color: 'text-foreground-light', - tooltip: 'Pipeline is not running - enable to start processing', + tooltip: stateMessages.message, } case PipelineStatusName.UNKNOWN: return { label: 'Unknown', dot:
, color: 'text-warning-600', - tooltip: 'Pipeline status could not be determined', + tooltip: stateMessages.message, } default: return { label: 'Unknown', dot:
, color: 'text-destructive-600', - tooltip: 'Pipeline status is unclear - check logs for details', + tooltip: stateMessages.message, } } } @@ -301,31 +149,27 @@ const PipelineStatus = ({ return ( <> - {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} {isSuccess && ( <> {statusConfig.isFailedStatus ? ( -
{renderFailedStatus(pipelineStatus as FailedStatus)}
+
{renderFailedStatus()}
) : ( - - - -
- {statusConfig.dot} - {statusConfig.label} -
-
- -

{statusConfig.tooltip}

-
-
-
+ + +
+ {statusConfig.dot} + {statusConfig.label} +
+
+ {statusConfig.tooltip} +
)} )} ) } - -export default PipelineStatus diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx new file mode 100644 index 0000000000000..24d41862f06be --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -0,0 +1,396 @@ +import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' +import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' +import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus } from './PipelineStatus' +import { TableState } from './ReplicationPipelineStatus.types' +import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils' + +interface ReplicationPipelineStatusProps { + pipelineId: number + destinationName?: string + onSelectBack: () => void +} + +export const ReplicationPipelineStatus = ({ + pipelineId, + destinationName, + onSelectBack, +}: ReplicationPipelineStatusProps) => { + const { ref: projectRef } = useParams() + const [filterString, setFilterString] = useState('') + + const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus() + const requestStatus = getRequestStatus(pipelineId) + + const { + data: pipeline, + error: pipelineError, + isLoading: isPipelineLoading, + isError: isPipelineError, + } = useReplicationPipelineByIdQuery({ + projectRef, + pipelineId, + }) + + const { + data: pipelineStatusData, + error: pipelineStatusError, + isLoading: isPipelineStatusLoading, + isError: isPipelineStatusError, + isSuccess: isPipelineStatusSuccess, + } = useReplicationPipelineStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { + data: replicationStatusData, + error: statusError, + isLoading: isStatusLoading, + isError: isStatusError, + } = useReplicationPipelineReplicationStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation() + + const statusName = getStatusName(pipelineStatusData?.status) + const config = getDisabledStateConfig({ requestStatus, statusName }) + + const tableStatuses = replicationStatusData?.table_statuses || [] + const filteredTableStatuses = + filterString.length === 0 + ? tableStatuses + : tableStatuses.filter((table: TableState) => + table.table_name.toLowerCase().includes(filterString.toLowerCase()) + ) + + const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error') + const hasErrors = errorTables.length > 0 + const isPipelineRunning = statusName === 'started' + const hasTableData = tableStatuses.length > 0 + const isEnablingDisabling = + requestStatus === PipelineStatusRequestStatus.EnableRequested || + requestStatus === PipelineStatusRequestStatus.DisableRequested + const showDisabledState = !isPipelineRunning || isEnablingDisabling + + const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => { + const statusText = `Table: ${tableName}\nStatus: ${state.name}${ + 'message' in state ? `\nError: ${state.message}` : '' + }${'lag' in state ? `\nLag: ${state.lag}ms` : ''}` + + try { + await copyToClipboard(statusText) + toast.success('Table status copied to clipboard') + } catch { + toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS) + } + } + + const onTogglePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + if (statusName === 'stopped') { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } else if (statusName === 'started') { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + useEffect(() => { + updatePipelineStatus(pipelineId, statusName) + }, [pipelineId, statusName, updatePipelineStatus]) + + if (isPipelineError) { + return ( +
+
+
+
+
+ +
+ ) + } + + return ( +
+ {/* Header with back button and filters */} +
+
+
+
+
+ + setFilterString(e.target.value)} + /> +
+ +
+
+ + {(isPipelineLoading || isStatusLoading) && } + + {isStatusError && ( + + )} + + {hasErrors && ( +
+
+ +
+

+ {errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed +

+

+ Some tables encountered replication errors. Check the logs for detailed error + information. +

+ +
+
+
+ )} + + {hasTableData && ( +
+ {showDisabledState && ( +
+
+
+
{config.icon}
+
+
+

{config.title}

+

{config.message}

+
+
+
+ )} + +
+ Table, + Status, + Details, + + Actions + , + ]} + body={filteredTableStatuses.map((table: TableState, index: number) => { + const statusConfig = getStatusConfig(table.state) + return ( + + +
+

{table.table_name}

+ + } + tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }} + > + + +
+
+ + {showDisabledState ? ( + Not Available + ) : ( + statusConfig.badge + )} + + + {showDisabledState ? ( +

+ Status unavailable while pipeline is {config.badge.toLowerCase()} +

+ ) : ( +
+
{statusConfig.description}
+ {'lag' in table.state && ( +
+ Lag: {table.state.lag}ms +
+ )} +
+ )} +
+ + } + className="px-1.5" + disabled={showDisabledState} + onClick={() => handleCopyTableStatus(table.table_name, table.state)} + tooltip={{ + content: { + side: 'bottom', + text: showDisabledState + ? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}` + : 'Copy status details', + }, + }} + /> + +
+ ) + })} + /> + + + )} + + {filteredTableStatuses.length === 0 && hasTableData && ( + + +
+

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+
+ )} + + {!isStatusLoading && tableStatuses.length === 0 && ( +
+
+
+ +
+
+

+ {showDisabledState ? 'Pipeline Not Running' : 'No table status information'} +

+

+ {showDisabledState + ? `The replication pipeline is currently ${statusName || 'not active'}. Table status + information is not available while the pipeline is in this state.` + : `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`} +

+
+

+ Data refreshes automatically every 2 seconds +

+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts new file mode 100644 index 0000000000000..c5ab523ec1ead --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts @@ -0,0 +1,10 @@ +export type TableState = { + table_id: number + table_name: string + state: + | { name: 'queued' } + | { name: 'copying_table' } + | { name: 'copied_table' } + | { name: 'following_wal'; lag: number } + | { name: 'error'; message: string } +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx new file mode 100644 index 0000000000000..f9ff22a9e9a0a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx @@ -0,0 +1,108 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { Badge } from 'ui' +import { getPipelineStateMessages } from './Pipeline.utils' +import { TableState } from './ReplicationPipelineStatus.types' + +export const getStatusConfig = (state: TableState['state']) => { + switch (state.name) { + case 'queued': + return { + badge: Queued, + description: 'Waiting to start replication', + color: 'text-warning-600', + } + case 'copying_table': + return { + badge: Copying, + description: 'Initial data copy in progress', + color: 'text-brand-600', + } + case 'copied_table': + return { + badge: Copied, + description: 'Initial copy completed', + color: 'text-success-600', + } + case 'following_wal': + return { + badge: Live, + description: `Replicating live changes`, + color: 'text-success-600', + } + case 'error': + return { + badge: Error, + description: state.message, + color: 'text-destructive-600', + } + default: + return { + badge: Unknown, + description: 'Unknown status', + color: 'text-warning-600', + } + } +} + +export const getDisabledStateConfig = ({ + requestStatus, + statusName, +}: { + requestStatus: PipelineStatusRequestStatus + statusName?: ReplicationPipelineStatusData['status']['name'] +}) => { + const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName) + + // Get icon and colors based on current state + const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested + const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested + const isTransitioning = isEnabling || isDisabling + + const icon = isTransitioning ? ( + + ) : statusName === 'failed' ? ( + + ) : statusName === 'starting' ? ( + + ) : statusName === 'unknown' ? ( + + ) : ( + + ) + + const colors = isEnabling + ? { + bg: 'bg-brand-50', + text: 'text-brand-900', + subtext: 'text-brand-700', + iconBg: 'bg-brand-600', + icon: 'text-white dark:text-black', + } + : isDisabling || statusName === 'starting' || statusName === 'unknown' + ? { + bg: 'bg-warning-50', + text: 'text-warning-900', + subtext: 'text-warning-700', + iconBg: 'bg-warning-600', + icon: 'text-white dark:text-black', + } + : statusName === 'failed' + ? { + bg: 'bg-destructive-50', + text: 'text-destructive-900', + subtext: 'text-destructive-700', + iconBg: 'bg-destructive-600', + icon: 'text-white dark:text-black', + } + : { + bg: 'bg-surface-100', + text: 'text-foreground', + subtext: 'text-foreground-light', + iconBg: 'bg-foreground-lighter', + icon: 'text-white dark:text-black', + } + + return { title, message, badge, icon, colors } +} diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index f09cc703093c2..ccd49c4b520ac 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -1,5 +1,14 @@ -import AlertError from 'components/ui/AlertError' import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import { Button, @@ -10,29 +19,31 @@ import { DropdownMenuTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { Pipeline } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { PipelineStatusName } from './PipelineStatus' interface RowMenuProps { + pipeline: Pipeline | undefined pipelineStatus: any error: ResponseError | null isLoading: boolean isError: boolean - onEnableClick: () => void - onDisableClick: () => void onEditClick: () => void onDeleteClick: () => void } -const RowMenu = ({ +export const RowMenu = ({ + pipeline, pipelineStatus, error, isLoading, isError, - onEnableClick, - onDisableClick, onEditClick, onDeleteClick, }: RowMenuProps) => { + const { ref: projectRef } = useParams() + const getStatusName = (status: any) => { if (status && typeof status === 'object' && 'name' in status) { return status.name @@ -43,33 +54,104 @@ const RowMenu = ({ const statusName = getStatusName(pipelineStatus) const pipelineEnabled = statusName !== PipelineStatusName.STOPPED + const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline } = useStopPipelineMutation() + const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus() + + const onEnablePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Enabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + const onDisablePipeline = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + return + } + + try { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Disabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION) + } + } + return (
- {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} -
) } - -export default RowMenu diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 711deddee3e2f..7df159728937b 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -9,6 +9,7 @@ import { ScaffoldSectionContent, ScaffoldTitle, } from 'components/layouts/Scaffold' +import { DocsButton } from 'components/ui/DocsButton' import { Input } from 'ui-patterns/DataInputs/Input' import { InviteMemberButton } from './InviteMemberButton' import MembersView from './MembersView' @@ -32,6 +33,7 @@ export const TeamSettings = () => { placeholder="Filter members" /> + diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx index 835d77cc54e3f..0267de60693d0 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -15,7 +15,6 @@ import { DrawerTrigger, Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from 'ui' import { useMediaQuery } from '../hooks/useMediaQuery' @@ -32,26 +31,24 @@ export function DataTableFilterControlsDrawer() { return ( - - - - - - - - -

- Toggle controls with{' '} - - - B - -

-
-
-
+ + + + + + + +

+ Toggle controls with{' '} + + + B + +

+
+
diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx index 0e9c18c8911a0..3e373ff2ee52a 100644 --- a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { useHotKey } from 'hooks/ui/useHotKey' -import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -10,28 +10,21 @@ export function DataTableResetButton() { useHotKey(table.resetColumnFilters, 'Escape') return ( - - - - - - -

- Reset filters with{' '} - - - Esc - -

-
-
-
+ + + + + +

+ Reset filters with{' '} + + + Esc + +

+
+
) } diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx index 4a5ed2200fe86..29f7a7f0495b9 100644 --- a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -1,16 +1,7 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react' import { ReactNode, useCallback, useEffect, useMemo } from 'react' -import { - Button, - cn, - Separator, - Skeleton, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from 'ui' +import { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -79,44 +70,41 @@ export function DataTableSheetDetails({ {isLoading && !selectedRowKey ? : title}
- - - - - - -

- Toggle controls with{' '} - - - B - -

-
-
-
+ + + + + +

+ Toggle controls with{' '} + + + B + +

+
+
diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts index ce20ee79d4a7b..30ae4cd85b7de 100644 --- a/apps/studio/data/replication/keys.ts +++ b/apps/studio/data/replication/keys.ts @@ -13,4 +13,6 @@ export const replicationKeys = { ['projects', projectRef, 'pipelines', pipelineId] as const, pipelinesStatus: (projectRef: string | undefined, pipelineId: number | undefined) => ['projects', projectRef, 'pipelines', pipelineId, 'status'] as const, + pipelinesReplicationStatus: (projectRef: string | undefined, pipelineId: number | undefined) => + ['projects', projectRef, 'pipelines', pipelineId, 'replication-status'] as const, } diff --git a/apps/studio/data/replication/pipeline-replication-status-query.ts b/apps/studio/data/replication/pipeline-replication-status-query.ts new file mode 100644 index 0000000000000..952fa97fa4412 --- /dev/null +++ b/apps/studio/data/replication/pipeline-replication-status-query.ts @@ -0,0 +1,50 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPipelineReplicationStatusParams = { projectRef?: string; pipelineId?: number } + +async function fetchReplicationPipelineReplicationStatus( + { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!pipelineId) throw new Error('pipelineId is required') + + const { data, error } = await get( + '/platform/replication/{ref}/pipelines/{pipeline_id}/replication-status', + { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationPipelineReplicationStatusData = Awaited< + ReturnType +> + +export const useReplicationPipelineReplicationStatusQuery = < + TData = ReplicationPipelineReplicationStatusData, +>( + { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), + ({ signal }) => fetchReplicationPipelineReplicationStatus({ projectRef, pipelineId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/pages/project/[ref]/database/replication.tsx b/apps/studio/pages/project/[ref]/database/replication.tsx deleted file mode 100644 index 20242cbeb779a..0000000000000 --- a/apps/studio/pages/project/[ref]/database/replication.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import ReplicationComingSoon from 'components/interfaces/Database/Replication/ComingSoon' -import Destinations from 'components/interfaces/Database/Replication/Destinations' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import { useFlag } from 'hooks/ui/useFlag' -import type { NextPageWithLayout } from 'types' - -const DatabaseReplicationPage: NextPageWithLayout = () => { - const enablePgReplicate = useFlag('enablePgReplicate') - - return ( - <> - {enablePgReplicate ? ( - <> - - - Replication - - - - - ) : ( - <> - - - Replication - Send data to other destinations - - - - - )} - - ) -} - -DatabaseReplicationPage.getLayout = (page) => ( - - {page} - -) - -export default DatabaseReplicationPage diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx new file mode 100644 index 0000000000000..bc1a2a5445ac4 --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' + +import { ReplicationComingSoon } from 'components/interfaces/Database/Replication/ComingSoon' +import { Destinations } from 'components/interfaces/Database/Replication/Destinations' +import { ReplicationPipelineStatus } from 'components/interfaces/Database/Replication/ReplicationPipelineStatus' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import { useFlag } from 'hooks/ui/useFlag' +import { PipelineRequestStatusProvider } from 'state/replication-pipeline-request-status' +import type { NextPageWithLayout } from 'types' + +const DatabaseReplicationPage: NextPageWithLayout = () => { + const enablePgReplicate = useFlag('enablePgReplicate') + const [selectedPipelineId, setSelectedPipelineId] = useState() + const [selectedDestinationName, setSelectedDestinationName] = useState() + + // [Joshen] Ideally selecting a pipeline should be a route on its own with pipelineId as the param + // e.g /project/ref/database/replication/[pipelineId] + // Can destinationName be derived from pipeline ID or something? + + const handleSelectPipeline = (pipelineId: number, destinationName: string) => { + setSelectedPipelineId(pipelineId) + setSelectedDestinationName(destinationName) + } + + const handleSelectBack = () => { + setSelectedPipelineId(undefined) + setSelectedDestinationName(undefined) + } + + return ( + <> + {enablePgReplicate ? ( + + + +
+ + {selectedPipelineId === undefined ? ( + + ) : ( + + )} +
+
+
+
+ ) : ( + <> + + +
+ +
+
+
+ + + )} + + ) +} + +DatabaseReplicationPage.getLayout = (page) => ( + + {page} + +) + +export default DatabaseReplicationPage diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx new file mode 100644 index 0000000000000..722c4de9120b5 --- /dev/null +++ b/apps/studio/state/replication-pipeline-request-status.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext, useState, ReactNode, useCallback } from 'react' + +export enum PipelineStatusRequestStatus { + None = 'None', + EnableRequested = 'EnableRequested', + DisableRequested = 'DisableRequested', +} + +interface PipelineRequestStatusContextType { + requestStatus: Record + setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void + getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus + updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void +} + +interface PipelineRequestStatusProviderProps { + children: ReactNode +} + +const PipelineRequestStatusContext = createContext( + undefined +) + +export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatusProviderProps) => { + const [requestStatus, setRequestStatusState] = useState< + Record + >({}) + + const setRequestStatus = (pipelineId: number, status: PipelineStatusRequestStatus) => { + setRequestStatusState((prev) => ({ + ...prev, + [pipelineId]: status, + })) + } + + const getRequestStatus = (pipelineId: number): PipelineStatusRequestStatus => { + return requestStatus[pipelineId] || PipelineStatusRequestStatus.None + } + + const updatePipelineStatus = useCallback( + (pipelineId: number, backendStatus: string | undefined) => { + const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None + + if ( + (currentRequestStatus === PipelineStatusRequestStatus.EnableRequested && + (backendStatus === 'started' || backendStatus === 'failed')) || + (currentRequestStatus === PipelineStatusRequestStatus.DisableRequested && + (backendStatus === 'stopped' || backendStatus === 'failed')) + ) { + setRequestStatus(pipelineId, PipelineStatusRequestStatus.None) + } + }, + [requestStatus, setRequestStatus] + ) + + return ( + + {children} + + ) +} + +export const usePipelineRequestStatus = () => { + const context = useContext(PipelineRequestStatusContext) + if (context === undefined) { + throw new Error('usePipelineRequestStatus must be used within a PipelineRequestStatusProvider') + } + return context +} diff --git a/apps/studio/styles/ui.scss b/apps/studio/styles/ui.scss index 886f96393d435..f0f37298fec5f 100644 --- a/apps/studio/styles/ui.scss +++ b/apps/studio/styles/ui.scss @@ -119,7 +119,7 @@ } .table-container thead th:first-child { - @apply pl-6 rounded rounded-r-none rounded-b-none; + @apply rounded rounded-r-none rounded-b-none; @apply border-l; } diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index b34c046fe0963..23e0737d942da 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -3391,6 +3391,23 @@ export interface paths { patch?: never trace?: never } + '/platform/replication/{ref}/pipelines/{pipeline_id}/replication-status': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get the status of a replication pipeline. */ + get: operations['ReplicationPipelinesController_getPipelineReplicationStatus'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/replication/{ref}/pipelines/{pipeline_id}/start': { parameters: { query?: never @@ -7353,6 +7370,41 @@ export interface components { tenant_id: string }[] } + ReplicationPipelineReplicationStatusResponse: { + /** @description Pipeline id */ + pipeline_id: number + /** @description Table statuses */ + table_statuses: { + /** @description Table replication state */ + state: + | { + /** @enum {string} */ + name: 'queued' + } + | { + /** @enum {string} */ + name: 'copying_table' + } + | { + /** @enum {string} */ + name: 'copied_table' + } + | { + lag: number + /** @enum {string} */ + name: 'following_wal' + } + | { + message: string + /** @enum {string} */ + name: 'error' + } + /** @description Table id (internal Postgres OID) */ + table_id: number + /** @description Table name */ + table_name: string + }[] + } ReplicationPipelineResponse: { /** @description Pipeline config */ config: { @@ -16607,6 +16659,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content */ 500: { headers: { @@ -16638,6 +16696,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to update project's content */ 500: { headers: { @@ -16671,6 +16735,12 @@ export interface operations { 'application/json': components['schemas']['UserContentObject'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to create project's content */ 500: { headers: { @@ -16702,6 +16772,12 @@ export interface operations { 'application/json': components['schemas']['BulkDeleteUserContentResponse'][] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to delete project's contents */ 500: { headers: { @@ -16734,6 +16810,12 @@ export interface operations { 'application/json': components['schemas']['GetContentCountV2Response'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve user's content counts */ 500: { headers: { @@ -16771,6 +16853,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content root folder */ 500: { headers: { @@ -16804,6 +16892,12 @@ export interface operations { 'application/json': components['schemas']['CreateUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to create project's content folder */ 500: { headers: { @@ -16830,6 +16924,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to delete project's content folders */ 500: { headers: { @@ -16867,6 +16967,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content folder */ 500: { headers: { @@ -16900,6 +17006,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to update project's content folder */ 500: { headers: { @@ -16930,6 +17042,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentByIdResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content by the given id */ 500: { headers: { @@ -18639,6 +18757,44 @@ export interface operations { } } } + ReplicationPipelinesController_getPipelineReplicationStatus: { + parameters: { + query?: never + header?: never + path: { + /** @description Pipeline id */ + pipeline_id: number + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Returns the pipeline replication status. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ReplicationPipelineReplicationStatusResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to get pipeline replication status. */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } ReplicationPipelinesController_startPipeline: { parameters: { query?: never