@@ -314,10 +308,7 @@ function SelectOption({
{active ? (
diff --git a/studio/components/interfaces/Realtime/Inspector/ApplyConfigModal.tsx b/studio/components/interfaces/Realtime/Inspector/ApplyConfigModal.tsx
new file mode 100644
index 00000000000..2cc3aaff8e9
--- /dev/null
+++ b/studio/components/interfaces/Realtime/Inspector/ApplyConfigModal.tsx
@@ -0,0 +1,31 @@
+import { Modal } from 'ui'
+
+import ConfirmationModal from 'components/ui/ConfirmationModal'
+
+interface ApplyConfigModalProps {
+ visible: boolean
+ onSelectCancel: () => void
+ onSelectConfirm: () => void
+}
+
+export const ApplyConfigModal = (props: ApplyConfigModalProps) => {
+ return (
+
+
+
+
+
+ The realtime inspector will clear currently collected messages and start listening for
+ new messages matching the updated filters.
+
+
+
+
+ )
+}
diff --git a/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx b/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx
new file mode 100644
index 00000000000..353f7b1f161
--- /dev/null
+++ b/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx
@@ -0,0 +1,176 @@
+import { Dispatch, SetStateAction, useState } from 'react'
+import {
+ Button,
+ IconBroadcast,
+ IconChevronDown,
+ IconDatabaseChanges,
+ IconPresence,
+ Input,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ Popover_Shadcn_,
+ Toggle,
+} from 'ui'
+
+import Link from 'next/link'
+import { RealtimeConfig } from '../useRealtimeMessages'
+
+interface ChooseChannelPopoverProps {
+ config: RealtimeConfig
+ onChangeConfig: Dispatch
>
+}
+
+export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPopoverProps) => {
+ const [open, setOpen] = useState(false)
+ const [channelName, setChannelName] = useState(config.channelName)
+
+ const onOpen = (v: boolean) => {
+ // when opening, copy the outside config into the intermediate one
+ if (v === true) {
+ setChannelName(config.channelName)
+ }
+ setOpen(v)
+ }
+
+ return (
+
+
+ }
+ >
+ 0 ? config.channelName : ''}
+ >
+ {config.channelName.length > 0 ? `Channel: ${config.channelName}` : 'Choose channel'}
+
+
+
+
+
+ {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 (
+
+
+
+
:
}
+ onClick={() => onChangeConfig({ ...config, enabled: !config.enabled })}
+ >
+ {config.enabled ? `Stop listening` : `Start listening`}
+
+
+
+
+
+ )
+}
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.
+
+
+
+
+
+
+ )
+}
+
+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
+
+
+
+
}>
+ Open inspector
+
+
+
+
+
+
+
+
Listen to a table for changes
+
Start developing in preview
+
+
+
}>
+ Replication settings
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
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 (
+
+ )
+}
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 && (
+
}
+ >
+
Loading tables...
+
+ )}
+
+ {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 (
+ <>
+
+
+ }
+ type={isFiltered ? 'primary' : 'dashed'}
+ className={cn('rounded-full px-1.5 text-xs', isFiltered ? '!py-0.5' : '!py-1')}
+ size="small"
+ >
+ {isFiltered ? (
+ <>
+ Filtered by
+
+ schema: {config.schema === '*' ? 'All schemas' : config.schema}
+
+ >
+ ) : (
+ Filter messages
+ )}
+
+ {config.table !== '*' ? (
+ <>
+ and
+ table: {config.table}
+ >
+ ) : null}
+
+
+
+
+ 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 (
+
+
+ }
+ type="dashed"
+ className="rounded-full px-1.5 pr-2 !py-1 text-xs"
+ size="small"
+ >
+ {isService
+ ? 'Bypassing RLS policies'
+ : config.bearer
+ ? 'Viewing events as user'
+ : 'Test RLS policies'}
+
+
+
+
+
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}
+
+
+
+
+
+
+
+
+ >
+ )
+}
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={
}
>
-
-
+
)}
@@ -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;
}