mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
progress
This commit is contained in:
1 parent
d8d312fee9
commit
849d8526af
22 files changed
+787
-343
No files matched your search
+80
-63
@@ -1,3 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
@@ -23,6 +24,8 @@ import { DestinationForm } from './DestinationForm'
|
||||
import { DestinationType } from './DestinationPanel.types'
|
||||
import { DestinationTypeSelection } from './DestinationTypeSelection'
|
||||
import { ReadReplicaForm } from './ReadReplicaForm'
|
||||
import { WarehouseDestinationForm } from './WarehouseDestinationForm'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
@@ -32,6 +35,8 @@ interface DestinationPanelProps {
|
||||
}
|
||||
|
||||
export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
const enablePgReplicate = useIsETLPrivateAlpha()
|
||||
const { hasAccess: hasETLReplicationAccess } = useCheckEntitlements('replication.etl')
|
||||
|
||||
@@ -39,6 +44,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
'destinationType',
|
||||
parseAsStringEnum<DestinationType>([
|
||||
'Read Replica',
|
||||
'Warehouse',
|
||||
'BigQuery',
|
||||
'Analytics Bucket',
|
||||
'DuckLake',
|
||||
@@ -94,74 +100,85 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
}
|
||||
}, [edit, invalidExistingDestination, setEdit])
|
||||
|
||||
useEffect(() => {
|
||||
if (urlDestinationType === 'Warehouse' && warehouseState.enabled) {
|
||||
setDestinationType(null)
|
||||
}
|
||||
}, [urlDestinationType, warehouseState.enabled, setDestinationType])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent size="lg" showClose={false}>
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editMode ? 'Edit destination' : 'Add destination'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode
|
||||
? 'Update the configuration for this destination.'
|
||||
: 'A destination can be a read replica or an external destination that receives replicated data in near real time.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Sheet
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
>
|
||||
<SheetContent size="lg" showClose={false}>
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editMode ? 'Edit destination' : 'Add destination'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode
|
||||
? 'Update the configuration for this destination.'
|
||||
: 'A destination can be a read replica or an external destination that receives replicated data in near real time.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<DestinationTypeSelection />
|
||||
<DestinationTypeSelection />
|
||||
|
||||
<DialogSectionSeparator />
|
||||
<DialogSectionSeparator />
|
||||
|
||||
{destinationType === 'Read Replica' ? (
|
||||
<ReadReplicaForm onClose={onClose} onSuccess={() => onSuccessCreateReadReplica?.()} />
|
||||
) : !enablePgReplicate ? (
|
||||
<SheetSection>
|
||||
<div className={cn('border rounded-md p-6 flex flex-col gap-y-4')}>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>Request Pipelines access</h4>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Pipelines is in <span className="text-foreground">alpha</span> and being
|
||||
rolled out gradually. Request access below to join the waitlist. Read replicas
|
||||
are available now.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="secondary"
|
||||
iconRight={<ArrowUpRight size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://forms.supabase.com/pg_replicate"
|
||||
>
|
||||
Request alpha access
|
||||
</Link>
|
||||
</Button>
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/replication#pipelines`} />
|
||||
</div>
|
||||
{destinationType === 'Read Replica' ? (
|
||||
<ReadReplicaForm onClose={onClose} onSuccess={() => onSuccessCreateReadReplica?.()} />
|
||||
) : destinationType === 'Warehouse' ? (
|
||||
<WarehouseDestinationForm onClose={onClose} />
|
||||
) : !enablePgReplicate ? (
|
||||
<SheetSection>
|
||||
<div className={cn('border rounded-md p-6 flex flex-col gap-y-4')}>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>Request Pipelines access</h4>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Pipelines is in <span className="text-foreground">alpha</span> and being rolled
|
||||
out gradually. Request access below to join the waitlist. Read replicas are
|
||||
available now.
|
||||
</p>
|
||||
</div>
|
||||
</SheetSection>
|
||||
) : replicationNotEnabled ? (
|
||||
<SheetSection>
|
||||
<EnablePipelinesCallout
|
||||
className="p-6!"
|
||||
type={destinationType}
|
||||
hasAccess={hasETLReplicationAccess}
|
||||
/>
|
||||
</SheetSection>
|
||||
) : (
|
||||
<DestinationForm
|
||||
visible={visible}
|
||||
selectedType={destinationType ?? 'Read Replica'}
|
||||
existingDestination={existingDestination}
|
||||
onClose={onClose}
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="secondary"
|
||||
iconRight={<ArrowUpRight size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://forms.supabase.com/pg_replicate"
|
||||
>
|
||||
Request alpha access
|
||||
</Link>
|
||||
</Button>
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/replication#pipelines`} />
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
) : replicationNotEnabled ? (
|
||||
<SheetSection>
|
||||
<EnablePipelinesCallout
|
||||
className="p-6!"
|
||||
type={destinationType}
|
||||
hasAccess={hasETLReplicationAccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
</SheetSection>
|
||||
) : (
|
||||
<DestinationForm
|
||||
visible={visible}
|
||||
selectedType={destinationType ?? 'Read Replica'}
|
||||
existingDestination={existingDestination}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
export type DestinationType =
|
||||
| 'Read Replica'
|
||||
| 'Warehouse'
|
||||
| 'BigQuery'
|
||||
| 'Analytics Bucket'
|
||||
| 'DuckLake'
|
||||
|
||||
+9
-1
@@ -32,6 +32,14 @@ vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: () => ({ infrastructureReadReplicas: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useIsWarehouseEnabled', () => ({
|
||||
useIsWarehouseEnabled: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Database/Warehouse/warehouseDemoStore', () => ({
|
||||
useWarehouseProjectState: () => ({ enabled: false }),
|
||||
}))
|
||||
|
||||
// Background queries from useDestinationInformation (sources + pipelines fire
|
||||
// even in create mode). Prevent retries so unmatched handlers fail fast.
|
||||
vi.mock('@/data/replication/utils', () => ({
|
||||
@@ -125,7 +133,7 @@ describe('DestinationTypeSelection', () => {
|
||||
fireEvent.click(await screen.findByRole('combobox'))
|
||||
fireEvent.click(await screen.findByText('BigQuery'))
|
||||
|
||||
expect(await screen.findByText(/This destination type is in alpha/)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/In alpha and may change/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('disables the selector in edit mode so the destination type cannot be changed', async () => {
|
||||
|
||||
+37
-6
@@ -1,6 +1,8 @@
|
||||
import { useParams } from 'common'
|
||||
import { AnalyticsBucket, BigQuery, Database } from 'icons'
|
||||
import { Snowflake } from 'lucide-react'
|
||||
import { Snowflake, Warehouse } from 'lucide-react'
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import type { ElementType } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Select,
|
||||
@@ -21,14 +23,16 @@ import {
|
||||
useIsETLSnowflakePrivateAlpha,
|
||||
} from '../useIsETLPrivateAlpha'
|
||||
import { DestinationType } from './DestinationPanel.types'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
|
||||
interface DestinationTypeOption {
|
||||
value: DestinationType
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Database
|
||||
icon: ElementType<{ size?: number; className?: string }>
|
||||
isAlpha: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -38,17 +42,34 @@ interface DestinationTypeGroup {
|
||||
options: DestinationTypeOption[]
|
||||
}
|
||||
|
||||
const LUCIDE_DESTINATION_TYPES = new Set<DestinationType>(['Warehouse', 'Snowflake'])
|
||||
|
||||
function DestinationOptionIcon({ option }: { option: DestinationTypeOption }) {
|
||||
const Icon = option.icon
|
||||
const className = 'shrink-0 text-foreground-light'
|
||||
|
||||
if (LUCIDE_DESTINATION_TYPES.has(option.value)) {
|
||||
return <Icon size={20} strokeWidth={1.5} className={className} />
|
||||
}
|
||||
|
||||
return <Icon size={20} className={className} />
|
||||
}
|
||||
|
||||
export const DestinationTypeSelection = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
|
||||
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
|
||||
const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
|
||||
const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
||||
|
||||
const [urlDestinationType, setDestinationType] = useQueryState(
|
||||
'destinationType',
|
||||
parseAsStringEnum<DestinationType>([
|
||||
'Read Replica',
|
||||
'Warehouse',
|
||||
'BigQuery',
|
||||
'Analytics Bucket',
|
||||
'DuckLake',
|
||||
@@ -91,6 +112,17 @@ export const DestinationTypeSelection = () => {
|
||||
{
|
||||
label: 'Pipelines',
|
||||
options: [
|
||||
{
|
||||
value: 'Warehouse',
|
||||
label: 'Warehouse',
|
||||
description: 'Replicate to a managed Warehouse endpoint for analytics',
|
||||
icon: Warehouse,
|
||||
isAlpha: true,
|
||||
enabled: isOptionVisible(
|
||||
'Warehouse',
|
||||
isWarehouseFeatureEnabled && !warehouseState.enabled
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'Analytics Bucket',
|
||||
label: 'Analytics Bucket',
|
||||
@@ -146,8 +178,7 @@ export const DestinationTypeSelection = () => {
|
||||
description={
|
||||
selectedOption?.isAlpha && (
|
||||
<span className="block text-sm text-foreground-light mb-1">
|
||||
This destination type is in alpha and may be unstable or introduce breaking changes
|
||||
while we iterate based on customer feedback.{' '}
|
||||
In alpha and may change.{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
|
||||
Leave feedback
|
||||
</InlineLink>
|
||||
@@ -163,7 +194,7 @@ export const DestinationTypeSelection = () => {
|
||||
<SelectTrigger className="h-auto py-2">
|
||||
{selectedOption ? (
|
||||
<div className="flex items-center gap-x-3 text-left">
|
||||
<selectedOption.icon size={20} className="shrink-0 text-foreground-light" />
|
||||
<DestinationOptionIcon option={selectedOption} />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.isAlpha && <Badge variant="warning">Alpha</Badge>}
|
||||
@@ -181,7 +212,7 @@ export const DestinationTypeSelection = () => {
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<option.icon size={20} className="shrink-0 text-foreground-light" />
|
||||
<DestinationOptionIcon option={option} />
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { useParams } from 'common'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Checkbox, Label, SheetFooter, SheetSection } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { enableWarehouseProject } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { useDefaultWarehouseSchemas } from '@/components/interfaces/Database/Warehouse/WarehouseSchemaScope'
|
||||
|
||||
interface WarehouseDestinationFormProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function WarehouseDestinationForm({ onClose }: WarehouseDestinationFormProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const defaultSchemas = useDefaultWarehouseSchemas()
|
||||
|
||||
const [selectedSchemas, setSelectedSchemas] = useState<string[]>(() => defaultSchemas)
|
||||
const [isEnabling, setIsEnabling] = useState(false)
|
||||
|
||||
const selectedSet = new Set(selectedSchemas)
|
||||
|
||||
const onToggleSchema = (schema: string, checked: boolean) => {
|
||||
setSelectedSchemas((prev) => (checked ? [...prev, schema] : prev.filter((s) => s !== schema)))
|
||||
}
|
||||
|
||||
const onEnable = async () => {
|
||||
if (!projectRef || selectedSchemas.length === 0) return
|
||||
setIsEnabling(true)
|
||||
enableWarehouseProject(projectRef, selectedSchemas)
|
||||
setIsEnabling(false)
|
||||
toast.success('Warehouse replication started')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetSection className="grow overflow-auto">
|
||||
<div className="flex flex-col gap-y-4 p-5">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Replicate your Postgres data to a separate Warehouse endpoint. Tables keep the same
|
||||
schema and table names (for example, <code>public.events</code>).
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-2">Replicated schemas</p>
|
||||
<p className="text-sm text-foreground-light mb-3">
|
||||
Choose which schemas to replicate. You can change this later.
|
||||
</p>
|
||||
<div className="flex flex-col gap-y-2 max-h-64 overflow-y-auto">
|
||||
{defaultSchemas.map((schema) => (
|
||||
<FormItemLayout
|
||||
key={schema}
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
label={schema}
|
||||
className="items-center"
|
||||
>
|
||||
<Checkbox
|
||||
id={`destination-warehouse-schema-${schema}`}
|
||||
checked={selectedSet.has(schema)}
|
||||
onCheckedChange={(checked) => onToggleSchema(schema, checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`destination-warehouse-schema-${schema}`} className="sr-only">
|
||||
Include schema {schema}
|
||||
</Label>
|
||||
</FormItemLayout>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setSelectedSchemas(defaultSchemas)}
|
||||
className="mt-3"
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isEnabling} disabled={selectedSchemas.length === 0} onClick={onEnable}>
|
||||
Enable Warehouse
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from './useIsETLPrivateAlpha'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { WarehouseDestinationRow } from '@/components/interfaces/Database/Warehouse/WarehouseDestinationRow'
|
||||
import { WarehouseManageSheet } from '@/components/interfaces/Database/Warehouse/WarehouseManageSheet'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
@@ -67,17 +68,22 @@ export const Destinations = () => {
|
||||
const hasManagedWarehouse = isWarehouseFeatureEnabled && warehouseState.enabled
|
||||
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
||||
|
||||
const newDestinationDefaultType = infrastructureReadReplicas
|
||||
? 'Read Replica'
|
||||
: etlEnableBigQuery
|
||||
? 'BigQuery'
|
||||
: etlEnableIceberg
|
||||
? 'Analytics Bucket'
|
||||
: etlEnableDucklake
|
||||
? 'DuckLake'
|
||||
: etlEnableSnowflake
|
||||
? 'Snowflake'
|
||||
: null
|
||||
const newDestinationDefaultType: DestinationType | null =
|
||||
isWarehouseFeatureEnabled && !warehouseState.enabled
|
||||
? 'Warehouse'
|
||||
: infrastructureReadReplicas
|
||||
? 'Read Replica'
|
||||
: etlEnableBigQuery
|
||||
? 'BigQuery'
|
||||
: etlEnableIceberg
|
||||
? 'Analytics Bucket'
|
||||
: etlEnableDucklake
|
||||
? 'DuckLake'
|
||||
: etlEnableSnowflake
|
||||
? 'Snowflake'
|
||||
: isWarehouseFeatureEnabled
|
||||
? 'Warehouse'
|
||||
: null
|
||||
|
||||
const prefetchedRef = useRef(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -89,6 +95,7 @@ export const Destinations = () => {
|
||||
'destinationType',
|
||||
parseAsStringEnum<DestinationType>([
|
||||
'Read Replica',
|
||||
'Warehouse',
|
||||
'BigQuery',
|
||||
'Analytics Bucket',
|
||||
'DuckLake',
|
||||
@@ -361,7 +368,7 @@ export const Destinations = () => {
|
||||
<EmptyStatePresentational
|
||||
icon={Database}
|
||||
title="Add a replication destination"
|
||||
description="Deploy a Read Replica to reduce latency and isolate workloads, or connect a Pipelines destination for analytics."
|
||||
description="Replicate to another region, an analytics destination, or Warehouse."
|
||||
className="mt-4"
|
||||
>
|
||||
<Button
|
||||
@@ -378,6 +385,7 @@ export const Destinations = () => {
|
||||
</div>
|
||||
|
||||
<DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
|
||||
<WarehouseManageSheet />
|
||||
|
||||
<DisablePipelinesDialog
|
||||
open={showDisablePipelinesDialog}
|
||||
|
||||
@@ -10,7 +10,7 @@ All platform behavior is mocked via `warehouseDemoStore` (localStorage). No real
|
||||
|
||||
## In scope
|
||||
|
||||
- **Replication page** — `WarehouseProjectCard` (enable/disable, schema scope, observability)
|
||||
- **Replication page** — enable via **Add destination → Warehouse**; manage via separate **Manage replication** sheet (`?warehouseManage=true`)
|
||||
- **Destinations table** — read-only managed row (`DuckLake` / `supabase_warehouse`)
|
||||
- **Table list** — read-only **Replicated** column
|
||||
- **SQL Editor** — Postgres / Warehouse target toggle + mock "Served by: Warehouse" footer
|
||||
@@ -43,7 +43,7 @@ Enable simulates `provisioning` → `backfilling` → `streaming` phase progress
|
||||
## QA checklist
|
||||
|
||||
1. Flag off → no Warehouse UI
|
||||
2. Flag on, disabled → Replication card with Enable CTA only
|
||||
2. Flag on, disabled → empty state **Add destination** opens sheet with Warehouse as default type
|
||||
3. Enable → observability metrics, schema checkboxes, Destinations row, Replicated column, SQL toggle, Connect Warehouse tab
|
||||
4. Uncheck schema → tables in that schema show Replicated = No
|
||||
5. Disable → confirm destructive modal, all Warehouse UI hidden
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useParams } from 'common'
|
||||
import { Database, Minus } from 'lucide-react'
|
||||
import { TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { DeleteDestination } from '../Replication/DeleteDestination'
|
||||
import { MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP } from './managedWarehouse.resources'
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { disableWarehouseProject, useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { WarehouseDestinationRowMenu } from './WarehouseDestinationRowMenu'
|
||||
import {
|
||||
formatReplicationLagSeconds,
|
||||
getWarehouseDestinationStatusLabel,
|
||||
@@ -13,52 +17,94 @@ import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
export function WarehouseDestinationRow() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const state = useWarehouseProjectState(projectRef)
|
||||
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
if (!state.enabled || !state.pipelineId) return null
|
||||
|
||||
const statusLabel = getWarehouseDestinationStatusLabel(state.replicationPhase)
|
||||
const destinationName = `DuckLake (Pipeline ID: ${state.pipelineId})`
|
||||
const statusLabel = getWarehouseDestinationStatusLabel(
|
||||
state.replicationPhase,
|
||||
state.pipelineStatus
|
||||
)
|
||||
const lag = formatReplicationLagSeconds(state.lagSeconds)
|
||||
const isCaughtUp = state.lagSeconds === 0 || state.lagSeconds === 2
|
||||
const isPipelineStopped =
|
||||
state.pipelineStatus === 'stopped' && state.replicationPhase === 'streaming'
|
||||
const showLag =
|
||||
!isPipelineStopped &&
|
||||
(state.replicationPhase === 'streaming' || state.replicationPhase === 'backfilling')
|
||||
|
||||
const onDeleteDestination = async () => {
|
||||
if (!projectRef) return
|
||||
setIsDeleting(true)
|
||||
disableWarehouseProject(projectRef)
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDestinationForm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Database size={18} className="text-foreground-light" />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px]">
|
||||
<div>
|
||||
<p>DuckLake (Pipeline ID: {state.pipelineId})</p>
|
||||
<p className="text-foreground-lighter">{state.destinationName}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WarehouseSyncChip phase={state.replicationPhase} />
|
||||
{statusLabel === 'Failed' && (
|
||||
<p className="text-xs text-foreground-lighter mt-1">Managed Warehouse pipeline</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{state.replicationPhase === 'streaming' || state.replicationPhase === 'backfilling' ? (
|
||||
isCaughtUp ? (
|
||||
<p className="text-foreground-light">Caught up</p>
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Database size={18} className="text-foreground-light" />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px]">
|
||||
<div>
|
||||
<p>{destinationName}</p>
|
||||
<p className="text-foreground-lighter">{state.destinationName}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WarehouseSyncChip phase={state.replicationPhase} pipelineStatus={state.pipelineStatus} />
|
||||
{statusLabel === 'Failed' && (
|
||||
<p className="text-xs text-foreground-lighter mt-1">Managed Warehouse pipeline</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{showLag ? (
|
||||
isCaughtUp ? (
|
||||
<p className="text-foreground-light">Caught up</p>
|
||||
) : (
|
||||
<p className="text-foreground-light">{lag}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-foreground-light">{lag}</p>
|
||||
)
|
||||
) : (
|
||||
<Minus size={18} className="text-foreground-lighter" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-foreground-light">{state.publicationName}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
<Minus size={18} className="text-foreground-lighter" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-foreground-light">{state.publicationName}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button asChild variant="default" className="relative">
|
||||
<Link href={`/project/${projectRef}/database/replication/${state.pipelineId}`}>
|
||||
View pipeline
|
||||
</Link>
|
||||
</Button>
|
||||
<WarehouseDestinationRowMenu
|
||||
replicationPhase={state.replicationPhase}
|
||||
pipelineStatus={state.pipelineStatus}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<DeleteDestination
|
||||
visible={showDeleteDestinationForm}
|
||||
setVisible={setShowDeleteDestinationForm}
|
||||
onDelete={onDeleteDestination}
|
||||
isLoading={isDeleting}
|
||||
name={destinationName}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useParams } from 'common'
|
||||
import { Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import type { ReplicationPhase, WarehousePipelineStatus } from './warehouseDemoStore'
|
||||
import {
|
||||
restartWarehousePipeline,
|
||||
startWarehousePipeline,
|
||||
stopWarehousePipeline,
|
||||
} from './warehouseDemoStore'
|
||||
|
||||
interface WarehouseDestinationRowMenuProps {
|
||||
replicationPhase: ReplicationPhase
|
||||
pipelineStatus: WarehousePipelineStatus
|
||||
onDeleteClick: () => void
|
||||
}
|
||||
|
||||
export function WarehouseDestinationRowMenu({
|
||||
replicationPhase,
|
||||
pipelineStatus,
|
||||
onDeleteClick,
|
||||
}: WarehouseDestinationRowMenuProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [, setShowManage] = useQueryState('warehouseManage', parseAsBoolean.withDefault(false))
|
||||
|
||||
const isTransitional = replicationPhase === 'provisioning' || replicationPhase === 'backfilling'
|
||||
const isStreaming = replicationPhase === 'streaming'
|
||||
const isFailed = replicationPhase === 'failed'
|
||||
const isRunning = pipelineStatus === 'running'
|
||||
const isStopped = pipelineStatus === 'stopped'
|
||||
|
||||
const showStopAndRestart = !isTransitional && isStreaming && isRunning
|
||||
const showStart = !isTransitional && isStreaming && isStopped
|
||||
const showFailedActions = isFailed
|
||||
|
||||
const onRestartPipeline = () => {
|
||||
if (!projectRef) return
|
||||
restartWarehousePipeline(projectRef)
|
||||
toast.success('Warehouse pipeline restarted')
|
||||
}
|
||||
|
||||
const onStopPipeline = () => {
|
||||
if (!projectRef) return
|
||||
stopWarehousePipeline(projectRef)
|
||||
toast.success('Warehouse pipeline stopped')
|
||||
}
|
||||
|
||||
const onStartPipeline = () => {
|
||||
if (!projectRef) return
|
||||
startWarehousePipeline(projectRef)
|
||||
toast.success('Warehouse pipeline started')
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
{(showStopAndRestart || showFailedActions) && (
|
||||
<>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onRestartPipeline}>
|
||||
<RotateCcw size={14} />
|
||||
<p>Restart pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
{showStopAndRestart && (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onStopPipeline}>
|
||||
<Pause size={14} />
|
||||
<p>Stop pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{showStart && (
|
||||
<>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onStartPipeline}>
|
||||
<Play size={14} />
|
||||
<p>Start pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="space-x-2" onClick={() => setShowManage(true)}>
|
||||
<Edit size={14} />
|
||||
<p>Edit destination</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
|
||||
<Trash size={14} />
|
||||
<p>Delete destination</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,14 @@ import { disableWarehouseProject } from './warehouseDemoStore'
|
||||
interface WarehouseDisableModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDisabled?: () => void
|
||||
}
|
||||
|
||||
export function WarehouseDisableModal({ open, onOpenChange }: WarehouseDisableModalProps) {
|
||||
export function WarehouseDisableModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onDisabled,
|
||||
}: WarehouseDisableModalProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [isDisabling, setIsDisabling] = useState(false)
|
||||
|
||||
@@ -29,6 +34,7 @@ export function WarehouseDisableModal({ open, onOpenChange }: WarehouseDisableMo
|
||||
disableWarehouseProject(projectRef)
|
||||
setIsDisabling(false)
|
||||
onOpenChange(false)
|
||||
onDisabled?.()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
Label,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { enableWarehouseProject } from './warehouseDemoStore'
|
||||
import { useDefaultWarehouseSchemas } from './WarehouseSchemaScope'
|
||||
|
||||
interface WarehouseEnableModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function WarehouseEnableModal({ open, onOpenChange }: WarehouseEnableModalProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const defaultSchemas = useDefaultWarehouseSchemas()
|
||||
const [selectedSchemas, setSelectedSchemas] = useState<string[]>(defaultSchemas)
|
||||
const [isEnabling, setIsEnabling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedSchemas(defaultSchemas)
|
||||
}, [open, defaultSchemas])
|
||||
|
||||
const selectedSet = new Set(selectedSchemas)
|
||||
|
||||
const onToggle = (schema: string, checked: boolean) => {
|
||||
setSelectedSchemas((prev) => (checked ? [...prev, schema] : prev.filter((s) => s !== schema)))
|
||||
}
|
||||
|
||||
const onEnable = async () => {
|
||||
if (!projectRef || selectedSchemas.length === 0) return
|
||||
setIsEnabling(true)
|
||||
enableWarehouseProject(projectRef, selectedSchemas)
|
||||
setIsEnabling(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="medium">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Warehouse</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSection>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Create an analytical replica of your Postgres data on a separate Warehouse endpoint.
|
||||
Tables use the same schema and table names (for example, <code>public.events</code>).
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light mt-3">
|
||||
Select which schemas to replicate. You can change this later from the Warehouse card.
|
||||
</p>
|
||||
<div className="flex flex-col gap-y-2 max-h-64 overflow-y-auto mt-4">
|
||||
{defaultSchemas.map((schema) => (
|
||||
<FormItemLayout
|
||||
key={schema}
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
label={schema}
|
||||
className="items-center"
|
||||
>
|
||||
<Checkbox
|
||||
id={`enable-warehouse-schema-${schema}`}
|
||||
checked={selectedSet.has(schema)}
|
||||
onCheckedChange={(checked) => onToggle(schema, checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`enable-warehouse-schema-${schema}`} className="sr-only">
|
||||
Include schema {schema}
|
||||
</Label>
|
||||
</FormItemLayout>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setSelectedSchemas(defaultSchemas)}
|
||||
className="mt-3"
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isEnabling} disabled={selectedSchemas.length === 0} onClick={onEnable}>
|
||||
Enable Warehouse
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useParams } from 'common'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DialogSectionSeparator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
} from 'ui'
|
||||
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { WarehouseDisableModal } from './WarehouseDisableModal'
|
||||
import { WarehouseSchemaScope } from './WarehouseSchemaScope'
|
||||
|
||||
export function WarehouseManageSheet() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const [showManage, setShowManage] = useQueryState(
|
||||
'warehouseManage',
|
||||
parseAsBoolean.withDefault(false)
|
||||
)
|
||||
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
|
||||
const [, setConnectTab] = useQueryState('connectTab')
|
||||
const [showDisableModal, setShowDisableModal] = useState(false)
|
||||
|
||||
const onClose = () => setShowManage(false)
|
||||
|
||||
const onDisableSuccess = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!warehouseState.enabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet
|
||||
open={showManage}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
>
|
||||
<SheetContent size="lg" showClose={false}>
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit destination</SheetTitle>
|
||||
<SheetDescription>Update which schemas are replicated to Warehouse.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<SheetSection className="grow overflow-auto">
|
||||
<div className="flex flex-col gap-y-4 p-5">
|
||||
<WarehouseSchemaScope
|
||||
disabled={
|
||||
warehouseState.replicationPhase === 'provisioning' ||
|
||||
warehouseState.replicationPhase === 'backfilling'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectTab('warehouse')
|
||||
setShowConnect(true)
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDisableModal(true)}>
|
||||
Disable Warehouse
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<WarehouseDisableModal
|
||||
open={showDisableModal}
|
||||
onOpenChange={setShowDisableModal}
|
||||
onDisabled={onDisableSuccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+4
-1
@@ -24,7 +24,10 @@ export function WarehouseObservabilityPanel() {
|
||||
className="[&_span]:text-sm"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<WarehouseSyncChip phase={state.replicationPhase} />
|
||||
<WarehouseSyncChip
|
||||
phase={state.replicationPhase}
|
||||
pipelineStatus={state.pipelineStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useParams } from 'common'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { WarehouseDisableModal } from './WarehouseDisableModal'
|
||||
import { WarehouseEnableModal } from './WarehouseEnableModal'
|
||||
import { WarehouseObservabilityPanel } from './WarehouseObservabilityPanel'
|
||||
import { WarehouseSchemaScope } from './WarehouseSchemaScope'
|
||||
import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
|
||||
export function WarehouseProjectCard() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const [showEnableModal, setShowEnableModal] = useState(false)
|
||||
const [showDisableModal, setShowDisableModal] = useState(false)
|
||||
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
|
||||
const [, setConnectTab] = useQueryState('connectTab')
|
||||
|
||||
if (!isWarehouseFeatureEnabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-6 flex flex-col gap-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h4 className="text-foreground text-base">Warehouse</h4>
|
||||
{warehouseState.enabled && (
|
||||
<WarehouseSyncChip phase={warehouseState.replicationPhase} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light max-w-2xl">
|
||||
{warehouseState.enabled
|
||||
? 'Analytical replica of your Postgres data on a separate Warehouse endpoint. Query the same schema and table names against the Warehouse host.'
|
||||
: 'Enable an analytical replica of your Postgres data on a separate Warehouse endpoint for analytics workloads.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2 shrink-0">
|
||||
{warehouseState.enabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectTab('warehouse')
|
||||
setShowConnect(true)
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDisableModal(true)}>
|
||||
Disable Warehouse
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setShowEnableModal(true)}>Enable Warehouse</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warehouseState.enabled && (
|
||||
<>
|
||||
<WarehouseObservabilityPanel />
|
||||
<WarehouseSchemaScope
|
||||
disabled={
|
||||
warehouseState.replicationPhase === 'provisioning' ||
|
||||
warehouseState.replicationPhase === 'backfilling'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WarehouseEnableModal open={showEnableModal} onOpenChange={setShowEnableModal} />
|
||||
<WarehouseDisableModal open={showDisableModal} onOpenChange={setShowDisableModal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { useParams } from 'common'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { isTableReplicated, useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { WarehouseObservabilityPanel } from './WarehouseObservabilityPanel'
|
||||
import { formatReplicationLagSeconds } from './warehouseReplication.utils'
|
||||
import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
import { useTablesQuery } from '@/data/tables/tables-query'
|
||||
|
||||
export function WarehouseReplicationPipelineStatus() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const state = useWarehouseProjectState(projectRef)
|
||||
|
||||
const { data: tables, isPending: isTablesLoading } = useTablesQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !!projectRef && state.enabled }
|
||||
)
|
||||
|
||||
const replicatedTables = useMemo(() => {
|
||||
if (!tables) return []
|
||||
return tables
|
||||
.filter((table) => isTableReplicated(projectRef, table.schema, table.name))
|
||||
.sort((a, b) => `${a.schema}.${a.name}`.localeCompare(`${b.schema}.${b.name}`))
|
||||
}, [tables, projectRef])
|
||||
|
||||
if (!state.enabled || !state.pipelineId) return null
|
||||
|
||||
const destinationName = `DuckLake (Pipeline ID: ${state.pipelineId})`
|
||||
const isPipelineStopped =
|
||||
state.pipelineStatus === 'stopped' && state.replicationPhase === 'streaming'
|
||||
const tableStatusLabel =
|
||||
state.replicationPhase === 'failed'
|
||||
? 'Error'
|
||||
: state.replicationPhase === 'backfilling'
|
||||
? 'Backfilling'
|
||||
: isPipelineStopped
|
||||
? 'Paused'
|
||||
: state.replicationPhase === 'streaming'
|
||||
? 'Replicating'
|
||||
: 'Pending'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button asChild variant="outline" icon={<ChevronLeft />} style={{ padding: '5px' }}>
|
||||
<Link href={`/project/${projectRef}/database/replication`} />
|
||||
</Button>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<h3 className="text-xl font-semibold">{destinationName}</h3>
|
||||
<WarehouseSyncChip
|
||||
phase={state.replicationPhase}
|
||||
pipelineStatus={state.pipelineStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WarehouseObservabilityPanel />
|
||||
|
||||
<div className="border border-default rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-default">
|
||||
<h4 className="text-sm font-semibold text-foreground">Table replication</h4>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Tables in replicated schemas use the same schema and table names on the Warehouse
|
||||
endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTablesLoading ? (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : replicatedTables.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-foreground-light">No replicated tables found.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Replication lag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{replicatedTables.map((table) => (
|
||||
<TableRow key={`${table.schema}.${table.name}`}>
|
||||
<TableCell>
|
||||
{table.schema}.{table.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tableStatusLabel === 'Error'
|
||||
? 'destructive'
|
||||
: tableStatusLabel === 'Backfilling' || tableStatusLabel === 'Pending'
|
||||
? 'warning'
|
||||
: tableStatusLabel === 'Replicating'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{tableStatusLabel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isPipelineStopped || state.replicationPhase === 'provisioning'
|
||||
? '—'
|
||||
: formatReplicationLagSeconds(state.lagSeconds)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function isWarehouseMockPipelineId(
|
||||
projectRef: string | undefined,
|
||||
pipelineId: string | undefined,
|
||||
warehouseState: ReturnType<typeof useWarehouseProjectState>
|
||||
): boolean {
|
||||
if (!projectRef || !pipelineId || !warehouseState.enabled) return false
|
||||
return warehouseState.pipelineId === pipelineId
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useWarehouseProjectState,
|
||||
} from './warehouseDemoStore'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { EMPTY_ARR } from '@/lib/void'
|
||||
|
||||
interface WarehouseSchemaScopeProps {
|
||||
disabled?: boolean
|
||||
@@ -17,12 +18,12 @@ interface WarehouseSchemaScopeProps {
|
||||
|
||||
export function WarehouseSchemaScope({ disabled = false }: WarehouseSchemaScopeProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: schemas = [] } = useSchemasQuery({ projectRef })
|
||||
const { data: schemas } = useSchemasQuery({ projectRef })
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const replicableSchemas = useMemo(
|
||||
() =>
|
||||
schemas
|
||||
(schemas ?? EMPTY_ARR)
|
||||
.map((s) => s.name)
|
||||
.filter(isReplicableSchema)
|
||||
.sort(),
|
||||
@@ -80,6 +81,9 @@ export function WarehouseSchemaScope({ disabled = false }: WarehouseSchemaScopeP
|
||||
|
||||
export function useDefaultWarehouseSchemas(): string[] {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: schemas = [] } = useSchemasQuery({ projectRef })
|
||||
return useMemo(() => getDefaultIncludedSchemas(schemas.map((s) => s.name)), [schemas])
|
||||
const { data: schemas } = useSchemasQuery({ projectRef })
|
||||
return useMemo(
|
||||
() => getDefaultIncludedSchemas((schemas ?? EMPTY_ARR).map((s) => s.name)),
|
||||
[schemas]
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Badge } from 'ui'
|
||||
|
||||
import type { ReplicationPhase } from './warehouseDemoStore'
|
||||
import type { ReplicationPhase, WarehousePipelineStatus } from './warehouseDemoStore'
|
||||
import { getWarehouseDestinationStatusLabel } from './warehouseReplication.utils'
|
||||
|
||||
interface WarehouseSyncChipProps {
|
||||
phase: ReplicationPhase
|
||||
pipelineStatus?: WarehousePipelineStatus
|
||||
}
|
||||
|
||||
export function WarehouseSyncChip({ phase }: WarehouseSyncChipProps) {
|
||||
const label = getWarehouseDestinationStatusLabel(phase)
|
||||
export function WarehouseSyncChip({ phase, pipelineStatus = 'running' }: WarehouseSyncChipProps) {
|
||||
const label = getWarehouseDestinationStatusLabel(phase, pipelineStatus)
|
||||
|
||||
const variant =
|
||||
phase === 'failed'
|
||||
? 'destructive'
|
||||
: phase === 'backfilling' || phase === 'provisioning'
|
||||
? 'warning'
|
||||
: phase === 'streaming'
|
||||
? 'success'
|
||||
: 'default'
|
||||
pipelineStatus === 'stopped' && phase === 'streaming'
|
||||
? 'default'
|
||||
: phase === 'failed'
|
||||
? 'destructive'
|
||||
: phase === 'backfilling' || phase === 'provisioning'
|
||||
? 'warning'
|
||||
: phase === 'streaming'
|
||||
? 'success'
|
||||
: 'default'
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>
|
||||
}
|
||||
@@ -5,4 +5,4 @@ export function isSupabaseManagedWarehousePublicationName(name: string): boolean
|
||||
}
|
||||
|
||||
export const MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP =
|
||||
'Managed by Supabase for Warehouse replication. Configure schemas on the Warehouse card above.'
|
||||
'Managed by Supabase for Warehouse replication. Use Manage to update replicated schemas.'
|
||||
@@ -6,9 +6,12 @@ import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
export type ReplicationPhase = 'idle' | 'provisioning' | 'backfilling' | 'streaming' | 'failed'
|
||||
|
||||
export type WarehousePipelineStatus = 'running' | 'stopped'
|
||||
|
||||
export interface WarehouseProjectState {
|
||||
enabled: boolean
|
||||
replicationPhase: ReplicationPhase
|
||||
pipelineStatus: WarehousePipelineStatus
|
||||
lagSeconds: number | null
|
||||
includedSchemas: string[]
|
||||
pipelineId: string | null
|
||||
@@ -21,6 +24,7 @@ export interface WarehouseProjectState {
|
||||
const DEFAULT_STATE: WarehouseProjectState = {
|
||||
enabled: false,
|
||||
replicationPhase: 'idle',
|
||||
pipelineStatus: 'stopped',
|
||||
lagSeconds: null,
|
||||
includedSchemas: [],
|
||||
pipelineId: null,
|
||||
@@ -71,7 +75,14 @@ export function getDefaultIncludedSchemas(availableSchemas: string[]): string[]
|
||||
}
|
||||
|
||||
function getProjectState(projectRef: string): WarehouseProjectState {
|
||||
return warehouseDemoStore.projects[projectRef] ?? { ...DEFAULT_STATE }
|
||||
const raw = warehouseDemoStore.projects[projectRef]
|
||||
if (!raw) return { ...DEFAULT_STATE }
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...raw,
|
||||
pipelineStatus:
|
||||
raw.pipelineStatus ?? (raw.replicationPhase === 'streaming' ? 'running' : 'stopped'),
|
||||
}
|
||||
}
|
||||
|
||||
function setProjectState(projectRef: string, next: WarehouseProjectState): void {
|
||||
@@ -81,7 +92,14 @@ function setProjectState(projectRef: string, next: WarehouseProjectState): void
|
||||
export function useWarehouseProjectState(projectRef: string | undefined) {
|
||||
const snap = useSnapshot(warehouseDemoStore)
|
||||
if (!projectRef) return { ...DEFAULT_STATE }
|
||||
return snap.projects[projectRef] ?? { ...DEFAULT_STATE }
|
||||
const raw = snap.projects[projectRef]
|
||||
if (!raw) return { ...DEFAULT_STATE }
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...raw,
|
||||
pipelineStatus:
|
||||
raw.pipelineStatus ?? (raw.replicationPhase === 'streaming' ? 'running' : 'stopped'),
|
||||
}
|
||||
}
|
||||
|
||||
export function isWarehouseProjectEnabled(projectRef: string | undefined): boolean {
|
||||
@@ -129,6 +147,7 @@ function simulatePhaseProgression(projectRef: string): void {
|
||||
setProjectState(projectRef, {
|
||||
...backfilling,
|
||||
replicationPhase: 'streaming',
|
||||
pipelineStatus: 'running',
|
||||
lagSeconds: 2,
|
||||
})
|
||||
}, 6000)
|
||||
@@ -140,6 +159,7 @@ export function enableWarehouseProject(projectRef: string, includedSchemas: stri
|
||||
setProjectState(projectRef, {
|
||||
enabled: true,
|
||||
replicationPhase: 'provisioning',
|
||||
pipelineStatus: 'running',
|
||||
lagSeconds: null,
|
||||
includedSchemas,
|
||||
pipelineId,
|
||||
@@ -156,6 +176,45 @@ export function disableWarehouseProject(projectRef: string): void {
|
||||
setProjectState(projectRef, { ...DEFAULT_STATE })
|
||||
}
|
||||
|
||||
export function stopWarehousePipeline(projectRef: string): void {
|
||||
const current = getProjectState(projectRef)
|
||||
if (!current.enabled || current.replicationPhase !== 'streaming') return
|
||||
setProjectState(projectRef, { ...current, pipelineStatus: 'stopped' })
|
||||
}
|
||||
|
||||
export function startWarehousePipeline(projectRef: string): void {
|
||||
const current = getProjectState(projectRef)
|
||||
if (!current.enabled || current.pipelineStatus !== 'stopped') return
|
||||
setProjectState(projectRef, {
|
||||
...current,
|
||||
pipelineStatus: 'running',
|
||||
lagSeconds: current.lagSeconds ?? 2,
|
||||
})
|
||||
}
|
||||
|
||||
export function restartWarehousePipeline(projectRef: string): void {
|
||||
const current = getProjectState(projectRef)
|
||||
if (!current.enabled) return
|
||||
clearPhaseTimer()
|
||||
setProjectState(projectRef, {
|
||||
...current,
|
||||
pipelineStatus: 'running',
|
||||
replicationPhase: 'backfilling',
|
||||
lagSeconds: 30,
|
||||
})
|
||||
|
||||
phaseTimer = setTimeout(() => {
|
||||
const state = getProjectState(projectRef)
|
||||
if (!state.enabled || state.replicationPhase !== 'backfilling') return
|
||||
setProjectState(projectRef, {
|
||||
...state,
|
||||
replicationPhase: 'streaming',
|
||||
pipelineStatus: 'running',
|
||||
lagSeconds: 2,
|
||||
})
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
export function updateWarehouseIncludedSchemas(
|
||||
projectRef: string,
|
||||
includedSchemas: string[]
|
||||
@@ -178,6 +237,7 @@ export function updateWarehouseIncludedSchemas(
|
||||
setProjectState(projectRef, {
|
||||
...state,
|
||||
replicationPhase: 'streaming',
|
||||
pipelineStatus: 'running',
|
||||
lagSeconds: 2,
|
||||
})
|
||||
}, 4000)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ReplicationPhase, WarehouseProjectState } from './warehouseDemoStore'
|
||||
import type {
|
||||
ReplicationPhase,
|
||||
WarehousePipelineStatus,
|
||||
WarehouseProjectState,
|
||||
} from './warehouseDemoStore'
|
||||
|
||||
export function formatReplicationPhase(phase: ReplicationPhase): string {
|
||||
switch (phase) {
|
||||
@@ -25,7 +29,11 @@ export function formatReplicationLagSeconds(lagSeconds: number | null): string {
|
||||
return `${(lagSeconds / 3600).toFixed(1)}h`
|
||||
}
|
||||
|
||||
export function getWarehouseDestinationStatusLabel(phase: ReplicationPhase): string {
|
||||
export function getWarehouseDestinationStatusLabel(
|
||||
phase: ReplicationPhase,
|
||||
pipelineStatus: WarehousePipelineStatus = 'running'
|
||||
): string {
|
||||
if (pipelineStatus === 'stopped' && phase === 'streaming') return 'Stopped'
|
||||
switch (phase) {
|
||||
case 'provisioning':
|
||||
return 'Starting'
|
||||
@@ -58,5 +66,10 @@ export function buildWarehouseConnectionString({
|
||||
}
|
||||
|
||||
export function isWarehouseReplicationHealthy(state: WarehouseProjectState): boolean {
|
||||
return state.enabled && state.replicationPhase === 'streaming' && (state.lagSeconds ?? 0) < 120
|
||||
return (
|
||||
state.enabled &&
|
||||
state.replicationPhase === 'streaming' &&
|
||||
state.pipelineStatus === 'running' &&
|
||||
(state.lagSeconds ?? 0) < 120
|
||||
)
|
||||
}
|
||||
@@ -4,18 +4,29 @@ import { useContext, useEffect } from 'react'
|
||||
|
||||
import { ReplicationPipelineStatus } from '@/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus'
|
||||
import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import {
|
||||
isWarehouseMockPipelineId,
|
||||
WarehouseReplicationPipelineStatus,
|
||||
} from '@/components/interfaces/Database/Warehouse/WarehouseReplicationPipelineStatus'
|
||||
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 { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const pipelineId = router.query.pipelineId as string | undefined
|
||||
const { hasLoaded } = useContext(FeatureFlagContext)
|
||||
const enablePgReplicate = useIsETLPrivateAlpha()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
const isWarehouseMockPipeline =
|
||||
isWarehouseFeatureEnabled && isWarehouseMockPipelineId(projectRef, pipelineId, warehouseState)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && !enablePgReplicate) {
|
||||
@@ -31,7 +42,11 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<FormHeader title="Replication" />
|
||||
<ReplicationPipelineStatus />
|
||||
{isWarehouseMockPipeline ? (
|
||||
<WarehouseReplicationPipelineStatus />
|
||||
) : (
|
||||
<ReplicationPipelineStatus />
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { Destinations } from '@/components/interfaces/Database/Replication/Destinations'
|
||||
import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram'
|
||||
import { WarehouseProjectCard } from '@/components/interfaces/Database/Warehouse/WarehouseProjectCard'
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
|
||||
@@ -43,8 +42,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
<h3 className="text-foreground text-xl prose">Replication</h3>
|
||||
</div>
|
||||
<p className="prose text-sm max-w-full">
|
||||
Deploy Read Replicas across multiple regions, or use Pipelines to replicate database
|
||||
changes to analytics destinations.
|
||||
Replicate to another region, an analytics destination, or Warehouse.
|
||||
</p>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
@@ -56,11 +54,6 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
</ScaffoldContainer>
|
||||
) : (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<WarehouseProjectCard />
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
<ReplicationDiagram />
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth className="pt-6!">
|
||||
|
||||
Reference in new issue
Block a user