From e02181aba811fa0298d035c13779a36bbe344aee Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Mon, 6 Nov 2023 08:32:20 +0100 Subject: [PATCH] feat: Realtime inspector (#17617) * Initial work. * Cleanup the listbox component. * Set the endpoint correctly. * Add checks for permissions on the realtime inspector. * Make the select for choosing tokens. * Make the JWT input work with the toggle. * Change the icon for broadcast. * Add a modal for sending an event. * Fix the events table to use react-data-grid. * Remove table id. * Refactor the no-results component. * Fix the listening state to match the new designs. * Add a functional no results component. * Remove unnecessary index page on realtime * First round of small styling and interaction behaviours fixes * Another round of small styling fixes * One more styling fix * Another style fix * One last styling fixes * Add line * last last fix * Rename events to messages. * Rename all files related to realtime messages to use the message wording. * Update the icon for realtime in the left sidebar. * Minor fix for the shimmer line. * Make the Realtime icons use currentColor. * Listen to all schemas by default. * Add all schemas option to the schema filter. * Fix the colors for error rows in the realtime table. * Fix the system icon to sit in center. * Use the correct icon for the right bar navigation. * Remove the realtime icon since it's not used anywhere. * Fix the filter popover colors. * Remove the side panel background color. * Add link to documentation on the filtering panel. * Refactor the RLS popover to the new design. * Minor styling fixes. * Add code editor to the send message modal. * Update the wording per Terry suggestions. * Remove default channel + fix filter button styling * Small styling fixes * Update messagestable * Deprecate all schema selector, extend existing schema selector * remove unnecessary divs * Last few fixes * okay last few * Small padding issue * Improve label --------- Co-authored-by: Joshen Lim --- packages/ui/index.tsx | 3 + packages/ui/src/components/Icon/IconBase.tsx | 3 +- .../icons/IconBroadcast/IconBroadcast.tsx | 36 +++ .../Icon/icons/IconBroadcast/index.tsx | 1 + .../IconDatabaseChanges.tsx | 38 ++++ .../Icon/icons/IconDatabaseChanges/index.tsx | 1 + .../Icon/icons/IconPresence/IconPresence.tsx | 26 +++ .../Icon/icons/IconPresence/index.tsx | 1 + .../ui/src/components/Icon/icons/index.tsx | 3 + .../ui/src/components/Listbox/Listbox2.tsx | 43 ++-- .../Realtime/Inspector/ApplyConfigModal.tsx | 31 +++ .../Inspector/ChooseChannelPopover/index.tsx | 176 ++++++++++++++ .../interfaces/Realtime/Inspector/Header.tsx | 35 +++ .../Realtime/Inspector/MessageSelection.tsx | 87 +++++++ .../Realtime/Inspector/Messages.types.ts | 15 ++ .../Realtime/Inspector/Messages.utils.ts | 14 ++ .../Realtime/Inspector/MessagesFormatters.tsx | 73 ++++++ .../Realtime/Inspector/MessagesTable.tsx | 182 +++++++++++++++ .../RealtimeFilterPopover/FilterSchema.tsx | 22 ++ .../RealtimeFilterPopover/TableSchema.tsx | 24 ++ .../RealtimeFilterPopover/TableSelector.tsx | 154 +++++++++++++ .../Inspector/RealtimeFilterPopover/index.tsx | 126 +++++++++++ .../RealtimeMessageColumnRenderer.tsx | 44 ++++ .../Inspector/RealtimeTokensPopover/index.tsx | 152 +++++++++++++ .../SelectedRealtimeMessagePanel.tsx | 38 ++++ .../Realtime/Inspector/SendMessageModal.tsx | 72 ++++++ .../interfaces/Realtime/Inspector/index.tsx | 55 +++++ .../Realtime/Inspector/useRealtimeMessages.ts | 214 ++++++++++++++++++ .../NavigationBar/NavigationBar.tsx | 2 + .../NavigationBar/NavigationBar.utils.tsx | 30 ++- .../layouts/RealtimeLayout/RealtimeLayout.tsx | 31 +++ .../RealtimeLayout/RealtimeMenu.utils.ts | 20 ++ studio/components/ui/AlertError.tsx | 3 +- studio/components/ui/SchemaSelector.tsx | 33 ++- .../project/[ref]/realtime/inspector.tsx | 21 ++ studio/styles/react-data-grid-logs.scss | 2 +- 36 files changed, 1774 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/components/Icon/icons/IconBroadcast/IconBroadcast.tsx create mode 100644 packages/ui/src/components/Icon/icons/IconBroadcast/index.tsx create mode 100644 packages/ui/src/components/Icon/icons/IconDatabaseChanges/IconDatabaseChanges.tsx create mode 100644 packages/ui/src/components/Icon/icons/IconDatabaseChanges/index.tsx create mode 100644 packages/ui/src/components/Icon/icons/IconPresence/IconPresence.tsx create mode 100644 packages/ui/src/components/Icon/icons/IconPresence/index.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/ApplyConfigModal.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/Header.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/MessageSelection.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/Messages.types.ts create mode 100644 studio/components/interfaces/Realtime/Inspector/Messages.utils.ts create mode 100644 studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSchema.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/SendMessageModal.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/index.tsx create mode 100644 studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts create mode 100644 studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx create mode 100644 studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts create mode 100644 studio/pages/project/[ref]/realtime/inspector.tsx diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 6887a98915e..9d6ec744556 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -212,6 +212,7 @@ export * from './src/components/Icon/icons/IconBookmark' export * from './src/components/Icon/icons/IconBox' export * from './src/components/Icon/icons/IconBriefcase' export * from './src/components/Icon/icons/IconBriefcase2' +export * from './src/components/Icon/icons/IconBroadcast' export * from './src/components/Icon/icons/IconCalendar' export * from './src/components/Icon/icons/IconCameraOff' export * from './src/components/Icon/icons/IconCamera' @@ -259,6 +260,7 @@ export * from './src/components/Icon/icons/IconCreditCard' export * from './src/components/Icon/icons/IconCrop' export * from './src/components/Icon/icons/IconCrosshair' export * from './src/components/Icon/icons/IconDatabase' +export * from './src/components/Icon/icons/IconDatabaseChanges' export * from './src/components/Icon/icons/IconDelete' export * from './src/components/Icon/icons/IconDisc' export * from './src/components/Icon/icons/IconDiscord' @@ -380,6 +382,7 @@ export * from './src/components/Icon/icons/IconPlus' export * from './src/components/Icon/icons/IconPocket' export * from './src/components/Icon/icons/IconPower' export * from './src/components/Icon/icons/IconPrinter' +export * from './src/components/Icon/icons/IconPresence' export * from './src/components/Icon/icons/IconRadio' export * from './src/components/Icon/icons/IconRefreshCcw' export * from './src/components/Icon/icons/IconRefreshCw' diff --git a/packages/ui/src/components/Icon/IconBase.tsx b/packages/ui/src/components/Icon/IconBase.tsx index 9ceb018a1fb..c4a65abac75 100644 --- a/packages/ui/src/components/Icon/IconBase.tsx +++ b/packages/ui/src/components/Icon/IconBase.tsx @@ -1,7 +1,7 @@ import React from 'react' import styleHandler from '../../lib/theme/styleHandler' -import { IconContext } from './IconContext' import { cn } from '../../lib/utils' +import { IconContext } from './IconContext' // @ts-ignore // import IconStyles from './Icon.module.css' @@ -16,6 +16,7 @@ interface Props { background?: 'brand' | 'gray' | 'red' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' src?: React.ReactNode icon?: any + viewBox?: string } interface StringMap { diff --git a/packages/ui/src/components/Icon/icons/IconBroadcast/IconBroadcast.tsx b/packages/ui/src/components/Icon/icons/IconBroadcast/IconBroadcast.tsx new file mode 100644 index 00000000000..34abc131fad --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconBroadcast/IconBroadcast.tsx @@ -0,0 +1,36 @@ +import IconBase from './../../IconBase' + +const SvgComponent = () => ( + <> + + + + +) + +function IconBroadcast(props: any) { + return } viewBox="0 0 24 24" {...props} /> +} + +export default IconBroadcast diff --git a/packages/ui/src/components/Icon/icons/IconBroadcast/index.tsx b/packages/ui/src/components/Icon/icons/IconBroadcast/index.tsx new file mode 100644 index 00000000000..c7e9cba42e5 --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconBroadcast/index.tsx @@ -0,0 +1 @@ +export { default as IconBroadcast } from './IconBroadcast' diff --git a/packages/ui/src/components/Icon/icons/IconDatabaseChanges/IconDatabaseChanges.tsx b/packages/ui/src/components/Icon/icons/IconDatabaseChanges/IconDatabaseChanges.tsx new file mode 100644 index 00000000000..7befddda31d --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconDatabaseChanges/IconDatabaseChanges.tsx @@ -0,0 +1,38 @@ +// DO NOT EDIT +// @ts-ignore +import IconBase from './../../IconBase' + +const SvgComponent = () => ( + + + + + +) + +function IconDatabaseChanges(props: any) { + return } viewBox="0 0 25 24" {...props} /> +} + +export default IconDatabaseChanges diff --git a/packages/ui/src/components/Icon/icons/IconDatabaseChanges/index.tsx b/packages/ui/src/components/Icon/icons/IconDatabaseChanges/index.tsx new file mode 100644 index 00000000000..f9194b729d2 --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconDatabaseChanges/index.tsx @@ -0,0 +1 @@ +export { default as IconDatabaseChanges } from './IconDatabaseChanges' diff --git a/packages/ui/src/components/Icon/icons/IconPresence/IconPresence.tsx b/packages/ui/src/components/Icon/icons/IconPresence/IconPresence.tsx new file mode 100644 index 00000000000..b774c034cb9 --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconPresence/IconPresence.tsx @@ -0,0 +1,26 @@ +// DO NOT EDIT +// @ts-ignore +import IconBase from './../../IconBase' +const SvgComponent = () => ( + <> + + + +) + +function IconPresence(props: any) { + return } viewBox="12.5 12 24 24" {...props} /> +} + +export default IconPresence diff --git a/packages/ui/src/components/Icon/icons/IconPresence/index.tsx b/packages/ui/src/components/Icon/icons/IconPresence/index.tsx new file mode 100644 index 00000000000..9d1252e78b2 --- /dev/null +++ b/packages/ui/src/components/Icon/icons/IconPresence/index.tsx @@ -0,0 +1 @@ +export { default as IconPresence } from './IconPresence' diff --git a/packages/ui/src/components/Icon/icons/index.tsx b/packages/ui/src/components/Icon/icons/index.tsx index 72cf06a101d..e12f7534453 100644 --- a/packages/ui/src/components/Icon/icons/index.tsx +++ b/packages/ui/src/components/Icon/icons/index.tsx @@ -39,6 +39,7 @@ export * as IconBookmark from './IconBookmark' export * as IconBox from './IconBox' export * as IconBriefcase from './IconBriefcase' export * as IconBriefcase2 from './IconBriefcase2' +export * as IconBroadcast from './IconBroadcast' export * as IconCalendar from './IconCalendar' export * as IconCameraOff from './IconCameraOff' export * as IconCamera from './IconCamera' @@ -86,6 +87,7 @@ export * as IconCreditCard from './IconCreditCard' export * as IconCrop from './IconCrop' export * as IconCrosshair from './IconCrosshair' export * as IconDatabase from './IconDatabase' +export * as IconDatabaseChanges from './IconDatabaseChanges' export * as IconDelete from './IconDelete' export * as IconDisc from './IconDisc' export * as IconDiscord from './IconDiscord' @@ -205,6 +207,7 @@ export * as IconPlusSquare from './IconPlusSquare' export * as IconPlus from './IconPlus' export * as IconPocket from './IconPocket' export * as IconPower from './IconPower' +export * as IconPresence from './IconPresence' export * as IconPrinter from './IconPrinter' export * as IconRadio from './IconRadio' export * as IconRefreshCcw from './IconRefreshCcw' diff --git a/packages/ui/src/components/Listbox/Listbox2.tsx b/packages/ui/src/components/Listbox/Listbox2.tsx index 7e18e98f82f..ba0f4b7736e 100644 --- a/packages/ui/src/components/Listbox/Listbox2.tsx +++ b/packages/ui/src/components/Listbox/Listbox2.tsx @@ -1,25 +1,19 @@ -/* This example requires Tailwind CSS v2.0+ */ -import React, { useEffect, useRef, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' -import { FormLayout } from '../../lib/Layout/FormLayout' - -import InputIconContainer from '../../lib/Layout/InputIconContainer' -import InputErrorIcon from '../../lib/Layout/InputErrorIcon' -import { IconCheck } from '../Icon/icons/IconCheck' - -import { useFormContext } from '../Form/FormContext' import { flatten } from 'lodash' +import React, { useEffect, useRef, useState } from 'react' -import { SelectContext } from './SelectContext' - +import { cn } from '@ui/lib/utils' +import { FormLayout } from '../../lib/Layout/FormLayout' +import InputErrorIcon from '../../lib/Layout/InputErrorIcon' +import InputIconContainer from '../../lib/Layout/InputIconContainer' import styleHandler from '../../lib/theme/styleHandler' - -function classNames(...classes: any) { - return classes.filter(Boolean).join(' ') -} +import { useFormContext } from '../Form/FormContext' +import { IconCheck } from '../Icon/icons/IconCheck' +import { SelectContext } from './SelectContext' export interface Props extends Omit, 'size'> { className?: string + buttonClassName?: string children: React.ReactNode descriptionText?: string | React.ReactNode error?: string @@ -34,7 +28,6 @@ export interface Props extends Omit actions?: React.ReactNode size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' defaultValue?: any - borderless?: boolean validation?: (x: any) => void optionsWidth?: number // override the button prop for onchange we only return a single value @@ -45,6 +38,7 @@ export interface Props extends Omit function Listbox({ children, className, + buttonClassName, descriptionText, error, icon, @@ -60,7 +54,6 @@ function Listbox({ style, size = 'medium', defaultValue, - borderless = false, validation, disabled, optionsWidth, @@ -187,7 +180,7 @@ function Listbox({ if (validation) fieldLevelValidation(id, validation(value)) } - let selectClasses = [__styles.container, __styles.base] + let selectClasses = [__styles.container, __styles.base, buttonClassName] if (error) selectClasses.push(__styles.variants.error) if (!error) selectClasses.push(__styles.variants.standard) // if (icon) selectClasses.push(SelectStyles['sbui-listbox--with-icon']) @@ -213,7 +206,7 @@ function Listbox({ + + +
+ {config.channelName.length === 0 ? ( + <> + +
+ { + setChannelName(e.target.value) + }} + /> + +
+

+ The channel you initialize with the Supabase Realtime client. Learn more in{' '} + + our docs + +

+ + ) : ( +
+
+

Currently using channel

+

+ {config.channelName} +

+
+

+ If you unset this channel, all of the messages populated on this page will disappear +

+ +
+ )} +
+
+
+
+ + +
+ onChangeConfig({ ...config, enablePresence: !config.enablePresence })} + /> +
+

