Files
Joshen Lim ddd94a9c24 Joshen/fe 2660 clean up stale feature flags enabled for 2 months part 2 (#43331)
## Context

Follow up from https://github.com/supabase/supabase/pull/43329, but
mutually exclusive

Just cleaning up feature flags that have been toggled on for all users
and unchanged for the past 2 months

- edgefunctionreport
- storagereport
- realtimeReport
- postgrestreport
- authreportv2
- newEdgeFunctionOverviewCharts
- apiReportCountries (Already not used)
- SentryLogDrain
- reportGranularityV2
- storageAnalyticsVector
- ShowIndexAdvisorOnTableEditor
2026-03-04 13:25:01 +08:00

482 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useIsETLPrivateAlpha } from 'components/interfaces/Database/Replication/useIsETLPrivateAlpha'
import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation'
import { useCreateTenantSourceMutation } from 'data/replication/create-tenant-source-mutation'
import { useCreatePublicationMutation } from 'data/replication/publication-create-mutation'
import { useUpdatePublicationMutation } from 'data/replication/publication-update-mutation'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useReplicationTablesQuery } from 'data/replication/tables-query'
import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
import { AnimatePresence, motion } from 'framer-motion'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Loader2, Plus } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Progress,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import z from 'zod'
import {
getAnalyticsBucketPublicationName,
getAnalyticsBucketsDestinationName,
} from './AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
/**
* [Joshen] So far this is purely just setting up a "Connect from empty state" flow
* Doing it bit by bit as this is quite an unknown territory, will adjust as we figure out
* limitations, correctness, etc, etc. ETL is also only available on staging so its quite hard
* to test things locally (Local set up is technically available but quite high friction)
*
* What's missing afaict:
* - Deleting namespaces
* - Removing tables
* - Adding more tables
* - Error handling due to multiple async processes
*/
const formId = 'connect-tables-form'
const FormSchema = z.object({
tables: z.array(z.string()).min(1, 'Select at least one table'),
})
type ConnectTablesForm = z.infer<typeof FormSchema>
enum PROGRESS_STAGE {
CREATE_PUBLICATION = 'CREATE_PUBLICATION',
CREATE_PIPELINE = 'CREATE_PIPELINE',
START_PIPELINE = 'START_PIPELINE',
UPDATE_PUBLICATION = 'CREATE_REPLICATION',
}
const PROGRESS_INDICATORS = {
CREATE: [
{
step: PROGRESS_STAGE.CREATE_PUBLICATION,
getDescription: (numTables: number) =>
`Creating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.CREATE_PIPELINE, description: `Creating replication pipeline` },
{ step: PROGRESS_STAGE.START_PIPELINE, description: `Starting replication pipeline` },
],
UPDATE: [
{
step: PROGRESS_STAGE.UPDATE_PUBLICATION,
getDescription: (numTables: number) =>
`Updating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.START_PIPELINE, description: 'Restarting replication pipeline...' },
],
}
interface ConnectTablesDialogProps {
onSuccessConnectTables: () => void
}
/** [Joshen] This component is currently not user-facing atm, might opt to clean up as we're likely not going to use this UI flow */
export const ConnectTablesDialog = ({ onSuccessConnectTables }: ConnectTablesDialogProps) => {
const { ref: projectRef, bucketId } = useParams()
const [visible, setVisible] = useState(false)
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
return (
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger asChild>
<Button size="tiny" type="primary" icon={<Plus />} onClick={() => setVisible(true)}>
{isEditingExistingPublication ? 'Add tables' : 'Connect tables'}
</Button>
</DialogTrigger>
{!sourceId ? (
<EnableReplicationDialogContent onClose={() => setVisible(false)} />
) : (
<ConnectTablesDialogContent
visible={visible}
onClose={() => setVisible(false)}
onSuccessConnectTables={onSuccessConnectTables}
/>
)}
</Dialog>
)
}
export const ConnectTablesDialogContent = ({
visible,
onClose,
onSuccessConnectTables,
}: ConnectTablesDialogProps & { visible: boolean; onClose: () => void }) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isConnecting, setIsConnecting] = useState(false)
const [connectingStep, setConnectingStep] = useState<PROGRESS_STAGE>()
const form = useForm<ConnectTablesForm>({
resolver: zodResolver(FormSchema),
defaultValues: { tables: [] },
})
const { tables: selectedTables } = form.watch()
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId })
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef, reveal: true },
{ enabled: canReadAPIKeys }
)
const { serviceKey } = getKeys(apiKeys)
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
const { mutateAsync: createPublication } = useCreatePublicationMutation()
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: createDestinationPipeline } = useCreateDestinationPipelineMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const progressIndicator = useMemo(
() => (isEditingExistingPublication ? PROGRESS_INDICATORS.UPDATE : PROGRESS_INDICATORS.CREATE),
// [Joshen] This is to prevent the progressIndicator from flipping to UPDATE in the middle of CREATE
// since the publication and pipelines are getting created in the middle of CREATE
// eslint-disable-next-line react-hooks/exhaustive-deps
[isConnecting]
)
const totalProgressSteps = progressIndicator.length
const currentStep = progressIndicator.findIndex((x) => x.step === connectingStep) + 1
const progressDescription = progressIndicator.find((x) => x.step === connectingStep)
const onSubmitNewPublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!bucketId) return toast.error('Bucket ID is required')
if (!sourceId) return toast.error('Replication has not been enabled for your project')
try {
setIsConnecting(true)
// Step 1: Create publication
setConnectingStep(PROGRESS_STAGE.CREATE_PUBLICATION)
const publicationName = getAnalyticsBucketPublicationName(bucketId)
const publicationTables = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
await createPublication({
projectRef,
sourceId,
name: publicationName,
tables: publicationTables,
})
// Step 2: Create destination pipeline
setConnectingStep(PROGRESS_STAGE.CREATE_PIPELINE)
const keysToDecrypt = Object.entries(wrapperValues)
.filter(([name]) =>
['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name)
)
.map(([_, keyId]) => keyId)
const decryptedValues = await getDecryptedValues({
projectRef,
connectionString: project?.connectionString,
ids: keysToDecrypt,
})
const warehouseName = bucketId
const catalogToken = serviceKey?.api_key ?? ''
const s3AccessKeyId = decryptedValues[wrapperValues['vault_aws_access_key_id']]
const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']]
const s3Region = projectSettings?.region ?? ''
const icebergConfiguration = {
projectRef,
warehouseName,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
}
const destinationName = getAnalyticsBucketsDestinationName(bucketId)
const { pipeline_id: pipelineId } = await createDestinationPipeline({
projectRef,
destinationName,
destinationConfig: { iceberg: icebergConfiguration },
sourceId,
pipelineConfig: { publicationName },
})
// Step 3: Start the destination pipeline
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId })
onSuccessConnectTables?.()
toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`)
form.reset()
onClose()
} catch (error: any) {
// [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly
// e.g publication gets created, but namespace creation fails -> should the old publication get deleted?
// Another question is probably whether all of these step by step logic should be at the API level instead of client level
// Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA
toast.error(`Failed to connect tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const onSubmitUpdatePublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return toast.error('Replication has not been enabled on this project')
if (!bucketId) return toast.error('Bucket ID is required')
if (!publication) return toast.error('Unable to find existing publication')
if (!pipeline) return toast.error('Unable to find existing pipeline')
try {
setIsConnecting(true)
const tablesToBeAdded = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
setConnectingStep(PROGRESS_STAGE.UPDATE_PUBLICATION)
const publicationTables = publication.tables.concat(tablesToBeAdded)
await updatePublication({
projectRef,
sourceId,
publicationName: publication.name,
tables: publicationTables,
})
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId: pipeline.id })
onSuccessConnectTables?.()
toast.success('Successfully updated connected tables! Pipeline is being restarted')
onClose()
} catch (error: any) {
toast.error(`Failed to update tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
if (isEditingExistingPublication) {
onSubmitUpdatePublication(values)
} else {
onSubmitNewPublication(values)
}
}
useEffect(() => {
form.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
return (
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'}
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
Select the database tables to send data from. A destination analytics table will be
created for each, and data will replicate automatically.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="overflow-visible">
<FormField_Shadcn_
control={form.control}
name="tables"
render={({ field }) => (
<FormItemLayout label="Tables">
<FormControl_Shadcn_>
<MultiSelector
values={field.value}
onValuesChange={field.onChange}
disabled={isConnecting}
>
<MultiSelector.Trigger
deletableBadge
badgeLimit="wrap"
mode="inline-combobox"
label="Select tables..."
/>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.map((table) => {
const alreadyConnected = (publication?.tables ?? []).some(
(x) => x.schema === table.schema && x.name === table.name
)
return (
<MultiSelector.Item
disabled={alreadyConnected}
className="[&>div]:flex [&>div]:items-center [&>div]:justify-between"
key={`${table.schema}.${table.name}`}
value={`${table.schema}.${table.name}`}
>
<span>{`${table.schema}.${table.name}`}</span>
{alreadyConnected && <span>Connected to analytics bucket</span>}
</MultiSelector.Item>
)
})}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
</form>
</Form_Shadcn_>
<AnimatePresence mode="wait">
{isConnecting && !!progressDescription && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<DialogSectionSeparator />
<DialogSection>
<div className="flex items-center gap-x-2 mb-2">
<p className="text-sm text-foreground-light">
{'getDescription' in progressDescription
? progressDescription.getDescription?.(selectedTables.length)
: progressDescription.description}
</p>
<Loader2 size={14} className="animate-spin" />
</div>
<Progress value={(currentStep / (totalProgressSteps + 1)) * 100} />
</DialogSection>
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<Button type="default" disabled={isConnecting} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" disabled={isConnecting}>
Connect
</Button>
</DialogFooter>
</DialogContent>
)
}
const EnableReplicationDialogContent = ({ onClose }: { onClose: () => void }) => {
const { ref: projectRef } = useParams()
const enablePgReplicate = useIsETLPrivateAlpha()
const { error } = useReplicationSourcesQuery({ projectRef })
const noAccessToReplication =
!enablePgReplicate || error?.message.includes('feature flag is required')
const { mutateAsync: createTenantSource, isPending: creatingTenantSource } =
useCreateTenantSourceMutation()
const onEnableReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
await createTenantSource({ projectRef })
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Database replication needs to be enabled</DialogTitle>
<DialogDescription>
Replication is used to sync data from your Postgres tables
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2 !p-0">
<Admonition
type="warning"
className="rounded-none border-0"
title={
noAccessToReplication
? 'Replication is currently unavailable for your project'
: 'Replication is currently in Alpha'
}
>
{noAccessToReplication ? (
<p className="text-sm !leading-normal">
Access to database replication is currently not available yet for public use. If
you're interested, do reach out to us via support!
</p>
) : (
<>
<p className="text-sm !leading-normal">
This feature is in active development and may change as we gather feedback.
Availability and behavior can evolve while in Alpha.
</p>
<p className="text-sm !leading-normal">
Pricing has not been finalized yet. You can enable replication now; we’ll announce
pricing later and notify you before any charges apply.
</p>
</>
)}
</Admonition>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={creatingTenantSource} onClick={() => onClose()}>
{noAccessToReplication ? 'Understood' : 'Cancel'}
</Button>
{!noAccessToReplication && (
<Button type="primary" loading={creatingTenantSource} onClick={onEnableReplication}>
Enable replication
</Button>
)}
</DialogFooter>
</DialogContent>
)
}