From b181cff0316335b3e707c84463a8bf4c34eacb7e Mon Sep 17 00:00:00 2001 From: Amogh Bharadwaj Date: Wed, 4 Oct 2023 21:49:35 +0530 Subject: [PATCH] UI for Snowflake Peer (#474) fixes: https://github.com/PeerDB-io/peerdb/issues/431 --- ui/app/api/peers/route.ts | 13 ++- .../peers/create/configuration/configForm.tsx | 40 -------- ui/app/peers/create/configuration/handlers.ts | 6 +- .../create/configuration/helpers/common.ts | 16 +++- .../peers/create/configuration/helpers/pg.ts | 31 ++++-- .../peers/create/configuration/helpers/sf.ts | 95 +++++++++++++++++++ ui/app/peers/create/configuration/page.tsx | 41 ++++++-- ui/app/peers/create/configuration/schema.ts | 64 +++++++++++++ ui/app/peers/create/configuration/types.ts | 4 +- ui/components/ConfigForm.tsx | 92 ++++++++++++++++++ ui/components/InfoPopover.tsx | 53 +++++++++++ ui/components/SelectSource.tsx | 3 +- ui/package.json | 1 + ui/yarn.lock | 69 ++++++++++++++ 14 files changed, 464 insertions(+), 64 deletions(-) delete mode 100644 ui/app/peers/create/configuration/configForm.tsx create mode 100644 ui/app/peers/create/configuration/helpers/sf.ts create mode 100644 ui/components/ConfigForm.tsx create mode 100644 ui/components/InfoPopover.tsx diff --git a/ui/app/api/peers/route.ts b/ui/app/api/peers/route.ts index 3fbba1b902..808187624d 100644 --- a/ui/app/api/peers/route.ts +++ b/ui/app/api/peers/route.ts @@ -1,5 +1,10 @@ import { PeerConfig } from '@/app/peers/create/configuration/types'; -import { DBType, Peer, PostgresConfig } from '@/grpc_generated/peers'; +import { + DBType, + Peer, + PostgresConfig, + SnowflakeConfig, +} from '@/grpc_generated/peers'; import { CreatePeerRequest, CreatePeerResponse, @@ -22,6 +27,12 @@ const constructPeer = ( type: DBType.POSTGRES, postgresConfig: config as PostgresConfig, }; + case 'SNOWFLAKE': + return { + name, + type: DBType.SNOWFLAKE, + snowflakeConfig: config as SnowflakeConfig, + }; default: return; } diff --git a/ui/app/peers/create/configuration/configForm.tsx b/ui/app/peers/create/configuration/configForm.tsx deleted file mode 100644 index de47e1764d..0000000000 --- a/ui/app/peers/create/configuration/configForm.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; -import { Label } from '@/lib/Label'; -import { RowWithTextField } from '@/lib/Layout'; -import { TextField } from '@/lib/TextField'; -import { PeerSetter } from './types'; - -interface Setting { - label: string; - stateHandler: (value: string, setter: PeerSetter) => void; - type?: string; -} - -interface ConfigProps { - settings: Setting[]; - setter: PeerSetter; -} - -export default function ConfigForm(props: ConfigProps) { - return ( - <> - {props.settings.map((setting, id) => { - return ( - {setting.label}} - action={ - - setting.stateHandler(e.target.value, props.setter) - } - /> - } - /> - ); - })} - - ); -} diff --git a/ui/app/peers/create/configuration/handlers.ts b/ui/app/peers/create/configuration/handlers.ts index 133c62d6e5..80ab6688f2 100644 --- a/ui/app/peers/create/configuration/handlers.ts +++ b/ui/app/peers/create/configuration/handlers.ts @@ -1,6 +1,6 @@ import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context'; import { Dispatch, SetStateAction } from 'react'; -import { pgSchema } from './schema'; +import { pgSchema, sfSchema } from './schema'; import { PeerConfig } from './types'; // Frontend form validation @@ -20,6 +20,10 @@ const validateFields = ( const pgConfig = pgSchema.safeParse(config); if (!pgConfig.success) validationErr = pgConfig.error.issues[0].message; break; + case 'SNOWFLAKE': + const sfConfig = sfSchema.safeParse(config); + if (!sfConfig.success) validationErr = sfConfig.error.issues[0].message; + break; default: validationErr = 'Unsupported peer type ' + type; } diff --git a/ui/app/peers/create/configuration/helpers/common.ts b/ui/app/peers/create/configuration/helpers/common.ts index 66fa236a34..b36423bf4d 100644 --- a/ui/app/peers/create/configuration/helpers/common.ts +++ b/ui/app/peers/create/configuration/helpers/common.ts @@ -1,9 +1,23 @@ +import { PeerConfig, PeerSetter } from '../types'; import { blankPostgresSetting } from './pg'; +import { blankSnowflakeSetting } from './sf'; -export const getBlankSetting = (dbType: string) => { +export interface Setting { + label: string; + stateHandler: (value: string, setter: PeerSetter) => void; + type?: string; + optional?: boolean; + tips?: string; + helpfulLink?: string; + default?: string | number; +} + +export const getBlankSetting = (dbType: string): PeerConfig => { switch (dbType) { case 'POSTGRES': return blankPostgresSetting; + case 'SNOWFLAKE': + return blankSnowflakeSetting; default: return blankPostgresSetting; } diff --git a/ui/app/peers/create/configuration/helpers/pg.ts b/ui/app/peers/create/configuration/helpers/pg.ts index 4790c0c1d6..8d287e3f25 100644 --- a/ui/app/peers/create/configuration/helpers/pg.ts +++ b/ui/app/peers/create/configuration/helpers/pg.ts @@ -1,41 +1,54 @@ -import { PeerSetter } from '../types'; +import { PostgresConfig } from '@/grpc_generated/peers'; +import { Setting } from './common'; -export const postgresSetting = [ +export const postgresSetting: Setting[] = [ { label: 'Host', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, host: value })), + tips: 'Specifies the IP host name or address on which postgres is to listen for TCP/IP connections from client applications. Ensure that this host has us whitelisted so we can connect to it.', }, { label: 'Port', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, port: parseInt(value, 10) })), type: 'number', // type for textfield + default: 5432, + tips: 'Specifies the TCP/IP port or local Unix domain socket file extension on which postgres is listening for connections from client applications.', }, { label: 'User', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, user: value })), + tips: 'Specify the user that we should use to connect to this host.', + helpfulLink: 'https://www.postgresql.org/docs/8.0/user-manag.html', }, { label: 'Password', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, password: value })), type: 'password', + tips: 'Password associated with the user you provided.', + helpfulLink: 'https://www.postgresql.org/docs/current/auth-password.html', }, { label: 'Database', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, database: value })), + tips: 'Specify which database to associate with this peer.', + helpfulLink: + 'https://www.postgresql.org/docs/current/sql-createdatabase.html', }, { label: 'Transaction Snapshot', - stateHandler: (value: string, setter: PeerSetter) => + stateHandler: (value, setter) => setter((curr) => ({ ...curr, transactionSnapshot: value })), + optional: true, + tips: 'This is optional and only needed if this peer is part of any query replication mirror.', }, ]; -export const blankPostgresSetting = { +export const blankPostgresSetting: PostgresConfig = { host: '', port: 5432, user: '', diff --git a/ui/app/peers/create/configuration/helpers/sf.ts b/ui/app/peers/create/configuration/helpers/sf.ts new file mode 100644 index 0000000000..0d90fd2c12 --- /dev/null +++ b/ui/app/peers/create/configuration/helpers/sf.ts @@ -0,0 +1,95 @@ +import { SnowflakeConfig } from '@/grpc_generated/peers'; +import { Setting } from './common'; + +export const snowflakeSetting: Setting[] = [ + { + label: 'Account ID', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, accountId: value })), + tips: 'This is the unique identifier for your Snowflake account. It has a URL-like format', + helpfulLink: + 'https://docs.snowflake.com/en/user-guide/admin-account-identifier', + }, + { + label: 'Username', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, username: value })), + tips: 'This is the username you use to login to your Snowflake account.', + helpfulLink: + 'https://docs.snowflake.com/en/user-guide/admin-user-management', + }, + { + label: 'Private Key', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, privateKey: value })), + type: 'file', + tips: 'This can be of any file extension. If you are using an encrypted key, you must fill the below password field for decryption.', + helpfulLink: 'https://docs.snowflake.com/en/user-guide/key-pair-auth', + }, + { + label: 'Warehouse', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, warehouse: value })), + tips: 'Warehouses denote a cluster of snowflake resources.', + helpfulLink: 'https://docs.snowflake.com/en/user-guide/warehouses', + }, + { + label: 'Database', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, database: value })), + tips: 'Specify which database to associate with this peer.', + helpfulLink: 'https://docs.snowflake.com/en/sql-reference/snowflake-db', + }, + { + label: 'Role', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, role: value })), + tips: 'You could use a default role, or setup a role with the required permissions.', + helpfulLink: + 'https://docs.snowflake.com/en/user-guide/security-access-control-overview#roles', + }, + { + label: 'Query Timeout', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, queryTimeout: parseInt(value, 10) || 30 })), + optional: true, + tips: 'This is the maximum time in seconds that a query can run before being cancelled. If not specified, the default is 30 seconds', + default: 30, + }, + { + label: 'S3 Integration', + stateHandler: (value, setter) => + setter((curr) => ({ ...curr, s3Integration: value })), + optional: true, + tips: `This is needed only if you plan to run a mirror and you wish to stage AVRO files on S3.`, + helpfulLink: + 'https://docs.snowflake.com/en/user-guide/data-load-s3-config-storage-integration', + }, + { + label: 'Password', + stateHandler: (value, setter) => { + if (!value.length) { + // remove password key from state if empty + setter((curr) => { + delete curr['password']; + return curr; + }); + } else setter((curr) => ({ ...curr, password: value })); + }, + type: 'password', + optional: true, + tips: 'This is needed only if the private key you provided is encrypted.', + helpfulLink: 'https://docs.snowflake.com/en/user-guide/key-pair-auth', + }, +]; + +export const blankSnowflakeSetting: SnowflakeConfig = { + accountId: '', + privateKey: '', + username: '', + warehouse: '', + database: '', + role: '', + queryTimeout: 30, + s3Integration: '', +}; diff --git a/ui/app/peers/create/configuration/page.tsx b/ui/app/peers/create/configuration/page.tsx index b8ce42c746..c0e75f3322 100644 --- a/ui/app/peers/create/configuration/page.tsx +++ b/ui/app/peers/create/configuration/page.tsx @@ -5,13 +5,15 @@ import { Label } from '@/lib/Label'; import { LayoutMain, RowWithTextField } from '@/lib/Layout'; import { Panel } from '@/lib/Panel'; import { TextField } from '@/lib/TextField'; +import { Tooltip } from '@/lib/Tooltip'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; -import ConfigForm from './configForm'; +import ConfigForm from '../../../../components/ConfigForm'; import { handleCreate, handleValidate } from './handlers'; -import { getBlankSetting } from './helpers/common'; +import { Setting, getBlankSetting } from './helpers/common'; import { postgresSetting } from './helpers/pg'; +import { snowflakeSetting } from './helpers/sf'; import { PeerConfig } from './types'; export default function CreateConfig() { const searchParams = useSearchParams(); @@ -26,9 +28,14 @@ export default function CreateConfig() { }); const [loading, setLoading] = useState(false); const configComponentMap = (dbType: string) => { + const configForm = (settingList: Setting[]) => ( + + ); switch (dbType) { case 'POSTGRES': - return ; + return configForm(postgresSetting); + case 'SNOWFLAKE': + return configForm(snowflakeSetting); default: return <>; } @@ -37,15 +44,28 @@ export default function CreateConfig() { return ( - - + - Name} + label={ + + } action={ } /> + {dbType && configComponentMap(dbType)} diff --git a/ui/app/peers/create/configuration/schema.ts b/ui/app/peers/create/configuration/schema.ts index 05e0dcaeb5..08bf97a3d1 100644 --- a/ui/app/peers/create/configuration/schema.ts +++ b/ui/app/peers/create/configuration/schema.ts @@ -42,3 +42,67 @@ export const pgSchema = z.object({ .max(100, 'Transaction snapshot too long (100 char limit)') .optional(), }); + +export const sfSchema = z.object({ + accountId: z + .string({ + required_error: 'Account ID is required', + invalid_type_error: 'Account ID must be a string', + }) + .nonempty({ message: 'Account ID must be non-empty' }) + .max(255, 'Account ID must be less than 255 characters'), + privateKey: z + .string({ + required_error: 'Private Key is required', + invalid_type_error: 'Private Key must be a string', + }) + .nonempty({ message: 'Private Key must be non-empty' }), + username: z + .string({ + required_error: 'Username is required', + invalid_type_error: 'Username must be a string', + }) + .nonempty({ message: 'Username must be non-empty' }) + .max(255, 'Username must be less than 255 characters'), + database: z + .string({ + required_error: 'Database is required', + invalid_type_error: 'Database must be a string', + }) + .nonempty({ message: 'Database must be non-empty' }) + .max(255, 'Database must be less than 100 characters'), + warehouse: z + .string({ + required_error: 'Warehouse is required', + invalid_type_error: 'Warehouse must be a string', + }) + .nonempty({ message: 'Warehouse must be non-empty' }) + .max(255, 'Warehouse must be less than 64 characters'), + role: z + .string({ + invalid_type_error: 'Role must be a string', + }) + .nonempty({ message: 'Role must be non-empty' }) + .max(255, 'Role must be below 255 characters'), + queryTimeout: z + .number({ + invalid_type_error: 'Query timeout must be a number', + }) + .int() + .min(0, 'Query timeout must be a positive integer') + .max(65535, 'Query timeout must be below 65535 seconds') + .optional(), + password: z + .string({ + invalid_type_error: 'Password must be a string', + }) + .max(255, 'Password must be less than 255 characters') + .optional() + .transform((e) => (e === '' ? undefined : e)), + s3Integration: z + .string({ + invalid_type_error: 's3Integration must be a string', + }) + .max(255, 's3Integration must be less than 255 characters') + .optional(), +}); diff --git a/ui/app/peers/create/configuration/types.ts b/ui/app/peers/create/configuration/types.ts index 7e678151e6..56db213056 100644 --- a/ui/app/peers/create/configuration/types.ts +++ b/ui/app/peers/create/configuration/types.ts @@ -1,5 +1,5 @@ -import { PostgresConfig } from '@/grpc_generated/peers'; +import { PostgresConfig, SnowflakeConfig } from '@/grpc_generated/peers'; import { Dispatch, SetStateAction } from 'react'; -export type PeerConfig = PostgresConfig; +export type PeerConfig = PostgresConfig | SnowflakeConfig; export type PeerSetter = Dispatch>; diff --git a/ui/components/ConfigForm.tsx b/ui/components/ConfigForm.tsx new file mode 100644 index 0000000000..e9e20a27dd --- /dev/null +++ b/ui/components/ConfigForm.tsx @@ -0,0 +1,92 @@ +'use client'; +import { Setting } from '@/app/peers/create/configuration/helpers/common'; +import { Label } from '@/lib/Label'; +import { RowWithTextField } from '@/lib/Layout'; +import { TextField } from '@/lib/TextField'; +import { Tooltip } from '@/lib/Tooltip'; +import { PeerSetter } from '../app/peers/create/configuration/types'; +import { InfoPopover } from './InfoPopover'; + +interface ConfigProps { + settings: Setting[]; + setter: PeerSetter; +} + +export default function ConfigForm(props: ConfigProps) { + const handleFile = ( + file: File, + setFile: (value: string, setter: PeerSetter) => void + ) => { + if (file) { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = () => { + setFile(reader.result as string, props.setter); + }; + reader.onerror = (error) => { + console.log(error); + }; + } + }; + + const handleChange = ( + e: React.ChangeEvent, + setting: Setting + ) => { + if (setting.type === 'file') { + if (e.target.files) handleFile(e.target.files[0], setting.stateHandler); + } else { + setting.stateHandler(e.target.value, props.setter); + } + }; + return ( + <> + {props.settings.map((setting, id) => { + return ( + + {setting.label}{' '} + {!setting.optional && ( + + + + )} + + } + action={ +
+ handleChange(e, setting)} + /> + {setting.tips && ( + + )} +
+ } + /> + ); + })} + + ); +} diff --git a/ui/components/InfoPopover.tsx b/ui/components/InfoPopover.tsx new file mode 100644 index 0000000000..d10722934d --- /dev/null +++ b/ui/components/InfoPopover.tsx @@ -0,0 +1,53 @@ +import { Icon } from '@/lib/Icon'; +import * as Popover from '@radix-ui/react-popover'; +export const InfoPopover = ({ + tips, + link, +}: { + tips: string; + link?: string; +}) => { + return ( + + + + + + + +
+

+ {tips} +

+ + {link && ( + + Click here for more info. + + )} +
+
+
+
+ ); +}; diff --git a/ui/components/SelectSource.tsx b/ui/components/SelectSource.tsx index 8e65b29293..955cb09160 100644 --- a/ui/components/SelectSource.tsx +++ b/ui/components/SelectSource.tsx @@ -11,7 +11,8 @@ interface SelectSourceProps { export default function SelectSource({ setPeerType }: SelectSourceProps) { const dbTypes: string[] = Object.values(DBType).filter( (value): value is string => - typeof value === 'string' && value === 'POSTGRES' + typeof value === 'string' && + (value === 'POSTGRES' || value === 'SNOWFLAKE') ); return (