+ Store and synchronize user state consistently across clients +

+
+
+
+
+ + +
+ + onChangeConfig({ ...config, enableBroadcast: !config.enableBroadcast }) + } + /> +
+

+ Send any data to any client subscribed to the same channel +

+
+
+
+
+ + +
+ + onChangeConfig({ ...config, enableDbChanges: !config.enableDbChanges }) + } + /> +
+

+ Listen for Database inserts, updates, deletes and more +

+
+
+ + ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/Header.tsx b/studio/components/interfaces/Realtime/Inspector/Header.tsx new file mode 100644 index 00000000000..bc4db794c1f --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/Header.tsx @@ -0,0 +1,35 @@ +import { PlayCircle, StopCircle } from 'lucide-react' +import { Dispatch, SetStateAction } from 'react' +import { Button } from 'ui' + +import { ChooseChannelPopover } from './ChooseChannelPopover' +import { RealtimeFilterPopover } from './RealtimeFilterPopover' +import { RealtimeTokensPopover } from './RealtimeTokensPopover' +import { RealtimeConfig } from './useRealtimeMessages' + +interface HeaderProps { + config: RealtimeConfig + onChangeConfig: Dispatch> +} + +export const Header = ({ config, onChangeConfig }: HeaderProps) => { + return ( +
+
+ + +
+ + +
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/MessageSelection.tsx b/studio/components/interfaces/Realtime/Inspector/MessageSelection.tsx new file mode 100644 index 00000000000..a690c7448a4 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/MessageSelection.tsx @@ -0,0 +1,87 @@ +import { useMemo } from 'react' +import { Button, IconX, cn } from 'ui' + +import CopyButton from 'components/ui/CopyButton' +import { LogData } from './Messages.types' +import { SelectedRealtimeMessagePanel } from './SelectedRealtimeMessagePanel' + +export interface MessageSelectionProps { + log: LogData | null + onClose: () => void +} + +const MessageSelection = ({ log, onClose }: MessageSelectionProps) => { + const selectionText = useMemo(() => { + return JSON.stringify(log, null, 2) + }, [log]) + + return ( +
+
+
+
+
+
+ + + +
+
+
+

Select an message

+

+ Click on a message on the left to view details. +

+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ {log && } +
+
+
+ ) +} + +export default MessageSelection diff --git a/studio/components/interfaces/Realtime/Inspector/Messages.types.ts b/studio/components/interfaces/Realtime/Inspector/Messages.types.ts new file mode 100644 index 00000000000..9ad446ce688 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/Messages.types.ts @@ -0,0 +1,15 @@ +interface Metadata { + [key: string]: string | number | Object | Object[] | any +} + +export interface CustomLogData { + [other: string]: unknown +} + +export interface PreviewLogData extends CustomLogData { + id: string + timestamp: number + message: string + metadata?: Metadata +} +export type LogData = CustomLogData & PreviewLogData diff --git a/studio/components/interfaces/Realtime/Inspector/Messages.utils.ts b/studio/components/interfaces/Realtime/Inspector/Messages.utils.ts new file mode 100644 index 00000000000..fd1e42e8618 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/Messages.utils.ts @@ -0,0 +1,14 @@ +import dayjs from 'dayjs' + +/** + * Convert a micro timestamp from number/string to iso timestamp + */ +export const unixMicroToIsoTimestamp = (unix: string | number): string => { + return dayjs.unix(Number(unix) / 1000).toISOString() +} + +export const isUnixMicro = (unix: string | number): boolean => { + const digitLength = String(unix).length === 16 + const isNum = !Number.isNaN(Number(unix)) + return isNum && digitLength +} diff --git a/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx b/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx new file mode 100644 index 00000000000..da6d08f97a4 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx @@ -0,0 +1,73 @@ +import { PropsWithChildren } from 'react' + +import CopyButton from 'components/ui/CopyButton' +import { unixMicroToIsoTimestamp } from './Messages.utils' + +export const RowLayout = ({ children }: PropsWithChildren<{}>) => ( +
{children}
+) +// renders a timestamp (either unix microsecond or iso timestamp) +export const SelectionDetailedTimestampRow = ({ value }: { value: string | number }) => ( + +) +export const SelectionDetailedRow = ({ + label, + value, + valueRender, +}: { + label: string + value: string + valueRender?: React.ReactNode +}) => { + return ( +
+ {label} + + {valueRender ?? value} + + +
+ ) +} + +/* + * JSON Syntax Highlighter + * + * for http response codes + */ +export function jsonSyntaxHighlight(input: Object) { + let json: string = JSON.stringify(input, null, 2) + json = json.replace(/&/g, '&').replace(//g, '>') + + const newJson = json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + + function (match) { + var cls = 'number text-tomato-900' + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'key text-scale-1200' + } else { + cls = 'string text-brand-600' + } + } else if (/true|false/.test(match)) { + cls = 'boolean text-blue-900' + } else if (/null/.test(match)) { + cls = 'null text-amber-1100' + } + return '' + match + '' + } + ) + + const jsonWithLineWraps = newJson.split(`\n`).map((x) => { + return `${x}` + }) + + return jsonWithLineWraps.join('\n') +} diff --git a/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx new file mode 100644 index 00000000000..7620eaf1001 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -0,0 +1,182 @@ +import { useParams } from 'common' +import { isEqual } from 'lodash' +import { Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { Key, useEffect, useState } from 'react' +import DataGrid, { RenderRowProps, Row } from 'react-data-grid' +import { Button, IconBroadcast, IconDatabaseChanges, IconExternalLink, IconPresence, cn } from 'ui' + +import ShimmerLine from 'components/ui/ShimmerLine' +import MessageSelection from './MessageSelection' +import { LogData } from './Messages.types' +import { ColumnRenderer } from './RealtimeMessageColumnRenderer' + +export const isErrorLog = (l: LogData) => { + return l.message === 'SYSTEM' && l.metadata?.status === 'error' +} + +interface MessagesTableProps { + enabled: boolean + data?: LogData[] + showSendMessage: () => void +} + +const NoResultAlert = ({ + enabled, + showSendMessage, +}: { + enabled: boolean + showSendMessage: () => void +}) => { + const router = useRouter() + const { ref } = useParams() + + return ( +
+ {enabled ?

No Realtime messages found

: null} +

Realtime message logs will be shown here

+ +
+
+ +
+

Create a Broadcast message

+

Start developing in preview

+
+ +
+
+ +
+

Join from another browser tab

+

+ Experiment with presence messages between multiple clients +

+
+ + + +
+ +
+ +
+

Listen to a table for changes

+

Start developing in preview

+
+ + + +
+
+
+

Not sure what to do?

+

Browse our documentation

+
+ +
+
+
+ ) +} + +const RowRenderer = (key: Key, props: RenderRowProps) => { + return +} + +const MessagesTable = ({ enabled, data = [], showSendMessage }: MessagesTableProps) => { + const [focusedLog, setFocusedLog] = useState(null) + const stringData = JSON.stringify(data) + + useEffect(() => { + if (!data) return + const found = data.find((datum) => datum.id === focusedLog?.id) + if (!found) { + setFocusedLog(null) + } + }, [stringData]) + + if (!data) return null + + return ( + <> +
+ +
+
+ {enabled && ( +
+
+ +
Listening
+
•
+
+ {data.length > 0 + ? data.length >= 100 + ? `Found a large number of messages, showing only the latest 100...` + : `Found ${data.length} messages...` + : `No message found yet...`} +
+
+ +
+ )} + + { + setFocusedLog(data[rowIdx]) + }} + selectedRows={new Set([])} + columns={ColumnRenderer} + rowClass={(row) => { + return cn([ + 'font-mono tracking-tight', + isEqual(row, focusedLog) + ? 'bg-scale-800 rdg-row--focused' + : ' bg-scale-200 hover:bg-scale-300 cursor-pointer', + isErrorLog(row) && '!bg-warning-300', + ]) + }} + rows={data} + rowKeyGetter={(row) => row.id} + renderers={{ + renderRow: RowRenderer, + noRowsFallback: ( +
+ +
+ ), + }} + /> +
+
+ setFocusedLog(null)} log={focusedLog} /> +
+
+
+ + ) +} +export default MessagesTable diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx new file mode 100644 index 00000000000..e17510760b4 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx @@ -0,0 +1,22 @@ +import SchemaSelector from 'components/ui/SchemaSelector' + +export const FilterSchema = ({ + value, + onChange, +}: { + value: string + onChange: (s: string) => void +}) => { + return ( +
+

WHERE

+ +
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSchema.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSchema.tsx new file mode 100644 index 00000000000..2603b647ebc --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSchema.tsx @@ -0,0 +1,24 @@ +import TableSelector from './TableSelector' + +export const TableSchema = ({ + value, + schema, + onChange, +}: { + value: string + schema: string + onChange: (v: string, id: number | undefined) => void +}) => { + return ( +
+

AND

+ +
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx new file mode 100644 index 00000000000..fced5f29c1a --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + IconCheck, + IconCode, + IconLoader, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + ScrollArea, +} from 'ui' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' + +interface TableSelectorProps { + className?: string + size?: 'tiny' | 'small' + showError?: boolean + selectedSchemaName: string + selectedTableName: string + onSelectTable: (name: string, id: number | undefined) => void +} + +const TableSelector = ({ + className, + size = 'tiny', + showError = true, + selectedSchemaName, + selectedTableName, + onSelectTable, +}: TableSelectorProps) => { + const [open, setOpen] = useState(false) + const { project } = useProjectContext() + + const { data, isLoading, isSuccess, isError, error, refetch } = useEntityTypesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: selectedSchemaName, + }) + + const entities = (data?.pages[0].data.entities ? [...data?.pages[0].data.entities] : []).sort( + (a, b) => (a.name > b.name ? 0 : -1) + ) + + return ( +
+ {isLoading && ( + + )} + + {showError && isError && ( + + + Failed to load tables + + + Error: {(error as any)?.message} + + + + )} + + {isSuccess && ( + + + + + + + + + No tables found + + 7 ? 'h-[210px]' : ''}> + { + onSelectTable('*', undefined) + setOpen(false) + }} + onClick={() => { + onSelectTable('*', undefined) + setOpen(false) + }} + > + All tables + {selectedSchemaName === '*' && ( + + )} + + {entities?.map((table) => ( + { + onSelectTable(table.name, table.id) + setOpen(false) + }} + onClick={() => { + onSelectTable(table.name, table.id) + setOpen(false) + }} + > + {table.name} + {selectedSchemaName === table.name && ( + + )} + + ))} + + + + + + + )} +
+ ) +} + +export default TableSelector diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx new file mode 100644 index 00000000000..c3946d529df --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx @@ -0,0 +1,126 @@ +import { PlusCircle } from 'lucide-react' +import { Dispatch, SetStateAction, useState } from 'react' +import { + Badge, + Button, + Input, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, +} from 'ui' + +import Link from 'next/link' +import { ApplyConfigModal } from '../ApplyConfigModal' +import { RealtimeConfig } from '../useRealtimeMessages' +import { FilterSchema } from './FilterSchema' +import { TableSchema } from './TableSchema' + +interface RealtimeFilterPopoverProps { + config: RealtimeConfig + onChangeConfig: Dispatch> +} + +export const RealtimeFilterPopover = ({ config, onChangeConfig }: RealtimeFilterPopoverProps) => { + const [open, setOpen] = useState(false) + const [applyConfigOpen, setApplyConfigOpen] = useState(false) + const [tempConfig, setTempConfig] = useState(config) + + const onOpen = (v: boolean) => { + // when opening, copy the outside config into the intermediate one + if (v === true) { + setTempConfig(config) + } + setOpen(v) + } + + const isFiltered = config.schema !== '*' + + return ( + <> + + + + + +
+ Filter incoming messages +
+
+ setTempConfig({ ...tempConfig, schema: v, table: '*' })} + /> + + setTempConfig({ ...tempConfig, table })} + /> +
+
+
+

