Skip to content

Commit 0910908

Browse files
joshenlimdnywh
andauthored
Swap analytics buckets endpoint (supabase#39610)
* Swap analytics buckets endpoint * Fix type issues * fix * Update API types * Fix tests * nit * Final fixes * Fix * chore(studio): analytics buckets contents (supabase#39701) * first commit * improvements * misc * cleanup * remove tappable whole * design updates * analytics improvements * polish * add clashed case * fix pairing status * connect tables basics * add multiselect * restore prod version for non-feature preview users * fix padding inconsistency * terminology fix * better empty state * fix terminology * block table connection dialog button * Nit refactors * minor nits * Add comment --------- Co-authored-by: Joshen Lim <[email protected]> * Default circumstance to fresh in ImportForeignSchemaDialog * Hook up connecting tables for analytics buckets (supabase#39906) * Hook up connecting tables for analytics buckets * Address comments * Clean up iceberg wrapper when deleting analytics bucket (supabase#39902) * Clean up iceberg wrapper when deleting analytics bucket * Clean up s3 access key when deleting analytics bucket * Make connect table disabled for now * Most clean up and refactoring * Refactor RQ * Nit * Refactor * nit --------- Co-authored-by: Danny White <[email protected]>
1 parent 72d50d8 commit 0910908

39 files changed

+2103
-336
lines changed

apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Datadog, Grafana, Sentry } from 'icons'
21
import { components } from 'api-types'
2+
import { Datadog, Grafana, Sentry } from 'icons'
33
import { BracesIcon } from 'lucide-react'
44

55
const iconProps = {
@@ -8,6 +8,8 @@ const iconProps = {
88
className: 'text-foreground-light',
99
}
1010

11+
export type LogDrainType = components['schemas']['CreateBackendParamsOpenapi']['type']
12+
1113
export const LOG_DRAIN_TYPES = [
1214
{
1315
value: 'webhook',
@@ -39,14 +41,6 @@ export const LOG_DRAIN_TYPES = [
3941

4042
export const LOG_DRAIN_SOURCE_VALUES = LOG_DRAIN_TYPES.map((source) => source.value)
4143

42-
// export type LogDrainType =
43-
// | (typeof LOG_DRAIN_TYPES)[number]['value']
44-
// | 'postgres'
45-
// | 'bigquery'
46-
// | 'elastic'
47-
48-
export type LogDrainType = components['schemas']['LFBackend']['type']
49-
5044
export const DATADOG_REGIONS = [
5145
{
5246
label: 'AP1',
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { snakeCase } from 'lodash'
2+
3+
export const getAnalyticsBucketPublicationName = (bucketId: string) => {
4+
return `analytics_${snakeCase(bucketId)}_publication`
5+
}
6+
7+
export const getAnalyticsBucketS3KeyName = (bucketId: string) => {
8+
return `${snakeCase(bucketId)}_keys`
9+
}
10+
11+
export const getAnalyticsBucketFDWName = (bucketId: string) => {
12+
return `${snakeCase(bucketId)}_fdw`
13+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { zodResolver } from '@hookform/resolvers/zod'
2+
import { Plus } from 'lucide-react'
3+
import { useState } from 'react'
4+
import { SubmitHandler, useForm } from 'react-hook-form'
5+
import { toast } from 'sonner'
6+
import z from 'zod'
7+
8+
import { useParams } from 'common'
9+
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
10+
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
11+
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
12+
import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation'
13+
import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation'
14+
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
15+
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
16+
import { AnalyticsBucket } from 'data/storage/analytics-buckets-query'
17+
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
18+
import { useTablesQuery } from 'data/tables/tables-query'
19+
import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
20+
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
21+
import { snakeCase } from 'lodash'
22+
import {
23+
Button,
24+
Dialog,
25+
DialogContent,
26+
DialogFooter,
27+
DialogHeader,
28+
DialogSection,
29+
DialogSectionSeparator,
30+
DialogTitle,
31+
DialogTrigger,
32+
Form_Shadcn_,
33+
FormControl_Shadcn_,
34+
FormField_Shadcn_,
35+
} from 'ui'
36+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
37+
import { MultiSelector } from 'ui-patterns/multi-select'
38+
import { convertKVStringArrayToJson } from '../../Integrations/Wrappers/Wrappers.utils'
39+
import { getCatalogURI } from '../StorageSettings/StorageSettings.utils'
40+
import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils'
41+
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
42+
43+
/**
44+
* [Joshen] So far this is purely just setting up a "Connect from empty state" flow
45+
* Doing it bit by bit as this is quite an unknown territory, will adjust as we figure out
46+
* limitations, correctness, etc, etc. ETL is also only available on staging so its quite hard
47+
* to test things locally (Local set up is technically available but quite high friction)
48+
*
49+
* What's missing afaict:
50+
* - Deleting namespaces
51+
* - Removing tables
52+
* - Adding more tables
53+
* - Error handling due to multiple async processes
54+
*/
55+
56+
const FormSchema = z.object({
57+
tables: z.array(z.string()).min(1, 'At least one table is required'),
58+
})
59+
60+
const formId = 'connect-tables-form'
61+
const isEnabled = false // Kill switch if we wanna hold off supporting connecting tables
62+
63+
type ConnectTablesForm = z.infer<typeof FormSchema>
64+
65+
export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) => {
66+
const form = useForm<ConnectTablesForm>({
67+
resolver: zodResolver(FormSchema),
68+
defaultValues: { tables: [] },
69+
})
70+
71+
const [visible, setVisible] = useState(false)
72+
const { ref: projectRef } = useParams()
73+
const { data: project } = useSelectedProjectQuery()
74+
75+
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucket.id })
76+
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
77+
78+
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
79+
const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
80+
const { serviceKey } = getKeys(apiKeys)
81+
82+
const { data: tables } = useTablesQuery({
83+
projectRef,
84+
connectionString: project?.connectionString,
85+
includeColumns: false,
86+
})
87+
88+
const { data: sourcesData } = useReplicationSourcesQuery({ projectRef })
89+
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
90+
91+
const { mutateAsync: createNamespace, isLoading: isCreatingNamespace } =
92+
useIcebergNamespaceCreateMutation()
93+
94+
const { mutateAsync: createPublication, isLoading: isCreatingPublication } =
95+
useCreatePublicationMutation()
96+
97+
const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } =
98+
useCreateDestinationPipelineMutation({
99+
onSuccess: () => {},
100+
})
101+
102+
const { mutateAsync: startPipeline } = useStartPipelineMutation()
103+
104+
const isConnecting = isCreatingNamespace || creatingDestinationPipeline || isCreatingPublication
105+
106+
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
107+
// [Joshen] Currently creates the destination for the analytics bucket here
108+
// Which also involves creating a namespace + publication
109+
// Publication name is automatically generated as {bucket.id}_publication
110+
// Destination name is automatically generated as {bucket.id}_destination
111+
112+
if (!projectRef) return console.error('Project ref is required')
113+
if (!sourceId) return toast.error('Source ID is required')
114+
115+
try {
116+
const publicationName = getAnalyticsBucketPublicationName(bucket.id)
117+
await createPublication({
118+
projectRef,
119+
sourceId,
120+
name: publicationName,
121+
tables: values.tables.map((table) => {
122+
const [schema, name] = table.split('.')
123+
return { schema, name }
124+
}),
125+
})
126+
127+
const keysToDecrypt = Object.entries(wrapperValues)
128+
.filter(([name]) =>
129+
['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name)
130+
)
131+
.map(([_, keyId]) => keyId)
132+
const decryptedValues = await getDecryptedValues({
133+
projectRef,
134+
connectionString: project?.connectionString,
135+
ids: keysToDecrypt,
136+
})
137+
138+
const warehouseName = bucket.id
139+
const catalogToken = serviceKey?.api_key ?? ''
140+
const s3AccessKeyId = decryptedValues[wrapperValues['vault_aws_access_key_id']]
141+
const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']]
142+
const s3Region = projectSettings?.region ?? ''
143+
144+
const protocol = projectSettings?.app_config?.protocol ?? 'https'
145+
const endpoint =
146+
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
147+
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
148+
const namespace = `${bucket.id}_namespace`
149+
await createNamespace({
150+
catalogUri,
151+
warehouse: warehouseName,
152+
token: catalogToken,
153+
namespace,
154+
})
155+
156+
const icebergConfiguration = {
157+
projectRef,
158+
warehouseName,
159+
namespace,
160+
catalogToken,
161+
s3AccessKeyId,
162+
s3SecretAccessKey,
163+
s3Region,
164+
}
165+
const destinationName = `${snakeCase(bucket.id)}_destination`
166+
167+
const { pipeline_id: pipelineId } = await createDestinationPipeline({
168+
projectRef,
169+
destinationName,
170+
destinationConfig: { iceberg: icebergConfiguration },
171+
sourceId,
172+
pipelineConfig: { publicationName },
173+
})
174+
175+
// Pipeline can start behind the scenes, don't need to await
176+
startPipeline({ projectRef, pipelineId })
177+
toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`)
178+
form.reset()
179+
setVisible(false)
180+
} catch (error: any) {
181+
// [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly
182+
// e.g publication gets created, but namespace creation fails -> should the old publication get deleted?
183+
// Another question is probably whether all of these step by step logic should be at the API level instead of client level
184+
// Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA
185+
toast.error(`Failed to connect tables: ${error.message}`)
186+
}
187+
}
188+
189+
const handleClose = () => {
190+
form.reset()
191+
setVisible(false)
192+
}
193+
194+
return (
195+
<Dialog
196+
open={visible}
197+
onOpenChange={(open) => {
198+
if (!open) handleClose()
199+
}}
200+
>
201+
<DialogTrigger asChild>
202+
<ButtonTooltip
203+
disabled={!isEnabled}
204+
size="tiny"
205+
type="primary"
206+
icon={<Plus size={14} />}
207+
onClick={() => setVisible(true)}
208+
tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }}
209+
>
210+
Connect tables
211+
</ButtonTooltip>
212+
</DialogTrigger>
213+
214+
<DialogContent>
215+
<DialogHeader>
216+
<DialogTitle>Connect tables</DialogTitle>
217+
</DialogHeader>
218+
219+
<DialogSectionSeparator />
220+
221+
<Form_Shadcn_ {...form}>
222+
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
223+
<DialogSection className="flex flex-col gap-y-4">
224+
<p className="text-sm">
225+
Select the database tables to send data from. A destination analytics table will be
226+
created for each, and data will replicate automatically.
227+
</p>
228+
</DialogSection>
229+
<DialogSectionSeparator />
230+
<DialogSection className="overflow-visible">
231+
<FormField_Shadcn_
232+
control={form.control}
233+
name="tables"
234+
render={({ field }) => (
235+
<FormItemLayout label="Tables">
236+
<FormControl_Shadcn_>
237+
<MultiSelector
238+
values={field.value}
239+
onValuesChange={field.onChange}
240+
disabled={isConnecting}
241+
>
242+
<MultiSelector.Trigger label="Select tables..." badgeLimit="wrap" />
243+
<MultiSelector.Content>
244+
<MultiSelector.List>
245+
{tables?.map((table) => (
246+
<MultiSelector.Item
247+
key={`${table.schema}.${table.name}`}
248+
value={`${table.schema}.${table.name}`}
249+
>
250+
{`${table.schema}.${table.name}`}
251+
</MultiSelector.Item>
252+
))}
253+
</MultiSelector.List>
254+
</MultiSelector.Content>
255+
</MultiSelector>
256+
</FormControl_Shadcn_>
257+
</FormItemLayout>
258+
)}
259+
/>
260+
</DialogSection>
261+
</form>
262+
</Form_Shadcn_>
263+
264+
<DialogFooter>
265+
<Button type="default" disabled={isConnecting} onClick={() => setVisible(false)}>
266+
Cancel
267+
</Button>
268+
<Button form={formId} htmlType="submit" loading={isConnecting} disabled={isConnecting}>
269+
Connect
270+
</Button>
271+
</DialogFooter>
272+
</DialogContent>
273+
</Dialog>
274+
)
275+
}

apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx renamed to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/CopyEnvButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const CopyEnvButton = ({
3434
).then((values) => values.join('\n'))
3535

3636
copyToClipboard(envFile, () => {
37-
toast.success('Copied to clipboard as .env file')
37+
toast.success('Copied to clipboard as environment variables')
3838
setIsLoading(false)
3939
})
4040
}, [serverOptions, values])

0 commit comments

Comments
 (0)