CreateIcebergWrapperSheet.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. // @ts-nocheck
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { useEffect, useRef } from 'react'
  4. import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
  5. import { toast } from 'sonner'
  6. import {
  7. Button,
  8. Card,
  9. CardContent,
  10. Form,
  11. FormControl,
  12. FormField,
  13. Input,
  14. RadioGroupStacked,
  15. RadioGroupStackedItem,
  16. SheetFooter,
  17. SheetHeader,
  18. SheetSection,
  19. SheetTitle,
  20. } from 'ui'
  21. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  22. import {
  23. PageSection,
  24. PageSectionContent,
  25. PageSectionDescription,
  26. PageSectionMeta,
  27. PageSectionSummary,
  28. PageSectionTitle,
  29. } from 'ui-patterns/PageSection'
  30. import * as z from 'zod'
  31. import { CreateWrapperSheetProps } from './CreateWrapperSheet'
  32. import InputField from './InputField'
  33. import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation'
  34. import { useSchemasQuery } from '@/data/database/schemas-query'
  35. import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
  36. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  37. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  38. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  39. const FORM_ID = 'create-wrapper-form'
  40. const S3TableSchema = z.object({
  41. target: z.literal('S3Tables'),
  42. source_schema: z.string().min(1, 'Please provide a namespace name'),
  43. wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
  44. target_schema: z.string().min(1, 'Please provide an unique target schema'),
  45. vault_aws_access_key_id: z.string().min(1, 'Required'),
  46. vault_aws_secret_access_key: z.string().min(1, 'Required'),
  47. region_name: z.string().min(1, 'Required'),
  48. vault_aws_s3table_bucket_arn: z.string().min(1, 'Required'),
  49. })
  50. const R2CatalogSchema = z.object({
  51. target: z.literal('R2Catalog'),
  52. source_schema: z.string().min(1, 'Please provide a namespace name'),
  53. wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
  54. target_schema: z.string().min(1, 'Please provide an unique target schema'),
  55. vault_aws_access_key_id: z.string().min(1, 'Required'),
  56. vault_aws_secret_access_key: z.string().min(1, 'Required'),
  57. vault_token: z.string().min(1, 'Required'),
  58. warehouse: z.string().min(1, 'Required'),
  59. s3: z.object({ endpoint: z.string().min(1, 'Required') }),
  60. catalog_uri: z.string().min(1, 'Required'),
  61. })
  62. const IcebergRestCatalogSchema = z.object({
  63. target: z.literal('IcebergRestCatalog'),
  64. source_schema: z.string().min(1, 'Please provide a namespace name'),
  65. wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
  66. target_schema: z.string().min(1, 'Please provide an unique target schema'),
  67. vault_aws_access_key_id: z.string().optional(),
  68. vault_aws_secret_access_key: z.string().optional(),
  69. region_name: z.string().optional(),
  70. vault_aws_s3table_bucket_arn: z.string().optional(),
  71. vault_token: z.string().optional(),
  72. warehouse: z.string().optional(),
  73. s3: z.object({ endpoint: z.string().min(1, 'Required') }),
  74. catalog_uri: z.string().optional(),
  75. })
  76. const formSchema = z.discriminatedUnion('target', [
  77. S3TableSchema,
  78. R2CatalogSchema,
  79. IcebergRestCatalogSchema,
  80. ])
  81. type FormSchema = z.infer<typeof formSchema>
  82. const targetFields: Record<Target, { name: string; required: boolean }[]> = {
  83. S3Tables: [
  84. { name: 'vault_aws_access_key_id', required: true },
  85. { name: 'vault_aws_secret_access_key', required: true },
  86. { name: 'region_name', required: true },
  87. { name: 'vault_aws_s3table_bucket_arn', required: true },
  88. ],
  89. R2Catalog: [
  90. { name: 'vault_aws_access_key_id', required: true },
  91. { name: 'vault_aws_secret_access_key', required: true },
  92. { name: 'vault_token', required: true },
  93. { name: 'warehouse', required: true },
  94. { name: 's3.endpoint', required: true },
  95. { name: 'catalog_uri', required: true },
  96. ],
  97. IcebergRestCatalog: [
  98. { name: 'vault_aws_access_key_id', required: false },
  99. { name: 'vault_aws_secret_access_key', required: false },
  100. { name: 'region_name', required: false },
  101. { name: 'vault_aws_s3table_bucket_arn', required: false },
  102. { name: 'vault_token', required: false },
  103. { name: 'warehouse', required: false },
  104. { name: 's3.endpoint', required: false },
  105. { name: 'catalog_uri', required: false },
  106. ],
  107. } as const
  108. type Target = 'S3Tables' | 'R2Catalog' | 'IcebergRestCatalog'
  109. const INITIAL_VALUES = {
  110. wrapper_name: '',
  111. source_schema: '',
  112. target_schema: '',
  113. target: 'S3Tables',
  114. vault_aws_access_key_id: '',
  115. vault_aws_s3table_bucket_arn: '',
  116. vault_aws_secret_access_key: '',
  117. region_name: '',
  118. } satisfies FormSchema
  119. export const CreateIcebergWrapperSheet = ({
  120. wrapperMeta,
  121. onDirty,
  122. onClose,
  123. onCloseWithConfirmation,
  124. }: CreateWrapperSheetProps) => {
  125. const { data: project } = useSelectedProjectQuery()
  126. const { data: org } = useSelectedOrganizationQuery()
  127. const { mutate: sendEvent } = useSendEventMutation()
  128. const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
  129. onSuccess: () => {
  130. toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
  131. onClose()
  132. },
  133. })
  134. const { data: schemas } = useSchemasQuery({
  135. projectRef: project?.ref!,
  136. connectionString: project?.connectionString,
  137. })
  138. const { mutateAsync: createSchema } = useSchemaCreateMutation()
  139. const form = useForm<FormSchema>({
  140. resolver: zodResolver(formSchema as any),
  141. defaultValues: INITIAL_VALUES,
  142. })
  143. const { resetField, formState, setError, watch } = form
  144. const { isDirty, isSubmitting } = formState
  145. useEffect(() => {
  146. onDirty(isDirty)
  147. }, [onDirty, isDirty])
  148. const currentTarget = useRef<FormSchema['target']>(INITIAL_VALUES.target)
  149. useEffect(() => {
  150. const subscription = watch((values) => {
  151. if (!values.target || values.target === currentTarget.current) return
  152. currentTarget.current = values.target
  153. const fields = targetFields[values.target]
  154. if (!fields) return
  155. wrapperMeta.server.options.forEach((option) => {
  156. // @ts-expect-error Can't reconcile with form schema
  157. resetField(option.name, { defaultValue: option.defaultValue ?? '' })
  158. })
  159. })
  160. return () => subscription.unsubscribe()
  161. }, [resetField, watch, wrapperMeta])
  162. const onSubmit: SubmitHandler<FormSchema> = async (values) => {
  163. const foundSchema = schemas?.find((s) => s.name === values.target_schema)
  164. if (foundSchema) {
  165. setError('target_schema', {
  166. type: 'validate',
  167. message: 'This schema already exists. Please specify a unique schema name.',
  168. })
  169. return
  170. }
  171. let formValues: Record<string, string> = {}
  172. if (values.target === 'R2Catalog' || values.target === 'IcebergRestCatalog') {
  173. const { s3, ...otherFormValues } = values
  174. formValues = otherFormValues
  175. formValues['s3.endpoint'] = s3.endpoint
  176. } else {
  177. formValues = values
  178. }
  179. try {
  180. await createSchema({
  181. projectRef: project?.ref,
  182. connectionString: project?.connectionString,
  183. name: values.target_schema,
  184. })
  185. await createFDW({
  186. projectRef: project?.ref,
  187. connectionString: project?.connectionString,
  188. wrapperMeta,
  189. formState: {
  190. ...formValues,
  191. server_name: `${values.wrapper_name}_server`,
  192. briven_target_schema: values.target_schema,
  193. },
  194. mode: 'schema',
  195. tables: [],
  196. sourceSchema: values.source_schema,
  197. targetSchema: values.target_schema,
  198. })
  199. sendEvent({
  200. action: 'foreign_data_wrapper_created',
  201. properties: {
  202. wrapperType: wrapperMeta.label,
  203. },
  204. groups: {
  205. project: project?.ref ?? 'Unknown',
  206. organization: org?.slug ?? 'Unknown',
  207. },
  208. })
  209. } catch (error) {
  210. console.error(error)
  211. // The error will be handled by the mutation onError callback (toast.error)
  212. }
  213. }
  214. const isLoading = isCreatingWrapper || isSubmitting
  215. const wrapperName = useWatch({ name: 'wrapper_name', control: form.control })
  216. const target = useWatch({ name: 'target', control: form.control })
  217. const targetOptions = wrapperMeta.server.options
  218. .filter((option) => targetFields[target].find((field) => field.name === option.name))
  219. .map((option) => {
  220. return {
  221. ...option,
  222. required: !!targetFields[target].find((field) => field.name === option.name)?.required,
  223. }
  224. })
  225. return (
  226. <>
  227. <div className="h-full" tabIndex={-1}>
  228. <Form {...form}>
  229. <form
  230. id={FORM_ID}
  231. onSubmit={form.handleSubmit(onSubmit)}
  232. className="flex flex-col h-full"
  233. >
  234. <SheetHeader>
  235. <SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
  236. </SheetHeader>
  237. <SheetSection className="grow overflow-y-auto">
  238. <PageSection>
  239. <PageSectionMeta>
  240. <PageSectionSummary>
  241. <PageSectionTitle>Wrapper Configuration</PageSectionTitle>
  242. </PageSectionSummary>
  243. </PageSectionMeta>
  244. <PageSectionContent>
  245. <Card>
  246. <CardContent>
  247. <FormField
  248. control={form.control}
  249. name="wrapper_name"
  250. render={({ field }) => (
  251. <FormItemLayout
  252. layout="horizontal"
  253. label="Wrapper Name"
  254. description={
  255. wrapperName.length > 0 ? (
  256. <>
  257. Your wrapper's server name will be{' '}
  258. <code className="text-code-inline">{wrapperName}_server</code>
  259. </>
  260. ) : (
  261. ''
  262. )
  263. }
  264. >
  265. <FormControl>
  266. <Input {...field} />
  267. </FormControl>
  268. </FormItemLayout>
  269. )}
  270. />
  271. </CardContent>
  272. </Card>
  273. </PageSectionContent>
  274. </PageSection>
  275. <PageSection>
  276. <PageSectionMeta>
  277. <PageSectionSummary>
  278. <PageSectionTitle>Data target</PageSectionTitle>
  279. </PageSectionSummary>
  280. </PageSectionMeta>
  281. <PageSectionContent>
  282. <Card>
  283. <CardContent>
  284. <FormField
  285. control={form.control}
  286. name="target"
  287. render={({ field }) => (
  288. <FormItemLayout layout="vertical">
  289. <div>
  290. <RadioGroupStacked value={field.value} onValueChange={field.onChange}>
  291. <RadioGroupStackedItem
  292. key="S3Tables"
  293. value="S3Tables"
  294. label="AWS S3 Tables"
  295. showIndicator={false}
  296. >
  297. <div className="flex gap-x-5">
  298. <div className="flex flex-col">
  299. <p className="text-foreground-light text-left">
  300. AWS S3 storage that's optimized for analytics workloads.
  301. </p>
  302. </div>
  303. </div>
  304. </RadioGroupStackedItem>
  305. <RadioGroupStackedItem
  306. key="R2Catalog"
  307. value="R2Catalog"
  308. label="Cloudflare R2 Catalog"
  309. showIndicator={false}
  310. >
  311. <div className="flex gap-x-5">
  312. <div className="flex flex-col">
  313. <p className="text-foreground-light text-left">
  314. Managed Apache Iceberg built directly into your R2 bucket.
  315. </p>
  316. </div>
  317. </div>
  318. </RadioGroupStackedItem>
  319. <RadioGroupStackedItem
  320. key="IcebergRestCatalog"
  321. value="IcebergRestCatalog"
  322. label="Iceberg REST Catalog"
  323. showIndicator={false}
  324. >
  325. <div className="flex gap-x-5">
  326. <div className="flex flex-col">
  327. <p className="text-foreground-light text-left">
  328. Can be used with any S3-compatible storage.
  329. </p>
  330. </div>
  331. </div>
  332. </RadioGroupStackedItem>
  333. </RadioGroupStacked>
  334. </div>
  335. </FormItemLayout>
  336. )}
  337. />
  338. </CardContent>
  339. </Card>
  340. </PageSectionContent>
  341. </PageSection>
  342. <PageSection>
  343. <PageSectionMeta>
  344. <PageSectionSummary>
  345. <PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
  346. </PageSectionSummary>
  347. </PageSectionMeta>
  348. <PageSectionContent>
  349. <Card>
  350. {targetOptions.map((option) =>
  351. option.hidden ? (
  352. <input
  353. key={`${option.name}-${option.required}-${option.hidden}`}
  354. type="hidden"
  355. // @ts-expect-error Can't reconcile with form schema
  356. {...form.register(option.name)}
  357. />
  358. ) : (
  359. <CardContent key={`${option.name}-${option.required}-${option.hidden}`}>
  360. <InputField control={form.control} option={option} />
  361. </CardContent>
  362. )
  363. )}
  364. </Card>
  365. </PageSectionContent>
  366. </PageSection>
  367. <PageSection>
  368. <PageSectionMeta>
  369. <PageSectionSummary>
  370. <PageSectionTitle>Foreign Schema</PageSectionTitle>
  371. <PageSectionDescription>
  372. You can query your data from the foreign tables in the specified schema after
  373. the wrapper is created.
  374. </PageSectionDescription>
  375. </PageSectionSummary>
  376. </PageSectionMeta>
  377. <PageSectionContent>
  378. <Card>
  379. <CardContent>
  380. {wrapperMeta.sourceSchemaOption && (
  381. <InputField
  382. control={form.control}
  383. option={wrapperMeta.sourceSchemaOption}
  384. />
  385. )}
  386. </CardContent>
  387. <CardContent>
  388. <InputField
  389. control={form.control}
  390. option={{
  391. name: 'target_schema',
  392. label: 'Specify a new schema to create all wrapper tables in',
  393. description:
  394. 'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
  395. required: true,
  396. encrypted: false,
  397. secureEntry: false,
  398. }}
  399. />
  400. </CardContent>
  401. </Card>
  402. </PageSectionContent>
  403. </PageSection>
  404. </SheetSection>
  405. <SheetFooter>
  406. <Button
  407. size="tiny"
  408. type="default"
  409. htmlType="button"
  410. onClick={onCloseWithConfirmation}
  411. disabled={isLoading}
  412. >
  413. Cancel
  414. </Button>
  415. <Button
  416. size="tiny"
  417. type="primary"
  418. form={FORM_ID}
  419. htmlType="submit"
  420. loading={isLoading}
  421. disabled={isLoading || !isDirty}
  422. >
  423. Create wrapper
  424. </Button>
  425. </SheetFooter>
  426. </form>
  427. </Form>
  428. </div>
  429. </>
  430. )
  431. }