AND

+ setTempConfig({ ...tempConfig, filter: v.target.value })} + /> +
+

+ Learn more about realtime filtering in{' '} + + our docs + +

+
+
+ + +
+
+
+ setApplyConfigOpen(false)} + onSelectConfirm={() => { + onChangeConfig(tempConfig) + setApplyConfigOpen(false) + setOpen(false) + }} + /> + + ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx new file mode 100644 index 00000000000..ac0315e1274 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx @@ -0,0 +1,44 @@ +import { Column } from 'react-data-grid' +import { IconAlertTriangle, IconBroadcast, IconDatabaseChanges, IconPresence, cn } from 'ui' + +import { LogData, PreviewLogData } from './Messages.types' +import { RowLayout } from './MessagesFormatters' +import { isErrorLog } from './MessagesTable' + +const ICONS = { + PRESENCE: , + BROADCAST: , + POSTGRES: , + SYSTEM: , +} + +export const ColumnRenderer: Column[] = [ + { + name: 'timestamp-with-truncated-text', + key: 'main-column', + renderCell: (data: { row: PreviewLogData }) => { + const type = data.row.message as keyof typeof ICONS + + return ( + +
+ {ICONS[type]} +
+ + {new Date(data.row.timestamp).toISOString()} + + + {JSON.stringify(data.row.metadata)} + +
+ ) + }, + }, +] diff --git a/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx new file mode 100644 index 00000000000..509c23b155a --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx @@ -0,0 +1,152 @@ +import { PlusCircle } from 'lucide-react' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { + Button, + IconKey, + IconUnlock, + IconUser, + Input, + Listbox, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' + +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' +import { RealtimeConfig } from '../useRealtimeMessages' +import Link from 'next/link' + +interface RealtimeTokensPopoverProps { + config: RealtimeConfig + onChangeConfig: Dispatch> +} + +export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokensPopoverProps) => { + const [open, setOpen] = useState(false) + const [tempConfig, setTempConfig] = useState(config) + + const { data: settings } = useProjectSettingsQuery({ projectRef: config.projectRef }) + + const apiService = (settings?.services ?? []).find( + (x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) + const apiKeys = apiService?.service_api_keys ?? [] + const selectedToken = apiKeys.find((k) => k.api_key === tempConfig.token) + const bearerEnabled = selectedToken?.tags === 'anon' + + const isService = config.token === apiKeys.find((key) => key.tags === 'service_role')?.api_key + + useEffect(() => { + const anonKey = apiKeys.find((k) => k.tags === 'anon') + if (anonKey) { + onChangeConfig({ ...config, token: anonKey.api_key }) + } + }, [apiKeys]) + + const onOpen = (v: boolean) => { + // when opening, copy the outside config into the intermediate one + if (v === true) { + setTempConfig(config) + } + setOpen(v) + } + + const onApply = () => { + onChangeConfig(tempConfig) + setOpen(false) + } + + return ( + + + + + +
+

Connection key settings

+ { + const selectedToken = apiKeys.find((k) => k.api_key === key) + if (selectedToken) { + if (selectedToken.tags === 'anon') { + setTempConfig({ ...config, token: key, bearer: '' }) + } else { + setTempConfig({ ...config, token: key, bearer: '' }) + } + } + }} + > + {apiKeys.map((key) => { + return ( + + {key.tags} + + ) + })} + + {selectedToken?.tags === 'service_role' ? ( + + The Service role is a predefined Postgres + role with elevated privileges. It will bypass all Row Level Security (RLS) policies. + + ) : ( + + The anonymous key will respect Row Level + Security (RLS) policies. + + )} +
+ {bearerEnabled && ( +
+

Impersonate a user

+

+ Provide a JWT token of a user to receive events from the perspective of the user. This + respects RLS policies relevant for that particular user. +

+ } + size="small" + className="flex-grow mt-2" + placeholder="Enter JWT token of a user" + defaultValue={tempConfig.bearer || ''} + onChange={(v) => setTempConfig({ ...tempConfig, bearer: v.target.value })} + /> +

+ Learn more about JWT tokens in{' '} + + our docs + +

+
+ )} +
+ + +
+
+
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx b/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx new file mode 100644 index 00000000000..dedc56adffd --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx @@ -0,0 +1,38 @@ +import { LogData } from './Messages.types' +import { jsonSyntaxHighlight, SelectionDetailedTimestampRow } from './MessagesFormatters' + +const LogsDivider = () => { + return ( +
+ ) +} + +export const SelectedRealtimeMessagePanel = ({ log }: { log: LogData }) => { + return ( + <> +
+ Message + +

+ {log.message} +

+
+ +
+ +
+ +
+

Metadata

+
+          
+
+
+ + ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/SendMessageModal.tsx b/studio/components/interfaces/Realtime/Inspector/SendMessageModal.tsx new file mode 100644 index 00000000000..8dce33c9b25 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/SendMessageModal.tsx @@ -0,0 +1,72 @@ +import { Input, Modal } from 'ui' + +import CodeEditor from 'components/ui/CodeEditor' +import { useEffect, useState } from 'react' +import { tryParseJson } from 'lib/helpers' + +interface SendMessageModalProps { + visible: boolean + onSelectCancel: () => void + onSelectConfirm: (v: { message: string; payload: string }) => void +} + +export const SendMessageModal = ({ + visible, + onSelectCancel, + onSelectConfirm, +}: SendMessageModalProps) => { + const [error, setError] = useState() + const [values, setValues] = useState({ message: 'test', payload: '{}' }) + + useEffect(() => { + if (visible) { + setError(undefined) + setValues({ message: 'test', payload: '{}' }) + } + }, [visible]) + + return ( + { + const payload = tryParseJson(values.payload) + if (payload === undefined) { + setError('Please provide a valid JSON') + } else { + onSelectConfirm(values) + } + }} + > + +
+ setValues({ ...values, message: v.target.value })} + /> +
+

Message payload

+ + setValues({ ...values, payload: e ?? '{}' }) + } + options={{ wordWrap: 'off', contextmenu: false }} + value={values.payload} + /> + {error !== undefined &&

{error}

} +
+
+
+
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/index.tsx b/studio/components/interfaces/Realtime/Inspector/index.tsx new file mode 100644 index 00000000000..a78b2aa819d --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/index.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' + +import { useParams } from 'common' +import { Header } from './Header' +import MessagesTable from './MessagesTable' +import { SendMessageModal } from './SendMessageModal' +import { RealtimeConfig, useRealtimeMessages } from './useRealtimeMessages' + +/** + * Acts as a container component for the entire log display + */ +export const RealtimeInspector = () => { + const { ref } = useParams() + const [sendMessageShown, setSendMessageShown] = useState(false) + + const [realtimeConfig, setRealtimeConfig] = useState({ + enabled: false, + projectRef: ref!, + channelName: '', + logLevel: 'info', + token: '', // will be filled out by RealtimeTokensPopover + schema: '*', + table: '*', + filter: undefined, + bearer: null, + enablePresence: true, + enableDbChanges: true, + enableBroadcast: true, + }) + + const { logData, sendMessage } = useRealtimeMessages(realtimeConfig) + + return ( +
+
+
+
+ setSendMessageShown(true)} + /> +
+
+ setSendMessageShown(false)} + onSelectConfirm={(v) => { + sendMessage(v.message, v.payload) + setSendMessageShown(false) + }} + /> +
+ ) +} diff --git a/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts b/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts new file mode 100644 index 00000000000..f16eaca6627 --- /dev/null +++ b/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts @@ -0,0 +1,214 @@ +import { createClient, RealtimeChannel, SupabaseClient } from '@supabase/supabase-js' +import { sortBy, take } from 'lodash' +import { useCallback, useEffect, useReducer, useState } from 'react' +import toast from 'react-hot-toast' + +import { useProjectApiQuery } from 'data/config/project-api-query' +import { uuidv4 } from 'lib/helpers' +import { EMPTY_ARR } from 'lib/void' +import { LogData } from './Messages.types' + +function reducer( + state: LogData[], + action: { type: 'add'; payload: { messageType: string; metadata: any } } | { type: 'clear' } +) { + if (action.type === 'clear') { + return EMPTY_ARR + } + + const newState = take( + sortBy( + [ + { + id: uuidv4(), + timestamp: new Date().getTime(), + message: action.payload.messageType, + metadata: action.payload.metadata, + } as LogData, + ...state, + ], + (l) => -l.timestamp + ), + 100 + ) + + return newState +} + +export interface RealtimeConfig { + enabled: boolean + channelName: string + projectRef: string + logLevel: string + token: string + schema: string + table: string + filter: string | undefined + bearer: string | null + enableBroadcast: boolean + enablePresence: boolean + enableDbChanges: boolean +} + +export const useRealtimeMessages = ({ + enabled, + channelName, + projectRef, + logLevel, + token, + schema, + table, + filter, + bearer, + enablePresence, + enableDbChanges, + enableBroadcast, +}: RealtimeConfig) => { + // the default host is prod until the correct one comes through an API call. + const [host, setHost] = useState(`https://${projectRef}.supabase.co`) + useProjectApiQuery( + { projectRef: projectRef }, + { + onSuccess: (data) => { + setHost(`${data.autoApiService.protocol}://${data.autoApiService.endpoint}`) + }, + } + ) + + const [logData, dispatch] = useReducer(reducer, [] as LogData[]) + const pushMessage = (messageType: string, metadata: any) => { + dispatch({ type: 'add', payload: { messageType, metadata } }) + } + + // Instantiate our client with the Realtime server and params to connect with + let [client, setClient] = useState | undefined>() + let [channel, setChannel] = useState() + + useEffect(() => { + if (!enabled) { + return + } + const opts = { + realtime: { + params: { + log_level: logLevel, + }, + }, + } + const newClient = createClient(host, token, opts) + if (bearer != '') { + newClient.realtime.setAuth(bearer) + } + + setClient(newClient) + return () => { + client?.realtime.disconnect() + setClient(undefined) + } + }, [enabled, bearer, host, logLevel, token]) + + useEffect(() => { + if (!client) { + return + } + dispatch({ type: 'clear' }) + const newChannel = client?.channel(channelName, { + config: { broadcast: { self: true } }, + }) + // Hack to confirm Postgres is subscribed + // Need to add 'extension' key in the 'payload' + newChannel.on('system' as any, {} as any, (payload: any) => { + // if (payload.extension === 'postgres_changes' && payload.status === 'ok') { + // pushMessageTo('#conn_info', 'postgres_subscribed', {}) + // } + pushMessage('SYSTEM', payload) + }) + + if (enableBroadcast) { + // Listen for all (`*`) `broadcast` messages + // The message name can by anything + // Match on specific message names to filter for only those types of messages and do something with them + newChannel.on('broadcast', { event: '*' }, (payload) => pushMessage('BROADCAST', payload)) + } + + // Listen for all (`*`) `presence` messages + if (enablePresence) { + newChannel.on('presence' as any, { event: '*' }, (payload) => { + pushMessage('PRESENCE', payload) + }) + } + + if (enableDbChanges) { + let postgres_changes_opts: any = { + event: '*', + schema: schema, + table: table, + filter: undefined, + } + if (filter !== '') { + postgres_changes_opts.filter = filter + } + newChannel.on('postgres_changes' as any, postgres_changes_opts, (payload: any) => { + let ts = performance.now() + performance.timeOrigin + let payload_ts = Date.parse(payload.commit_timestamp) + let latency = ts - payload_ts + pushMessage('POSTGRES', { ...payload, latency }) + }) + } + + // Finally, subscribe to the Channel we just setup + newChannel.subscribe(async (status, error) => { + if (status === 'SUBSCRIBED') { + // Let LiveView know we connected so we can update the button text + // pushMessageTo('#conn_info', 'broadcast_subscribed', { host: host }) + + if (enablePresence) { + const name = 'user_name_' + Math.floor(Math.random() * 100) + newChannel.send({ + type: 'presence', + event: 'TRACK', + payload: { name: name, t: performance.now() }, + }) + } + } else if (status === 'CLOSED') { + // console.log(`Realtime Channel status: ${status}`) + } else { + // console.error(`Realtime Channel error status: ${status}`) + // console.error(`Realtime Channel error: ${error}`) + } + }) + + setChannel(newChannel) + return () => { + newChannel.unsubscribe() + setChannel(undefined) + } + }, [ + client, + channelName, + enableBroadcast, + enableDbChanges, + enablePresence, + filter, + host, + schema, + table, + ]) + + const sendMessage = useCallback( + (message: string, payload: any) => { + if (channel) { + channel.send({ + type: 'broadcast', + event: message, + payload, + }) + toast.success('Successfully broadcasted message') + } else { + toast.error('Failed to broadcast message: channel has not been set') + } + }, + [channel] + ) + return { logData, sendMessage } +} diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index cbf55dc059a..89cefe2f49c 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -58,6 +58,7 @@ const NavigationBar = () => { projectEdgeFunctionAll: edgeFunctionsEnabled, projectStorageAll: storageEnabled, } = useIsFeatureEnabled(['project_auth:all', 'project_edge_function:all', 'project_storage:all']) + const realtimeEnabled = useFlag('realtimeDashboard') const activeRoute = router.pathname.split('/')[3] const toolRoutes = generateToolRoutes(projectRef, project, supabaseAIEnabled) @@ -65,6 +66,7 @@ const NavigationBar = () => { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, storage: storageEnabled, + realtime: realtimeEnabled, }) const otherRoutes = generateOtherRoutes(projectRef, project, isNewAPIDocsEnabled) diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 33ce25be3ac..4ecbb2dbdd6 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -51,7 +51,7 @@ export const generateToolRoutes = ( export const generateProductRoutes = ( ref?: string, project?: ProjectBase, - features?: { auth?: boolean; edgeFunctions?: boolean; storage?: boolean } + features?: { auth?: boolean; edgeFunctions?: boolean; storage?: boolean; realtime?: boolean } ): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}/building` @@ -59,6 +59,7 @@ export const generateProductRoutes = ( const authEnabled = features?.auth ?? true const edgeFunctionsEnabled = features?.edgeFunctions ?? true const storageEnabled = features?.storage ?? true + const realtimeEnabled = features?.realtime ?? true return [ { @@ -165,6 +166,33 @@ export const generateProductRoutes = ( }, ] : []), + ...(IS_PLATFORM && realtimeEnabled + ? [ + { + key: 'realtime', + label: 'Realtime', + icon: ( + + + + ), + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`), + }, + ] + : []), ] } diff --git a/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx b/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx new file mode 100644 index 00000000000..252767497bd --- /dev/null +++ b/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx @@ -0,0 +1,31 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' + +import ProductMenu from 'components/ui/ProductMenu' +import { useSelectedProject, useStore, withAuth } from 'hooks' +import ProjectLayout from '../' +import { generateRealtimeMenu } from './RealtimeMenu.utils' + +export interface RealtimeLayoutProps { + title?: string +} + +const RealtimeLayout = ({ children }: PropsWithChildren) => { + const { meta } = useStore() + const project = useSelectedProject() + + const router = useRouter() + const page = router.pathname.split('/')[4] + + return ( + } + > +
{children}
+
+ ) +} + +export default withAuth(RealtimeLayout) diff --git a/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts b/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts new file mode 100644 index 00000000000..dde4482c3c3 --- /dev/null +++ b/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts @@ -0,0 +1,20 @@ +import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import { Project } from 'types' + +export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => { + const ref = project?.ref ?? 'default' + + return [ + { + title: 'Realtime', + items: [ + { + name: 'Inspector', + key: 'inspector', + url: `/project/${ref}/realtime/inspector`, + items: [], + }, + ], + }, + ] +} diff --git a/studio/components/ui/AlertError.tsx b/studio/components/ui/AlertError.tsx index 5cd6690316d..4002e66a0ae 100644 --- a/studio/components/ui/AlertError.tsx +++ b/studio/components/ui/AlertError.tsx @@ -1,7 +1,6 @@ import Link from 'next/link' import { ResponseError } from 'types' import { - Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, @@ -28,7 +27,7 @@ const AlertError = ({ ref, subject, error, className }: AlertErrorProps) => { return ( - + {subject}
diff --git a/studio/components/ui/SchemaSelector.tsx b/studio/components/ui/SchemaSelector.tsx index fa49707b839..f90227b9e9f 100644 --- a/studio/components/ui/SchemaSelector.tsx +++ b/studio/components/ui/SchemaSelector.tsx @@ -28,6 +28,7 @@ interface SchemaSelectorProps { size?: 'tiny' | 'small' showError?: boolean selectedSchemaName: string + supportSelectAll?: boolean onSelectSchema: (name: string) => void onSelectCreateSchema?: () => void } @@ -37,6 +38,7 @@ const SchemaSelector = ({ size = 'tiny', showError = true, selectedSchemaName, + supportSelectAll = false, onSelectSchema, onSelectCreateSchema, }: SchemaSelectorProps) => { @@ -65,10 +67,8 @@ const SchemaSelector = ({ className="w-full [&>span]:w-full" icon={} > -
-
-

Loading schemas...

-
+
+

Loading schemas...

)} @@ -100,7 +100,9 @@ const SchemaSelector = ({ >

schema

-

{selectedSchemaName}

+

+ {selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName} +

@@ -111,6 +113,25 @@ const SchemaSelector = ({ No schemas found 7 ? 'h-[210px]' : ''}> + {supportSelectAll && ( + { + onSelectSchema('*') + setOpen(false) + }} + onClick={() => { + onSelectSchema('*') + setOpen(false) + }} + > + All schemas + {selectedSchemaName === '*' && ( + + )} + + )} {schemas?.map((schema) => ( {schema.name} - {schema.name === selectedSchemaName && ( + {selectedSchemaName === schema.name && ( )} diff --git a/studio/pages/project/[ref]/realtime/inspector.tsx b/studio/pages/project/[ref]/realtime/inspector.tsx new file mode 100644 index 00000000000..e23f5f7f97c --- /dev/null +++ b/studio/pages/project/[ref]/realtime/inspector.tsx @@ -0,0 +1,21 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { NextPageWithLayout } from 'types' + +import { RealtimeInspector } from 'components/interfaces/Realtime/Inspector' +import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions } from 'hooks' + +export const InspectorPage: NextPageWithLayout = () => { + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + + if (!canReadAPIKeys) { + return + } + + return +} + +InspectorPage.getLayout = (page) => {page} + +export default InspectorPage diff --git a/studio/styles/react-data-grid-logs.scss b/studio/styles/react-data-grid-logs.scss index a3396962b92..ba3f2440cbd 100644 --- a/studio/styles/react-data-grid-logs.scss +++ b/studio/styles/react-data-grid-logs.scss @@ -1,7 +1,7 @@ .data-grid--simple-logs { .rdg-cell, .rdg-cell span { - @apply text-foreground-light font-normal #{!important}; + @apply text-foreground-light font-normal; border-left: none; border-right: none; }