diff --git a/ui/app/api/peers/route.ts b/ui/app/api/peers/route.ts new file mode 100644 index 0000000000..3fbba1b902 --- /dev/null +++ b/ui/app/api/peers/route.ts @@ -0,0 +1,54 @@ +import { PeerConfig } from '@/app/peers/create/configuration/types'; +import { DBType, Peer, PostgresConfig } from '@/grpc_generated/peers'; +import { + CreatePeerRequest, + CreatePeerResponse, + CreatePeerStatus, + ValidatePeerRequest, + ValidatePeerResponse, + ValidatePeerStatus, +} from '@/grpc_generated/route'; +import { GetFlowServiceClientFromEnv } from '@/rpc/rpc'; + +const constructPeer = ( + name: string, + type: string, + config: PeerConfig +): Peer | undefined => { + switch (type) { + case 'POSTGRES': + return { + name, + type: DBType.POSTGRES, + postgresConfig: config as PostgresConfig, + }; + default: + return; + } +}; + +export async function POST(request: Request) { + const body = await request.json(); + const { name, type, config, mode } = body; + const flowServiceClient = GetFlowServiceClientFromEnv(); + const peer = constructPeer(name, type, config); + if (mode === 'validate') { + const validateReq: ValidatePeerRequest = { peer }; + const validateStatus: ValidatePeerResponse = + await flowServiceClient.validatePeer(validateReq); + if (validateStatus.status === ValidatePeerStatus.INVALID) { + return new Response(validateStatus.message); + } else if (validateStatus.status === ValidatePeerStatus.VALID) { + return new Response('valid'); + } + } else if (mode === 'create') { + const req: CreatePeerRequest = { peer }; + const createStatus: CreatePeerResponse = + await flowServiceClient.createPeer(req); + if (createStatus.status === CreatePeerStatus.FAILED) { + return new Response(createStatus.message); + } else if (createStatus.status === CreatePeerStatus.CREATED) { + return new Response('created'); + } else return new Response('status of peer creation is unknown'); + } else return new Response('mode of peer creation is unknown'); +} diff --git a/ui/app/peers/create/configuration/configForm.tsx b/ui/app/peers/create/configuration/configForm.tsx new file mode 100644 index 0000000000..de47e1764d --- /dev/null +++ b/ui/app/peers/create/configuration/configForm.tsx @@ -0,0 +1,40 @@ +'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 new file mode 100644 index 0000000000..133c62d6e5 --- /dev/null +++ b/ui/app/peers/create/configuration/handlers.ts @@ -0,0 +1,93 @@ +import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context'; +import { Dispatch, SetStateAction } from 'react'; +import { pgSchema } from './schema'; +import { PeerConfig } from './types'; + +// Frontend form validation +const validateFields = ( + type: string, + config: PeerConfig, + setMessage: Dispatch>, + name?: string +): boolean => { + if (!name) { + setMessage({ ok: false, msg: 'Peer name is required' }); + return false; + } + let validationErr: string | undefined; + switch (type) { + case 'POSTGRES': + const pgConfig = pgSchema.safeParse(config); + if (!pgConfig.success) validationErr = pgConfig.error.issues[0].message; + break; + default: + validationErr = 'Unsupported peer type ' + type; + } + if (validationErr) { + setMessage({ ok: false, msg: validationErr }); + return false; + } else setMessage({ ok: true, msg: '' }); + return true; +}; + +// API call to validate peer +export const handleValidate = async ( + type: string, + config: PeerConfig, + setMessage: Dispatch>, + setLoading: Dispatch>, + name?: string +) => { + const isValid = validateFields(type, config, setMessage, name); + if (!isValid) return; + setLoading(true); + const statusMessage = await fetch('/api/peers/', { + method: 'POST', + body: JSON.stringify({ + name, + type, + config, + mode: 'validate', + }), + }).then((res) => res.text()); + if (statusMessage !== 'valid') { + setMessage({ ok: false, msg: statusMessage }); + setLoading(false); + return; + } else { + setMessage({ ok: true, msg: 'Peer is valid' }); + } + setLoading(false); +}; + +// API call to create peer +export const handleCreate = async ( + type: string, + config: PeerConfig, + setMessage: Dispatch>, + setLoading: Dispatch>, + router: AppRouterInstance, + name?: string +) => { + let isValid = validateFields(type, config, setMessage, name); + if (!isValid) return; + setLoading(true); + const statusMessage = await fetch('/api/peers/', { + method: 'POST', + body: JSON.stringify({ + name, + type, + config, + mode: 'create', + }), + }).then((res) => res.text()); + if (statusMessage !== 'created') { + setMessage({ ok: false, msg: statusMessage }); + setLoading(false); + return; + } else { + setMessage({ ok: true, msg: 'Peer created successfully' }); + router.push('/peers'); + } + setLoading(false); +}; diff --git a/ui/app/peers/create/configuration/helpers/common.ts b/ui/app/peers/create/configuration/helpers/common.ts new file mode 100644 index 0000000000..66fa236a34 --- /dev/null +++ b/ui/app/peers/create/configuration/helpers/common.ts @@ -0,0 +1,10 @@ +import { blankPostgresSetting } from './pg'; + +export const getBlankSetting = (dbType: string) => { + switch (dbType) { + case 'POSTGRES': + return blankPostgresSetting; + default: + return blankPostgresSetting; + } +}; diff --git a/ui/app/peers/create/configuration/helpers/pg.ts b/ui/app/peers/create/configuration/helpers/pg.ts new file mode 100644 index 0000000000..4790c0c1d6 --- /dev/null +++ b/ui/app/peers/create/configuration/helpers/pg.ts @@ -0,0 +1,45 @@ +import { PeerSetter } from '../types'; + +export const postgresSetting = [ + { + label: 'Host', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, host: value })), + }, + { + label: 'Port', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, port: parseInt(value, 10) })), + type: 'number', // type for textfield + }, + { + label: 'User', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, user: value })), + }, + { + label: 'Password', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, password: value })), + type: 'password', + }, + { + label: 'Database', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, database: value })), + }, + { + label: 'Transaction Snapshot', + stateHandler: (value: string, setter: PeerSetter) => + setter((curr) => ({ ...curr, transactionSnapshot: value })), + }, +]; + +export const blankPostgresSetting = { + host: '', + port: 5432, + user: '', + password: '', + database: '', + transactionSnapshot: '', +}; diff --git a/ui/app/peers/create/configuration/page.tsx b/ui/app/peers/create/configuration/page.tsx new file mode 100644 index 0000000000..b8ce42c746 --- /dev/null +++ b/ui/app/peers/create/configuration/page.tsx @@ -0,0 +1,110 @@ +'use client'; +import { Button } from '@/lib/Button'; +import { ButtonGroup } from '@/lib/ButtonGroup'; +import { Label } from '@/lib/Label'; +import { LayoutMain, RowWithTextField } from '@/lib/Layout'; +import { Panel } from '@/lib/Panel'; +import { TextField } from '@/lib/TextField'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import ConfigForm from './configForm'; +import { handleCreate, handleValidate } from './handlers'; +import { getBlankSetting } from './helpers/common'; +import { postgresSetting } from './helpers/pg'; +import { PeerConfig } from './types'; +export default function CreateConfig() { + const searchParams = useSearchParams(); + const router = useRouter(); + const dbType = searchParams.get('dbtype') || ''; + const blankSetting = getBlankSetting(dbType); + const [name, setName] = useState(''); + const [config, setConfig] = useState(blankSetting); + const [formMessage, setFormMessage] = useState<{ ok: boolean; msg: string }>({ + ok: true, + msg: '', + }); + const [loading, setLoading] = useState(false); + const configComponentMap = (dbType: string) => { + switch (dbType) { + case 'POSTGRES': + return ; + default: + return <>; + } + }; + + return ( + + + + + + + + Name} + action={ + setName(e.target.value)} + /> + } + /> + {dbType && configComponentMap(dbType)} + + + + + + + + + {loading && ( + + )} + {!loading && formMessage.msg.length > 0 && ( + + )} + + + + ); +} diff --git a/ui/app/peers/create/configuration/schema.ts b/ui/app/peers/create/configuration/schema.ts new file mode 100644 index 0000000000..05e0dcaeb5 --- /dev/null +++ b/ui/app/peers/create/configuration/schema.ts @@ -0,0 +1,44 @@ +import * as z from 'zod'; + +export const pgSchema = z.object({ + host: z + .string({ + required_error: 'Host is required', + invalid_type_error: 'Host must be a string', + }) + .nonempty() + .max(255, 'Host must be less than 255 characters'), + port: z + .number({ + required_error: 'Port is required', + invalid_type_error: 'Port must be a number', + }) + .int() + .min(1, 'Port must be a positive integer') + .max(65535, 'Port must be below 65535'), + database: z + .string({ + required_error: 'Database is required', + invalid_type_error: 'Database must be a string', + }) + .min(1, { message: 'Database name should be non-empty' }) + .max(100, 'Database must be less than 100 characters'), + user: z + .string({ + required_error: 'User is required', + invalid_type_error: 'User must be a string', + }) + .min(1, 'User must be non-empty') + .max(64, 'User must be less than 64 characters'), + password: z + .string({ + required_error: 'Password is required', + invalid_type_error: 'Password must be a string', + }) + .min(1, 'Password must be non-empty') + .max(100, 'Password must be less than 100 characters'), + transactionSnapshot: z + .string() + .max(100, 'Transaction snapshot too long (100 char limit)') + .optional(), +}); diff --git a/ui/app/peers/create/configuration/types.ts b/ui/app/peers/create/configuration/types.ts new file mode 100644 index 0000000000..7e678151e6 --- /dev/null +++ b/ui/app/peers/create/configuration/types.ts @@ -0,0 +1,5 @@ +import { PostgresConfig } from '@/grpc_generated/peers'; +import { Dispatch, SetStateAction } from 'react'; + +export type PeerConfig = PostgresConfig; +export type PeerSetter = Dispatch>; diff --git a/ui/app/connectors/create/page.tsx b/ui/app/peers/create/page.tsx similarity index 51% rename from ui/app/connectors/create/page.tsx rename to ui/app/peers/create/page.tsx index 30c458489e..63352337ba 100644 --- a/ui/app/connectors/create/page.tsx +++ b/ui/app/peers/create/page.tsx @@ -1,3 +1,4 @@ +'use client'; import { Action } from '@/lib/Action'; import { Button } from '@/lib/Button'; import { ButtonGroup } from '@/lib/ButtonGroup'; @@ -5,9 +6,14 @@ import { Icon } from '@/lib/Icon'; import { Label } from '@/lib/Label'; import { LayoutMain, RowWithSelect } from '@/lib/Layout'; import { Panel } from '@/lib/Panel'; -import { Select } from '@/lib/Select'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import SelectSource from '../../../components/SelectSource'; export default function CreatePeer() { + const [peerType, setPeerType] = useState(''); + const router = useRouter(); return ( @@ -15,25 +21,36 @@ export default function CreatePeer() { - }>Learn about peers + } + href='https://docs.peerdb.io/sql/commands/create-peer' + target='_blank' + > + Learn about peers + - Data source } - action={ setPeerType(val)} + > + {dbTypes.map((dbType, id) => { + return ( + + {dbType} + + ); + })} + + ); +} diff --git a/ui/components/SidebarComponent.tsx b/ui/components/SidebarComponent.tsx index 074b6e3d88..7ae16913be 100644 --- a/ui/components/SidebarComponent.tsx +++ b/ui/components/SidebarComponent.tsx @@ -31,10 +31,10 @@ export default function SidebarComponent() { } > - Connectors + Peers