diff --git a/ui/app/mirrors/create/schema.ts b/ui/app/mirrors/create/schema.ts index 1d14b02569..2aab468409 100644 --- a/ui/app/mirrors/create/schema.ts +++ b/ui/app/mirrors/create/schema.ts @@ -6,8 +6,9 @@ export const flowNameSchema = z required_error: 'Mirror name is required.', }) .min(1, { message: 'Mirror name cannot be empty.' }) - .regex(/^[\w]*$/, { - message: 'Mirror name must contain only letters, numbers and underscores', + .regex(/^[a-z0-9_]*$/, { + message: + 'Mirror name must contain only lowercase letters, numbers and underscores', }); export const tableMappingSchema = z diff --git a/ui/app/mirrors/edit/[mirrorId]/aggregatedCountsByInterval.ts b/ui/app/mirrors/edit/[mirrorId]/aggregatedCountsByInterval.ts index b3721873a9..b2fdf7b1fb 100644 --- a/ui/app/mirrors/edit/[mirrorId]/aggregatedCountsByInterval.ts +++ b/ui/app/mirrors/edit/[mirrorId]/aggregatedCountsByInterval.ts @@ -23,6 +23,10 @@ function aggregateCountsByInterval( case 'day': timeUnit = 'YYYY-MM-DD'; break; + case '1min': + case '5min': + timeUnit = 'YYYY-MM-DD HH:mm'; + break; default: throw new Error('Invalid interval provided'); } @@ -32,7 +36,7 @@ function aggregateCountsByInterval( // Iterate through the timestamps and populate the aggregatedCounts object for (let { timestamp, count } of timestamps) { - const date = roundUpToNearest15Minutes(timestamp); + const date = roundUpToNearestNMinutes(timestamp, 15); const formattedTimestamp = moment(date).format(timeUnit); if (!aggregatedCounts[formattedTimestamp]) { @@ -48,7 +52,10 @@ function aggregateCountsByInterval( let currentTimestamp = new Date(); if (interval === '15min') { - currentTimestamp = roundUpToNearest15Minutes(currentTimestamp); + currentTimestamp = roundUpToNearestNMinutes(currentTimestamp, 15); + } + if (interval === '5min') { + currentTimestamp = roundUpToNearestNMinutes(currentTimestamp, 5); } while (intervals.length < 30) { @@ -73,13 +80,13 @@ function aggregateCountsByInterval( return resultArray; } -function roundUpToNearest15Minutes(date: Date) { +function roundUpToNearestNMinutes(date: Date, N: number) { const minutes = date.getMinutes(); - const remainder = minutes % 15; + const remainder = minutes % N; if (remainder > 0) { - // Round up to the nearest 15 minutes - date.setMinutes(minutes + (15 - remainder)); + // Round up to the nearest N minutes + date.setMinutes(minutes + (N - remainder)); } // Reset seconds and milliseconds to zero to maintain the same time diff --git a/ui/app/mirrors/edit/[mirrorId]/cdc.tsx b/ui/app/mirrors/edit/[mirrorId]/cdc.tsx index f6f302da22..7d61280747 100644 --- a/ui/app/mirrors/edit/[mirrorId]/cdc.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/cdc.tsx @@ -1,16 +1,24 @@ 'use client'; import TimeLabel from '@/components/TimeComponent'; -import { QRepMirrorStatus, SnapshotStatus } from '@/grpc_generated/route'; +import { + CDCMirrorStatus, + QRepMirrorStatus, + SnapshotStatus, +} from '@/grpc_generated/route'; import { Button } from '@/lib/Button'; import { Icon } from '@/lib/Icon'; import { Label } from '@/lib/Label'; import { ProgressBar } from '@/lib/ProgressBar'; import { SearchField } from '@/lib/SearchField'; import { Table, TableCell, TableRow } from '@/lib/Table'; +import * as Tabs from '@radix-ui/react-tabs'; import moment, { Duration, Moment } from 'moment'; import Link from 'next/link'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import ReactSelect from 'react-select'; +import styled from 'styled-components'; +import CdcDetails from './cdcDetails'; class TableCloneSummary { flowJobName: string; @@ -87,18 +95,59 @@ function summarizeTableClone(clone: QRepMirrorStatus): TableCloneSummary { type SnapshotStatusProps = { status: SnapshotStatus; }; + +const ROWS_PER_PAGE = 5; export const SnapshotStatusTable = ({ status }: SnapshotStatusProps) => { + const [sortField, setSortField] = useState< + 'cloneStartTime' | 'avgTimePerPartition' + >('cloneStartTime'); + const allRows = status.clones.map(summarizeTableClone); + const [currentPage, setCurrentPage] = useState(1); + const totalPages = Math.ceil(allRows.length / ROWS_PER_PAGE); const [searchQuery, setSearchQuery] = useState(''); - const snapshotRows = useMemo( - () => - status.clones - .map(summarizeTableClone) - .filter((row: any) => - row.tableName.toLowerCase().includes(searchQuery.toLowerCase()) - ), - [status.clones, searchQuery] - ); + const displayedRows = useMemo(() => { + const shownRows = allRows.filter((row: any) => + row.tableName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + shownRows.sort((a, b) => { + const aValue = a[sortField]; + const bValue = b[sortField]; + if (aValue === null || bValue === null) { + return 0; + } + + if (aValue < bValue) { + return -1; + } else if (aValue > bValue) { + return 1; + } else { + return 0; + } + }); + const startRow = (currentPage - 1) * ROWS_PER_PAGE; + const endRow = startRow + ROWS_PER_PAGE; + return shownRows.length > ROWS_PER_PAGE + ? shownRows.slice(startRow, endRow) + : shownRows; + }, [allRows, currentPage, searchQuery, sortField]); + + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const sortOptions = [ + { value: 'cloneStartTime', label: 'Start Time' }, + { value: 'avgTimePerPartition', label: 'Time Per Partition' }, + ]; return (
{ toolbar={{ left: ( <> - - + + { + const sortVal = + (val?.value as 'cloneStartTime' | 'avgTimePerPartition') ?? + 'cloneStartTime'; + setSortField(sortVal); + }} + value={{ + value: sortField, + label: sortOptions.find((opt) => opt.value === sortField) + ?.label, + }} + defaultValue={{ value: 'cloneStartTime', label: 'Start Time' }} + /> ), right: ( @@ -139,7 +204,7 @@ export const SnapshotStatusTable = ({ status }: SnapshotStatusProps) => { } > - {snapshotRows.map((clone, index) => ( + {displayedRows.map((clone, index) => ( - @@ -179,3 +239,102 @@ export const SnapshotStatusTable = ({ status }: SnapshotStatusProps) => { ); }; + +const Trigger = styled( + ({ isActive, ...props }: { isActive?: boolean } & Tabs.TabsTriggerProps) => ( + + ) +)<{ isActive?: boolean }>` + background-color: ${({ theme, isActive }) => + isActive ? theme.colors.accent.surface.selected : 'white'}; + + font-weight: ${({ isActive }) => (isActive ? 'bold' : 'normal')}; + + &:hover { + color: ${({ theme }) => theme.colors.accent.text.highContrast}; + } +`; + +type SyncStatusRow = { + batchId: number; + startTime: Date; + endTime: Date | null; + numRows: number; +}; + +type CDCMirrorStatusProps = { + cdc: CDCMirrorStatus; + rows: SyncStatusRow[]; + createdAt?: Date; + syncStatusChild?: React.ReactNode; +}; +export function CDCMirror({ + cdc, + rows, + createdAt, + syncStatusChild, +}: CDCMirrorStatusProps) { + const [selectedTab, setSelectedTab] = useState(''); + + let snapshot = <>; + if (cdc.snapshotStatus) { + snapshot = ; + } + + const handleTab = (tabVal: string) => { + localStorage.setItem('mirrortab', tabVal); + setSelectedTab(tabVal); + }; + + useEffect(() => { + if (typeof window !== 'undefined') { + setSelectedTab(localStorage?.getItem('mirrortab') || 'tab1'); + } + }, []); + + return ( + handleTab(val)} + style={{ marginTop: '2rem' }} + > + + + Overview + + + Sync Status + + + Initial Copy + + + + + + + {syncStatusChild} + + + {snapshot} + + + ); +} diff --git a/ui/app/mirrors/edit/[mirrorId]/cdcDetails.tsx b/ui/app/mirrors/edit/[mirrorId]/cdcDetails.tsx index fcdf7dca83..f52450ade0 100644 --- a/ui/app/mirrors/edit/[mirrorId]/cdcDetails.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/cdcDetails.tsx @@ -1,13 +1,15 @@ 'use client'; +import MirrorInfo from '@/components/MirrorInfo'; +import PeerButton from '@/components/PeerComponent'; +import TimeLabel from '@/components/TimeComponent'; import { FlowConnectionConfigs } from '@/grpc_generated/flow'; +import { dBTypeFromJSON } from '@/grpc_generated/peers'; import { Badge } from '@/lib/Badge'; import { Icon } from '@/lib/Icon'; import { Label } from '@/lib/Label'; import moment from 'moment'; -import CdcGraph from './cdcGraph'; - -import PeerButton from '@/components/PeerComponent'; -import { dBTypeFromJSON } from '@/grpc_generated/peers'; +import MirrorValues from './configValues'; +import TablePairs from './tablePairs'; type SyncStatusRow = { batchId: number; @@ -19,11 +21,13 @@ type SyncStatusRow = { type props = { syncs: SyncStatusRow[]; mirrorConfig: FlowConnectionConfigs | undefined; + createdAt?: Date; }; -function CdcDetails({ syncs, mirrorConfig }: props) { +function CdcDetails({ syncs, createdAt, mirrorConfig }: props) { let lastSyncedAt = moment(syncs[0]?.startTime).fromNow(); let rowsSynced = syncs.reduce((acc, sync) => acc + sync.numRows, 0); + const tablesSynced = mirrorConfig?.tableMappings; return ( <>
@@ -91,6 +95,16 @@ function CdcDetails({ syncs, mirrorConfig }: props) {
+
+
+ +
+
+ +
+
+ +
+ +
-
- -
+ + ); } -function numberWithCommas(x: Number): string { +export function numberWithCommas(x: any): string { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } diff --git a/ui/app/mirrors/edit/[mirrorId]/cdcGraph.tsx b/ui/app/mirrors/edit/[mirrorId]/cdcGraph.tsx index 9c490f6549..cdc6d91a37 100644 --- a/ui/app/mirrors/edit/[mirrorId]/cdcGraph.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/cdcGraph.tsx @@ -1,7 +1,8 @@ +'use client'; import { Label } from '@/lib/Label'; import moment from 'moment'; import { useEffect, useState } from 'react'; - +import ReactSelect from 'react-select'; type SyncStatusRow = { batchId: number; startTime: Date; @@ -11,13 +12,6 @@ type SyncStatusRow = { import aggregateCountsByInterval from './aggregatedCountsByInterval'; -const aggregateTypeMap: { [key: string]: string } = { - '15min': ' 15 mins', - hour: 'Hour', - day: 'Day', - month: 'Month', -}; - function CdcGraph({ syncs }: { syncs: SyncStatusRow[] }) { let [aggregateType, setAggregateType] = useState('hour'); const initialCount: [string, number][] = []; @@ -35,21 +29,26 @@ function CdcGraph({ syncs }: { syncs: SyncStatusRow[] }) { setCounts(counts); }, [aggregateType, syncs]); + const timeOptions = [ + { label: '1min', value: '1min' }, + { label: '5min', value: '5min' }, + { label: '15min', value: '15min' }, + { label: 'hour', value: 'hour' }, + { label: 'day', value: 'day' }, + { label: 'month', value: 'month' }, + ]; return (
- {['15min', 'hour', 'day', 'month'].map((type) => { - return ( - - ); - })} + val && setAggregateType(val.value)} + />
-
+
@@ -65,34 +64,11 @@ function CdcGraph({ syncs }: { syncs: SyncStatusRow[] }) { ); } -type filterButtonProps = { - aggregateType: string; - selectedAggregateType: string; - setAggregateType: Function; -}; -function FilterButton({ - aggregateType, - selectedAggregateType, - setAggregateType, -}: filterButtonProps): React.ReactNode { - return ( - - ); -} - function formatGraphLabel(date: Date, aggregateType: String): string { switch (aggregateType) { + case '1min': + case '5min': case '15min': - return moment(date).format('MMM Do HH:mm'); case 'hour': return moment(date).format('MMM Do HH:mm'); case 'day': diff --git a/ui/app/mirrors/edit/[mirrorId]/configValues.ts b/ui/app/mirrors/edit/[mirrorId]/configValues.ts new file mode 100644 index 0000000000..7f5172d1cd --- /dev/null +++ b/ui/app/mirrors/edit/[mirrorId]/configValues.ts @@ -0,0 +1,57 @@ +import { FlowConnectionConfigs, QRepSyncMode } from '@/grpc_generated/flow'; + +const syncModeToLabel = (mode: QRepSyncMode) => { + switch (mode) { + case QRepSyncMode.QREP_SYNC_MODE_STORAGE_AVRO: + return 'AVRO'; + case QRepSyncMode.QREP_SYNC_MODE_MULTI_INSERT: + return 'Copy with Binary'; + default: + return 'AVRO'; + } +}; +const MirrorValues = (mirrorConfig: FlowConnectionConfigs | undefined) => { + return [ + { + value: `${mirrorConfig?.maxBatchSize} rows`, + label: 'Pull Batch Size', + }, + { + value: `${mirrorConfig?.snapshotNumRowsPerPartition} rows`, + label: 'Snapshot Rows Per Partition', + }, + { + value: `${mirrorConfig?.snapshotNumTablesInParallel} table(s)`, + label: 'Snapshot Tables In Parallel', + }, + { + value: `${mirrorConfig?.snapshotMaxParallelWorkers} worker(s)`, + label: 'Snapshot Parallel Tables', + }, + { + value: `${syncModeToLabel(mirrorConfig?.cdcSyncMode!)} mode`, + label: 'CDC Sync Mode', + }, + { + value: `${syncModeToLabel(mirrorConfig?.snapshotSyncMode!)} mode`, + label: 'Snapshot Sync Mode', + }, + { + value: `${ + mirrorConfig?.cdcStagingPath?.length + ? mirrorConfig?.cdcStagingPath + : 'Local' + }`, + label: 'CDC Staging Path', + }, + { + value: `${ + mirrorConfig?.snapshotStagingPath?.length + ? mirrorConfig?.snapshotStagingPath + : 'Local' + }`, + label: 'Snapshot Staging Path', + }, + ]; +}; +export default MirrorValues; diff --git a/ui/app/mirrors/edit/[mirrorId]/page.tsx b/ui/app/mirrors/edit/[mirrorId]/page.tsx index ccea9bb196..756684b854 100644 --- a/ui/app/mirrors/edit/[mirrorId]/page.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/page.tsx @@ -4,9 +4,7 @@ import { Header } from '@/lib/Header'; import { LayoutMain } from '@/lib/Layout'; import { GetFlowHttpAddressFromEnv } from '@/rpc/http'; import { redirect } from 'next/navigation'; -import { Suspense } from 'react'; -import { SnapshotStatusTable } from './cdc'; -import CdcDetails from './cdcDetails'; +import { CDCMirror } from './cdc'; import SyncStatus from './syncStatus'; export const dynamic = 'force-dynamic'; @@ -27,10 +25,6 @@ async function getMirrorStatus(mirrorId: string) { return json; } -function Loading() { - return
Loading...
; -} - export default async function EditMirror({ params: { mirrorId }, }: EditMirrorProps) { @@ -39,12 +33,14 @@ export default async function EditMirror({ return
No mirror status found!
; } - let syncStatusChild = <>; - if (mirrorStatus.cdcStatus) { - syncStatusChild = ; - } else { - redirect(`/mirrors/status/qrep/${mirrorId}`); - } + let createdAt = await prisma.flows.findFirst({ + select: { + created_at: true, + }, + where: { + name: mirrorId, + }, + }); let syncs = await prisma.cdc_batches.findMany({ where: { @@ -58,6 +54,16 @@ export default async function EditMirror({ }, }); + let syncStatusChild = <>; + if (mirrorStatus.cdcStatus) { + let rowsSynced = syncs.reduce((acc, sync) => acc + sync.rows_in_batch, 0); + syncStatusChild = ( + + ); + } else { + redirect(`/mirrors/status/qrep/${mirrorId}`); + } + const rows = syncs.map((sync) => ({ batchId: sync.id, startTime: sync.start_time, @@ -68,22 +74,12 @@ export default async function EditMirror({ return (
{mirrorId}
- }> - {mirrorStatus.cdcStatus && ( - <> - - {mirrorStatus.cdcStatus.snapshotStatus && ( - - )} - - )} -
{syncStatusChild}
-
+
); } diff --git a/ui/app/mirrors/edit/[mirrorId]/syncStatus.tsx b/ui/app/mirrors/edit/[mirrorId]/syncStatus.tsx index f1fea81f17..0a481241ca 100644 --- a/ui/app/mirrors/edit/[mirrorId]/syncStatus.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/syncStatus.tsx @@ -1,11 +1,20 @@ import prisma from '@/app/utils/prisma'; +import { Label } from '@/lib/Label'; +import CdcGraph from './cdcGraph'; import { SyncStatusTable } from './syncStatusTable'; +function numberWithCommas(x: Number): string { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} type SyncStatusProps = { flowJobName: string | undefined; + rowsSynced: Number; }; -export default async function SyncStatus({ flowJobName }: SyncStatusProps) { +export default async function SyncStatus({ + flowJobName, + rowsSynced, +}: SyncStatusProps) { if (!flowJobName) { return
Flow job name not provided!
; } @@ -31,6 +40,20 @@ export default async function SyncStatus({ flowJobName }: SyncStatusProps) { return (
+
+
+ +
+
+ +
+
+ +
+ +
); diff --git a/ui/app/mirrors/edit/[mirrorId]/syncStatusTable.tsx b/ui/app/mirrors/edit/[mirrorId]/syncStatusTable.tsx index 717df2705c..581144a7ef 100644 --- a/ui/app/mirrors/edit/[mirrorId]/syncStatusTable.tsx +++ b/ui/app/mirrors/edit/[mirrorId]/syncStatusTable.tsx @@ -9,7 +9,7 @@ import { SearchField } from '@/lib/SearchField'; import { Table, TableCell, TableRow } from '@/lib/Table'; import moment from 'moment'; import { useMemo, useState } from 'react'; - +import ReactSelect from 'react-select'; type SyncStatusRow = { batchId: number; startTime: Date; @@ -48,27 +48,58 @@ function TimeWithDurationOrRunning({ } } -const ROWS_PER_PAGE = 10; - +const ROWS_PER_PAGE = 5; +const sortOptions = [ + { value: 'startTime', label: 'Start Time' }, + { value: 'endTime', label: 'End Time' }, + { value: 'numRows', label: 'Rows Synced' }, +]; export const SyncStatusTable = ({ rows }: SyncStatusTableProps) => { const [currentPage, setCurrentPage] = useState(1); + const [sortField, setSortField] = useState< + 'startTime' | 'endTime' | 'numRows' + >('startTime'); + const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE); const [searchQuery, setSearchQuery] = useState(''); - const startRow = (currentPage - 1) * ROWS_PER_PAGE; - const endRow = startRow + ROWS_PER_PAGE; const displayedRows = useMemo(() => { - const allRows = rows.slice(startRow, endRow); - const shownRows = allRows.filter( + const searchRows = rows.filter( (row: any) => row.batchId == parseInt(searchQuery, 10) ); - return shownRows.length > 0 ? shownRows : allRows; - }, [searchQuery, endRow, startRow, rows]); + const shownRows = searchRows.length > 0 ? searchRows : rows; + shownRows.sort((a, b) => { + const aValue = a[sortField]; + const bValue = b[sortField]; + if (aValue === null || bValue === null) { + return 0; + } + + if (aValue < bValue) { + return -1; + } else if (aValue > bValue) { + return 1; + } else { + return 0; + } + }); + + const startRow = (currentPage - 1) * ROWS_PER_PAGE; + const endRow = startRow + ROWS_PER_PAGE; + return shownRows.length > ROWS_PER_PAGE + ? shownRows.slice(startRow, endRow) + : shownRows; + }, [searchQuery, currentPage, rows, sortField]); + const handlePrevPage = () => { - if (currentPage > 1) setCurrentPage(currentPage - 1); + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } }; const handleNextPage = () => { - if (currentPage < totalPages) setCurrentPage(currentPage + 1); + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } }; return ( @@ -90,6 +121,21 @@ export const SyncStatusTable = ({ rows }: SyncStatusTableProps) => { > + opt.value === sortField) + ?.label, + }} + onChange={(val, _) => { + const sortVal = + (val?.value as 'startTime' | 'endTime' | 'numRows') ?? + 'startTime'; + setSortField(sortVal); + }} + defaultValue={{ value: 'startTime', label: 'Start Time' }} + /> ), right: ( @@ -115,8 +161,8 @@ export const SyncStatusTable = ({ rows }: SyncStatusTableProps) => { } > - {displayedRows.map((row, index) => ( - + {displayedRows.map((row) => ( + diff --git a/ui/app/mirrors/edit/[mirrorId]/tablePairs.tsx b/ui/app/mirrors/edit/[mirrorId]/tablePairs.tsx new file mode 100644 index 0000000000..5289e77a04 --- /dev/null +++ b/ui/app/mirrors/edit/[mirrorId]/tablePairs.tsx @@ -0,0 +1,83 @@ +'use client'; +import { TableMapping } from '@/grpc_generated/flow'; +import { SearchField } from '@/lib/SearchField'; +import { useMemo, useState } from 'react'; + +const TablePairs = ({ tables }: { tables?: TableMapping[] }) => { + const [searchQuery, setSearchQuery] = useState(''); + const shownTables = useMemo(() => { + const shownTables = tables?.filter( + (table: TableMapping) => + table.sourceTableIdentifier.includes(searchQuery) || + table.destinationTableIdentifier.includes(searchQuery) + ); + return shownTables?.length ? shownTables : tables; + }, [tables, searchQuery]); + if (tables) + return ( + <> +
+ ) => { + setSearchQuery(e.target.value); + }} + /> +
+
+ + + + + + + + {shownTables?.map((table) => ( + + + + + ))} + +
+ Source Table + + Destination Table +
+ {table.sourceTableIdentifier} + + {table.destinationTableIdentifier} +
+ + ); +}; + +export default TablePairs; diff --git a/ui/app/peers/[peerName]/datatables.tsx b/ui/app/peers/[peerName]/datatables.tsx index e06a3cec17..f86f7c5536 100644 --- a/ui/app/peers/[peerName]/datatables.tsx +++ b/ui/app/peers/[peerName]/datatables.tsx @@ -69,7 +69,7 @@ export const SlotTable = ({ data }: { data: SlotInfo[] }) => { diff --git a/ui/app/peers/create/[peerType]/handlers.ts b/ui/app/peers/create/[peerType]/handlers.ts index f51e44aff7..21c0a63b64 100644 --- a/ui/app/peers/create/[peerType]/handlers.ts +++ b/ui/app/peers/create/[peerType]/handlers.ts @@ -4,19 +4,21 @@ import { UValidatePeerResponse, } from '@/app/dto/PeersDTO'; import { Dispatch, SetStateAction } from 'react'; -import { bqSchema, pgSchema, sfSchema } from './schema'; +import { bqSchema, peerNameSchema, pgSchema, sfSchema } from './schema'; -// Frontend form validation const validateFields = ( type: string, config: PeerConfig, setMessage: Dispatch>, name?: string ): boolean => { - if (!name) { - setMessage({ ok: false, msg: 'Peer name is required' }); + const peerNameValid = peerNameSchema.safeParse(name); + if (!peerNameValid.success) { + const peerNameErr = peerNameValid.error.issues[0].message; + setMessage({ ok: false, msg: peerNameErr }); return false; } + let validationErr: string | undefined; switch (type) { case 'POSTGRES': diff --git a/ui/app/peers/create/[peerType]/schema.ts b/ui/app/peers/create/[peerType]/schema.ts index 69da7b1c1b..3f92aef257 100644 --- a/ui/app/peers/create/[peerType]/schema.ts +++ b/ui/app/peers/create/[peerType]/schema.ts @@ -1,5 +1,16 @@ import * as z from 'zod'; +export const peerNameSchema = z + .string({ + invalid_type_error: 'Peer name is invalid.', + required_error: 'Peer name is required.', + }) + .min(1, { message: 'Peer name cannot be empty.' }) + .regex(/^[a-z0-9_]*$/, { + message: + 'Peer name must contain only lowercase letters, numbers and underscores', + }); + export const pgSchema = z.object({ host: z .string({ diff --git a/ui/components/DropDialog.tsx b/ui/components/DropDialog.tsx index f3de3c283f..2de31d9c55 100644 --- a/ui/components/DropDialog.tsx +++ b/ui/components/DropDialog.tsx @@ -79,7 +79,7 @@ export const DropDialog = ({ return ( diff --git a/ui/components/MirrorInfo.tsx b/ui/components/MirrorInfo.tsx new file mode 100644 index 0000000000..e66f9d5345 --- /dev/null +++ b/ui/components/MirrorInfo.tsx @@ -0,0 +1,65 @@ +'use client'; +import { Button } from '@/lib/Button'; +import { Dialog, DialogClose } from '@/lib/Dialog'; +import { Icon } from '@/lib/Icon'; +import { Label } from '@/lib/Label'; + +interface InfoPopoverProps { + configs: { + label: string; + value?: string | number; + }[]; +} + +const MirrorInfo = ({ configs }: InfoPopoverProps) => { + return ( + + + + } + > +
+
+ + + + +
+ + + {configs.map((config, index) => ( + + + + + ))} + +
+ + + +
+
+
+ ); +}; + +export default MirrorInfo; diff --git a/ui/lib/Dialog/Dialog.tsx b/ui/lib/Dialog/Dialog.tsx index 78c5b14b85..51a0a65264 100644 --- a/ui/lib/Dialog/Dialog.tsx +++ b/ui/lib/Dialog/Dialog.tsx @@ -7,6 +7,7 @@ import { DialogContent, DialogContentProps } from './DialogContent'; type DialogProps = RadixDialog.DialogProps & { triggerButton: RenderObject; + noInteract: boolean; } & PropsWithChildren & DialogContentProps; @@ -14,6 +15,7 @@ export function Dialog({ triggerButton, size, children, + noInteract, ...rootProps }: DialogProps) { const TriggerButton = isDefined(triggerButton) && triggerButton; @@ -23,8 +25,17 @@ export function Dialog({ {TriggerButton} e.preventDefault()} + onPointerDownOutside={(e) => { + if (noInteract) e.preventDefault(); + }} size={size} + style={{ + position: 'fixed', + left: '50%', + top: '50%', + transform: 'translate(-0%, -50%)', + boxShadow: '0px 2px 3px rgba(0,0,0,0.2)', + }} > {children} diff --git a/ui/lib/Dialog/DialogContent.styles.ts b/ui/lib/Dialog/DialogContent.styles.ts index c250e28acf..fdcf214583 100644 --- a/ui/lib/Dialog/DialogContent.styles.ts +++ b/ui/lib/Dialog/DialogContent.styles.ts @@ -26,6 +26,9 @@ const sizes = { width: ${({ theme }) => theme.size.xxLarge}; ${css(({ theme }) => theme.dropShadow.xxLarge)}; `, + auto: css` + width: fit-content; + `, }; export type DialogSize = keyof typeof sizes;