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 <joshenlimek@gmail.com>
This commit is contained in:
Ivan VasilovandJoshen Lim authored and GitHub committed 2023-11-06 15:32:20 +08:00
1 parent c5593f7a5e
commit e02181aba8
36 files changed
+1774 -37

No files matched your search

+3
View File
@@ -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'
+2 -1
View File
@@ -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 {
@@ -0,0 +1,36 @@
import IconBase from './../../IconBase'
const SvgComponent = () => (
<>
<path
id="Ellipse 146"
d="M17.3154 6.70312C18.6968 8.06227 19.5532 9.95249 19.5532 12.0426C19.5532 14.1326 18.6968 16.0229 17.3154 17.382M6.79102 6.70312C5.40966 8.06227 4.55322 9.95249 4.55322 12.0426C4.55322 14.1326 5.40966 16.0229 6.79102 17.382"
stroke="currentCollor"
strokeMiterlimit="10"
strokeLinejoin="bevel"
/>
<ellipse
id="Ellipse 144"
cx="12.0532"
cy="12.0428"
rx="3.00928"
ry="3.00666"
stroke="currentCollor"
strokeMiterlimit="10"
strokeLinejoin="bevel"
/>
<path
id="Vector 96"
d="M12.0747 15.0488L12.0747 23.9996"
stroke="currentCollor"
strokeMiterlimit="10"
strokeLinejoin="bevel"
/>
</>
)
function IconBroadcast(props: any) {
return <IconBase src={<SvgComponent />} viewBox="0 0 24 24" {...props} />
}
export default IconBroadcast
@@ -0,0 +1 @@
export { default as IconBroadcast } from './IconBroadcast'
@@ -0,0 +1,38 @@
// DO NOT EDIT
// @ts-ignore
import IconBase from './../../IconBase'
const SvgComponent = () => (
<svg xmlns="http://www.w3.org/2000/svg" width={25} height={24} fill="none">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="bevel"
strokeMiterlimit={10}
d="M20.307 12a7.807 7.807 0 0 1-7.807 7.808M4.693 12A7.807 7.807 0 0 1 12.5 4.193"
opacity={0.33}
/>
<circle
cx={17.512}
cy={6.971}
r={3.723}
fill="currentColor"
stroke="currentColor"
strokeLinejoin="bevel"
strokeMiterlimit={10}
/>
<path
fill="currentColor"
stroke="currentColor"
strokeLinejoin="bevel"
strokeMiterlimit={10}
d="m10.11 13.287 2.137 3.703-2.138 3.703H5.833L3.695 16.99l2.138-3.703h4.276Z"
/>
</svg>
)
function IconDatabaseChanges(props: any) {
return <IconBase src={<SvgComponent />} viewBox="0 0 25 24" {...props} />
}
export default IconDatabaseChanges
@@ -0,0 +1 @@
export { default as IconDatabaseChanges } from './IconDatabaseChanges'
@@ -0,0 +1,26 @@
// DO NOT EDIT
// @ts-ignore
import IconBase from './../../IconBase'
const SvgComponent = () => (
<>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeMiterlimit={10}
d="m23.149 23.499 3.424 7.83a.5.5 0 0 0 .942-.074l.74-2.868a.5.5 0 0 1 .364-.36l3.039-.756a.5.5 0 0 0 .08-.943l-7.93-3.487a.5.5 0 0 0-.66.658Z"
/>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeMiterlimit={10}
d="M24.544 32.746h-5.623a3 3 0 0 1-3-3V18.5a3 3 0 0 1 3-3h11.247a3 3 0 0 1 3 3v5.623"
opacity={0.33}
/>
</>
)
function IconPresence(props: any) {
return <IconBase src={<SvgComponent />} viewBox="12.5 12 24 24" {...props} />
}
export default IconPresence
@@ -0,0 +1 @@
export { default as IconPresence } from './IconPresence'
@@ -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'
+17 -26
View File
@@ -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<React.InputHTMLAttributes<HTMLButtonElement>, 'size'> {
className?: string
buttonClassName?: string
children: React.ReactNode
descriptionText?: string | React.ReactNode
error?: string
@@ -34,7 +28,6 @@ export interface Props extends Omit<React.InputHTMLAttributes<HTMLButtonElement>
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<React.InputHTMLAttributes<HTMLButtonElement>
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({
<DropdownMenuPrimitive.Trigger asChild disabled={disabled}>
<button
ref={triggerRef}
className={selectClasses.join(' ')}
className={cn(selectClasses)}
onBlur={handleBlurEvent}
onFocus={onFocus}
name={name}
@@ -298,11 +291,12 @@ function SelectOption({
return (
<DropdownMenuPrimitive.Item
key={id}
className={`${classNames(
className={cn(
__styles.option,
active ? __styles.option_active : ' ',
disabled ? __styles.option_disabled : ' '
)} ${className}`}
disabled ? __styles.option_disabled : ' ',
className
)}
onSelect={() => (!disabled ? onChange(value) : {})}
>
<div className={__styles.option_inner}>
@@ -314,10 +308,7 @@ function SelectOption({
{active ? (
<span
className={classNames(
__styles.option_check,
active ? __styles.option_check_active : ''
)}
className={cn(__styles.option_check, active ? __styles.option_check_active : '')}
>
<IconCheck className={__styles.option_check_icon} aria-hidden="true" />
</span>
@@ -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 (
<ConfirmationModal
header="Previously found messages will be lost"
danger
buttonLabel="Confirm"
size="small"
{...props}
>
<Modal.Content>
<div className="py-4">
<p className="text-sm text-foreground"></p>
<p className="text-sm text-foreground-light">
The realtime inspector will clear currently collected messages and start listening for
new messages matching the updated filters.
</p>
</div>
</Modal.Content>
</ConfirmationModal>
)
}
@@ -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<SetStateAction<RealtimeConfig>>
}
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 (
<Popover_Shadcn_ open={open} onOpenChange={onOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
className="rounded-r-none"
type="default"
size="tiny"
iconRight={<IconChevronDown />}
>
<p
className="max-w-[120px] truncate"
title={config.channelName.length > 0 ? config.channelName : ''}
>
{config.channelName.length > 0 ? `Channel: ${config.channelName}` : 'Choose channel'}
</p>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0" align="start">
<div className="border-b border-overlay p-4 flex flex-col text-sm">
{config.channelName.length === 0 ? (
<>
<label className="text-foreground text-xs mb-2">Name of channel</label>
<div className="flex flex-row">
<Input
size="tiny"
className="w-full"
inputClassName="rounded-r-none"
placeholder="Enter a channel name"
value={channelName}
onChange={(e) => {
setChannelName(e.target.value)
}}
/>
<Button
type="default"
className="rounded-l-none"
disabled={channelName.length === 0}
onClick={() => {
setOpen(false)
onChangeConfig({ ...config, channelName })
}}
>
Set channel
</Button>
</div>
<p className="text-xs text-foreground-lighter mt-2">
The channel you initialize with the Supabase Realtime client. Learn more in{' '}
<Link
href="https://supabase.com/docs/guides/realtime/concepts#channels"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground transition"
>
our docs
</Link>
</p>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-xs">Currently using channel</p>
<p className="text-xs border border-scale-600 py-0.5 px-1 rounded-md bg-surface-200">
{config.channelName}
</p>
</div>
<p className="text-xs text-foreground-lighter mt-2">
If you unset this channel, all of the messages populated on this page will disappear
</p>
<Button
type="default"
onClick={() => onChangeConfig({ ...config, channelName: '', enabled: false })}
>
Unset channel
</Button>
</div>
)}
</div>
<div className="border-b border-overlay p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2.5 items-center">
<IconPresence size="xlarge" className="bg-brand-400 rounded text-brand-600" />
<label htmlFor="toggle-presence" className="text-sm">
Presence
</label>
</div>
<Toggle
id="toggle-presence"
size="tiny"
checked={config.enablePresence}
onChange={() => onChangeConfig({ ...config, enablePresence: !config.enablePresence })}
/>
</div>
<p className="text-xs text-foreground-light pt-1">
Store and synchronize user state consistently across clients
</p>
</div>
<div className="border-b border-overlay p-4">
<div className="flex items-center justify-between">
<div className="flex gap-2.5 items-center">
<IconBroadcast size="xlarge" className="bg-brand-400 rounded text-brand-600" />
<label htmlFor="toggle-broadcast" className="text-sm">
Broadcast
</label>
</div>
<Toggle
id="toggle-broadcast"
size="tiny"
checked={config.enableBroadcast}
onChange={() =>
onChangeConfig({ ...config, enableBroadcast: !config.enableBroadcast })
}
/>
</div>
<p className="text-xs text-foreground-light pt-1">
Send any data to any client subscribed to the same channel
</p>
</div>
<div className="border-b border-overlay p-4">
<div className="flex items-center justify-between">
<div className="flex gap-2.5 items-center">
<IconDatabaseChanges size="xlarge" className="bg-brand-400 rounded text-brand-600" />
<label htmlFor="toggle-db-changes" className="text-sm">
Database changes
</label>
</div>
<Toggle
id="toggle-db-changes"
size="tiny"
checked={config.enableDbChanges}
onChange={() =>
onChangeConfig({ ...config, enableDbChanges: !config.enableDbChanges })
}
/>
</div>
<p className="text-xs text-foreground-light pt-1">
Listen for Database inserts, updates, deletes and more
</p>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -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<SetStateAction<RealtimeConfig>>
}
export const Header = ({ config, onChangeConfig }: HeaderProps) => {
return (
<div className="flex flex-row h-14 gap-2.5 items-center px-4">
<div className="flex flex-row">
<ChooseChannelPopover config={config} onChangeConfig={onChangeConfig} />
<Button
size="tiny"
type={config.enabled ? 'warning' : 'primary'}
className="rounded-l-none border-l-0"
disabled={config.channelName.length === 0}
icon={config.enabled ? <StopCircle size="16" /> : <PlayCircle size="16" />}
onClick={() => onChangeConfig({ ...config, enabled: !config.enabled })}
>
{config.enabled ? `Stop listening` : `Start listening`}
</Button>
</div>
<RealtimeTokensPopover config={config} onChangeConfig={onChangeConfig} />
<RealtimeFilterPopover config={config} onChangeConfig={onChangeConfig} />
</div>
)
}
@@ -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 (
<div
className={cn(
'relative flex h-full flex-grow flex-col border-l border-t-2 overflow-y-scroll bg-gray-200'
)}
>
<div
className={cn(
'absolute flex h-full w-full flex-col items-center justify-center gap-2 bg-scale-200 text-center opacity-0 transition-all',
log ? 'z-0 opacity-0' : 'z-10 opacity-100'
)}
>
<div
className={cn(
'flex w-full max-w-sm scale-95 flex-col items-center justify-center gap-6 text-center opacity-0 transition-all delay-300 duration-500',
log ? 'mt-0 scale-95 opacity-0' : 'mt-8 scale-100 opacity-100'
)}
>
<div className="relative flex h-4 w-32 items-center rounded border border-scale-600 px-2 dark:border-scale-400">
<div className="h-0.5 w-2/3 rounded-full bg-scale-600 dark:bg-scale-500"></div>
<div className="absolute right-1 -bottom-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
</div>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-foreground">Select an message</h3>
<p className="text-xs text-foreground-lighter">
Click on a message on the left to view details.
</p>
</div>
</div>
</div>
<div className="relative h-px flex-grow">
<div className="pt-4 flex flex-col gap-4">
<div className="px-4 flex flex-row justify-between items-center">
<div className="transition">
<CopyButton text={selectionText} type="default" title="Copy log to clipboard" />
</div>
<Button
type="text"
className="cursor-pointer transition hover:text-scale-1200 h-8 w-8 px-0 py-0 flex items-center justify-center"
onClick={onClose}
>
<IconX size={14} strokeWidth={2} className="text-scale-900" />
</Button>
</div>
<div className="h-px w-full bg-scale-600 rounded" />
</div>
<div className="flex flex-col space-y-6 py-4">
{log && <SelectedRealtimeMessagePanel log={log} />}
</div>
</div>
</div>
)
}
export default MessageSelection
@@ -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
@@ -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
}
@@ -0,0 +1,73 @@
import { PropsWithChildren } from 'react'
import CopyButton from 'components/ui/CopyButton'
import { unixMicroToIsoTimestamp } from './Messages.utils'
export const RowLayout = ({ children }: PropsWithChildren<{}>) => (
<div className="flex h-full w-full items-center gap-4">{children}</div>
)
// renders a timestamp (either unix microsecond or iso timestamp)
export const SelectionDetailedTimestampRow = ({ value }: { value: string | number }) => (
<SelectionDetailedRow label="Timestamp" value={unixMicroToIsoTimestamp(value)} />
)
export const SelectionDetailedRow = ({
label,
value,
valueRender,
}: {
label: string
value: string
valueRender?: React.ReactNode
}) => {
return (
<div className="grid grid-cols-12 group">
<span className="text-scale-900 text-sm col-span-4 whitespace-pre-wrap">{label}</span>
<span className="text-scale-1200 text-sm col-span-6 whitespace-pre-wrap break-all">
{valueRender ?? value}
</span>
<CopyButton
bounceIconOnCopy
text={value}
className="group-hover:opacity-100 opacity-0 my-auto transition col-span-2 h-4 w-4 px-0 py-0"
type="text"
title="Copy to clipboard"
/>
</div>
)
}
/*
* JSON Syntax Highlighter
*
* for http response codes
*/
export function jsonSyntaxHighlight(input: Object) {
let json: string = JSON.stringify(input, null, 2)
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 '<span class="' + cls + '">' + match + '</span>'
}
)
const jsonWithLineWraps = newJson.split(`\n`).map((x) => {
return `<span class="line text-xs">${x}</span>`
})
return jsonWithLineWraps.join('\n')
}
@@ -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 (
<div className="w-full max-w-md flex items-center flex-col">
{enabled ? <p>No Realtime messages found</p> : null}
<p className="text-foreground-lighter">Realtime message logs will be shown here</p>
<div className="mt-4 border bg-surface-100 border-border rounded-md justify-start items-center flex flex-col w-full">
<div className="w-full px-5 py-4 items-center gap-4 inline-flex border-b">
<IconBroadcast size="xlarge" className="bg-brand-400 rounded w-6 text-brand-600" />
<div className="grow flex-col flex">
<p className="text-foreground">Create a Broadcast message</p>
<p className="text-foreground-lighter text-xs">Start developing in preview</p>
</div>
<Button type="default" onClick={showSendMessage}>
Send a test message
</Button>
</div>
<div className="w-full px-5 py-4 items-center gap-4 inline-flex border-b">
<IconPresence size="xlarge" className="bg-brand-400 rounded w-6 text-brand-600" />
<div className="grow flex-col flex">
<p className="text-foreground">Join from another browser tab</p>
<p className="text-foreground-lighter text-xs">
Experiment with presence messages between multiple clients
</p>
</div>
<Link href={`${router.basePath}${router.asPath}`} target="_blank">
<Button type="default" iconRight={<IconExternalLink />}>
Open inspector
</Button>
</Link>
</div>
<div className="w-full px-5 py-4 items-center gap-4 inline-flex border-b">
<IconDatabaseChanges size="xlarge" className="bg-brand-400 rounded w-6 text-brand-600" />
<div className="grow flex-col flex">
<p className="text-foreground">Listen to a table for changes</p>
<p className="text-foreground-lighter text-xs">Start developing in preview</p>
</div>
<Link
href={`${router.basePath}/project/${ref}/database/replication`}
target="_blank"
rel="noreferrer"
>
<Button type="default" iconRight={<IconExternalLink />}>
Replication settings
</Button>
</Link>
</div>
<div className="w-full px-5 py-4 items-center gap-4 inline-flex rounded-b-md bg-background">
<div className="grow flex-col flex">
<p className="text-foreground">Not sure what to do?</p>
<p className="text-foreground-lighter text-xs">Browse our documentation</p>
</div>
<Button type="default" iconRight={<IconExternalLink />}>
<a href="https://supabase.com/docs/guides/realtime" target="_blank" rel="noreferrer">
Documentation
</a>
</Button>
</div>
</div>
</div>
)
}
const RowRenderer = (key: Key, props: RenderRowProps<LogData, unknown>) => {
return <Row key={key} {...props} isRowSelected={false} selectedCellIdx={undefined} />
}
const MessagesTable = ({ enabled, data = [], showSendMessage }: MessagesTableProps) => {
const [focusedLog, setFocusedLog] = useState<LogData | null>(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 (
<>
<section className="flex w-full flex-col" style={{ maxHeight: 'calc(100vh - 42px - 3rem)' }}>
<ShimmerLine active={enabled} />
<div className={cn('flex h-full flex-row', enabled ? 'border-brand-400' : null)}>
<div className="flex flex-grow flex-col">
{enabled && (
<div className="w-full h-8 px-4 bg-surface-100 border-b items-center inline-flex justify-between text-foreground-light">
<div className="inline-flex gap-2.5 text-xs">
<Loader2 size="16" className="animate-spin" />
<div>Listening</div>
<div>•</div>
<div>
{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...`}
</div>
</div>
<Button type="link" onClick={showSendMessage}>
<span className="underline text-foreground-light hover:text-brand-600">
Send test message
</span>
</Button>
</div>
)}
<DataGrid
className="data-grid--simple-logs h-full"
rowHeight={40}
headerRowHeight={0}
onSelectedCellChange={({ rowIdx }) => {
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: (
<div className="mx-auto flex h-full w-full items-center justify-center space-y-12 py-4 transition-all delay-200 duration-500">
<NoResultAlert enabled={enabled} showSendMessage={showSendMessage} />
</div>
),
}}
/>
</div>
<div className="flex w-1/2 flex-col">
<MessageSelection onClose={() => setFocusedLog(null)} log={focusedLog} />
</div>
</div>
</section>
</>
)
}
export default MessagesTable
@@ -0,0 +1,22 @@
import SchemaSelector from 'components/ui/SchemaSelector'
export const FilterSchema = ({
value,
onChange,
}: {
value: string
onChange: (s: string) => void
}) => {
return (
<div className="flex flex-row gap-4 items-center">
<p className="w-[60px] flex justify-end text-sm">WHERE</p>
<SchemaSelector
supportSelectAll
size="small"
selectedSchemaName={value}
onSelectSchema={onChange}
className="rounded-l-none [&>button>span>div]:py-0 w-64"
/>
</div>
)
}
@@ -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 (
<div className="flex flex-row gap-4 items-center">
<p className="w-[60px] flex justify-end text-sm">AND</p>
<TableSelector
selectedSchemaName={schema}
selectedTableName={value}
onSelectTable={onChange}
className="w-64"
size="small"
/>
</div>
)
}
@@ -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 (
<div className={className}>
{isLoading && (
<Button
type="outline"
className="w-full [&>span]:w-full text-xs text-light"
icon={<IconLoader className="animate-spin" size={12} />}
>
<p className="flex">Loading tables...</p>
</Button>
)}
{showError && isError && (
<Alert_Shadcn_ variant="warning" className="!px-3 !py-3">
<AlertTitle_Shadcn_ className="text-xs text-amber-900">
Failed to load tables
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs mb-2">
Error: {(error as any)?.message}
</AlertDescription_Shadcn_>
<Button type="default" size="tiny" onClick={() => refetch()}>
Reload tables
</Button>
</Alert_Shadcn_>
)}
{isSuccess && (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size={size}
type="outline"
className={`w-full [&>span]:w-full ${size === 'small' ? 'py-1.5' : ''}`}
iconRight={
<IconCode className="text-foreground-light rotate-90" strokeWidth={2} size={12} />
}
>
<div className="w-full flex space-x-3">
<p className="text-xs text-light">table</p>
<p className="text-xs">
{selectedTableName === '*' ? 'All tables' : selectedTableName}
</p>
</div>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="start">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Find table..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No tables found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(entities || []).length > 7 ? 'h-[210px]' : ''}>
<CommandItem_Shadcn_
key="all-tables"
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
onSelectTable('*', undefined)
setOpen(false)
}}
onClick={() => {
onSelectTable('*', undefined)
setOpen(false)
}}
>
<span>All tables</span>
{selectedSchemaName === '*' && (
<IconCheck className="text-brand" strokeWidth={2} />
)}
</CommandItem_Shadcn_>
{entities?.map((table) => (
<CommandItem_Shadcn_
key={table.id}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
onSelectTable(table.name, table.id)
setOpen(false)
}}
onClick={() => {
onSelectTable(table.name, table.id)
setOpen(false)
}}
>
<span>{table.name}</span>
{selectedSchemaName === table.name && (
<IconCheck className="text-brand" strokeWidth={2} />
)}
</CommandItem_Shadcn_>
))}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)}
</div>
)
}
export default TableSelector
@@ -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<SetStateAction<RealtimeConfig>>
}
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 (
<>
<Popover_Shadcn_ open={open} onOpenChange={onOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
icon={<PlusCircle size="16" />}
type={isFiltered ? 'primary' : 'dashed'}
className={cn('rounded-full px-1.5 text-xs', isFiltered ? '!py-0.5' : '!py-1')}
size="small"
>
{isFiltered ? (
<>
<span className="mr-1">Filtered by </span>
<Badge className="!bg-brand-400 !text-brand-600">
schema: {config.schema === '*' ? 'All schemas' : config.schema}
</Badge>
</>
) : (
<span className="mr-1">Filter messages</span>
)}
{config.table !== '*' ? (
<>
<span> and </span>
<Badge className="!bg-brand-400 !text-brand-600">table: {config.table}</Badge>
</>
) : null}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-full" align="start">
<div className="border-b border-overlay text-xs px-4 py-3 text-foreground">
Filter incoming messages
</div>
<div className="flex border-b border-overlay p-4 gap-y-2 flex-col">
<FilterSchema
value={tempConfig.schema}
onChange={(v) => setTempConfig({ ...tempConfig, schema: v, table: '*' })}
/>
<TableSchema
value={tempConfig.table}
schema={tempConfig.schema}
onChange={(table) => setTempConfig({ ...tempConfig, table })}
/>
</div>
<div className="border-b border-overlay p-4 flex flex-col gap-2">
<div className="flex flex-row gap-4 items-center">
<p className="w-[60px] flex justify-end text-sm">AND</p>
<Input
size="tiny"
className="flex-grow"
placeholder="body=eq.hey"
value={tempConfig.filter}
onChange={(v) => setTempConfig({ ...tempConfig, filter: v.target.value })}
/>
</div>
<p className="text-xs text-foreground-light pl-[80px]">
Learn more about realtime filtering in{' '}
<Link
className="underline"
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/guides/realtime/postgres-changes#available-filters"
>
our docs
</Link>
</p>
</div>
<div className="px-4 py-2 gap-2 flex justify-end">
<Button type="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setApplyConfigOpen(true)}>Apply</Button>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<ApplyConfigModal
visible={applyConfigOpen}
onSelectCancel={() => setApplyConfigOpen(false)}
onSelectConfirm={() => {
onChangeConfig(tempConfig)
setApplyConfigOpen(false)
setOpen(false)
}}
/>
</>
)
}
@@ -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: <IconPresence size="xlarge" />,
BROADCAST: <IconBroadcast size="xlarge" />,
POSTGRES: <IconDatabaseChanges size="xlarge" />,
SYSTEM: <IconAlertTriangle size="large" />,
}
export const ColumnRenderer: Column<LogData, unknown>[] = [
{
name: 'timestamp-with-truncated-text',
key: 'main-column',
renderCell: (data: { row: PreviewLogData }) => {
const type = data.row.message as keyof typeof ICONS
return (
<RowLayout>
<div
className={cn(
'flex justify-center items-center min-w-[24px]',
isErrorLog(data.row) ? 'text-warning-600' : 'text-green-900'
)}
>
{ICONS[type]}
</div>
<span className={cn('font-mono', isErrorLog(data.row) ? '!text-warning-600' : '')}>
{new Date(data.row.timestamp).toISOString()}
</span>
<span
className={cn('truncate font-mono', isErrorLog(data.row) ? '!text-warning-600' : '')}
>
{JSON.stringify(data.row.metadata)}
</span>
</RowLayout>
)
},
},
]
@@ -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<SetStateAction<RealtimeConfig>>
}
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 (
<Popover_Shadcn_ open={open} onOpenChange={onOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
icon={<PlusCircle size={16} />}
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'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[500px] p-0" align="start">
<div className="border-b border-overlay p-4 flex-grow flex flex-col gap-y-3">
<p className="text-sm text-foreground">Connection key settings</p>
<Listbox
type="select"
value={tempConfig.token}
size="small"
className="w-full"
onChange={(key) => {
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 (
<Listbox.Option key={key.tags} label={key.tags} value={key.api_key}>
<span className="text-foreground">{key.tags}</span>
</Listbox.Option>
)
})}
</Listbox>
{selectedToken?.tags === 'service_role' ? (
<span className="text-sm text-foreground-light">
The <span className="text-foreground">Service role</span> is a predefined Postgres
role with elevated privileges. It will bypass all Row Level Security (RLS) policies.
</span>
) : (
<span className="text-sm text-foreground-light">
The <span className="text-foreground">anonymous key</span> will respect Row Level
Security (RLS) policies.
</span>
)}
</div>
{bearerEnabled && (
<div className="border-b border-overlay p-4 flex-grow flex flex-col gap-y-2">
<p className="text-sm text-foreground">Impersonate a user</p>
<p className="text-sm text-foreground-light">
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.
</p>
<Input
icon={<IconKey />}
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 })}
/>
<p className="text-xs text-foreground-light">
Learn more about JWT tokens in{' '}
<Link
className="underline"
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts"
>
our docs
</Link>
</p>
</div>
)}
<div className="px-4 py-3 gap-2 flex justify-end">
<Button type="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={onApply}>Apply</Button>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -0,0 +1,38 @@
import { LogData } from './Messages.types'
import { jsonSyntaxHighlight, SelectionDetailedTimestampRow } from './MessagesFormatters'
const LogsDivider = () => {
return (
<div className="h-px w-full bg-panel-border-interior-light dark:bg-panel-border-interior-dark" />
)
}
export const SelectedRealtimeMessagePanel = ({ log }: { log: LogData }) => {
return (
<>
<div className="px-8">
<span className="col-span-4 text-sm text-scale-900">Message</span>
<p className="text-wrap mt-2 overflow-x-auto whitespace-pre-wrap font-mono text-xs text-scale-1200">
{log.message}
</p>
</div>
<LogsDivider />
<div className="px-8 space-y-2">
<SelectionDetailedTimestampRow value={log.timestamp} />
</div>
<LogsDivider />
<div className="px-8">
<h3 className="mb-4 text-lg text-scale-1200">Metadata</h3>
<pre className="syntax-highlight overflow-x-auto text-sm">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: log.metadata ? jsonSyntaxHighlight(log.metadata) : '',
}}
/>
</pre>
</div>
</>
)
}
@@ -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<string>()
const [values, setValues] = useState({ message: 'test', payload: '{}' })
useEffect(() => {
if (visible) {
setError(undefined)
setValues({ message: 'test', payload: '{}' })
}
}, [visible])
return (
<Modal
size="medium"
alignFooter="right"
header="Broadcast a message to all clients"
visible={visible}
loading={false}
onCancel={onSelectCancel}
onConfirm={() => {
const payload = tryParseJson(values.payload)
if (payload === undefined) {
setError('Please provide a valid JSON')
} else {
onSelectConfirm(values)
}
}}
>
<Modal.Content>
<div className="py-4 flex flex-col gap-y-4">
<Input
label="Message name"
size="small"
className="flex-grow"
value={values.message}
onChange={(v) => setValues({ ...values, message: v.target.value })}
/>
<div className="flex flex-col gap-y-2">
<p className="text-sm text-scale-1100">Message payload</p>
<CodeEditor
id="message-payload"
language="json"
className="!mb-0 h-32 overflow-hidden rounded border"
onInputChange={(e: string | undefined) =>
setValues({ ...values, payload: e ?? '{}' })
}
options={{ wordWrap: 'off', contextmenu: false }}
value={values.payload}
/>
{error !== undefined && <p className="text-sm text-red-900">{error}</p>}
</div>
</div>
</Modal.Content>
</Modal>
)
}
@@ -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<RealtimeConfig>({
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 (
<div className="flex flex-col flex-grow h-full">
<Header config={realtimeConfig} onChangeConfig={setRealtimeConfig} />
<div className="relative flex flex-col flex-grow h-full">
<div className="flex h-full">
<MessagesTable
enabled={realtimeConfig.enabled}
data={logData}
showSendMessage={() => setSendMessageShown(true)}
/>
</div>
</div>
<SendMessageModal
visible={sendMessageShown}
onSelectCancel={() => setSendMessageShown(false)}
onSelectConfirm={(v) => {
sendMessage(v.message, v.payload)
setSendMessageShown(false)
}}
/>
</div>
)
}
@@ -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<SupabaseClient<any, 'public', any> | undefined>()
let [channel, setChannel] = useState<RealtimeChannel | undefined>()
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 }
}
@@ -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)
@@ -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: (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={products.realtime.icon[18]}
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`),
},
]
: []),
]
}
@@ -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<RealtimeLayoutProps>) => {
const { meta } = useStore()
const project = useSelectedProject()
const router = useRouter()
const page = router.pathname.split('/')[4]
return (
<ProjectLayout
isLoading={false}
product="Realtime"
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project)} />}
>
<main className="h-screen">{children}</main>
</ProjectLayout>
)
}
export default withAuth(RealtimeLayout)
@@ -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: [],
},
],
},
]
}
+1 -2
View File
@@ -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 (
<Alert_Shadcn_ className={className} variant="warning" title={subject}>
<IconAlertCircle className="h-4 w-4" />
<IconAlertCircle className="h-4 w-4" color="bg-warning-300" strokeWidth={2} />
<AlertTitle_Shadcn_>{subject}</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-3">
<div>
+27 -6
View File
@@ -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={<IconLoader className="animate-spin" size={12} />}
>
<div>
<div className="w-full flex space-x-3 py-0.5">
<p className="text-xs text-light">Loading schemas...</p>
</div>
<div className="w-full flex space-x-3 py-0.5">
<p className="text-xs text-light">Loading schemas...</p>
</div>
</Button>
)}
@@ -100,7 +100,9 @@ const SchemaSelector = ({
>
<div className="w-full flex space-x-3 py-0.5">
<p className="text-xs text-light">schema</p>
<p className="text-xs">{selectedSchemaName}</p>
<p className="text-xs">
{selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName}
</p>
</div>
</Button>
</PopoverTrigger_Shadcn_>
@@ -111,6 +113,25 @@ const SchemaSelector = ({
<CommandEmpty_Shadcn_>No schemas found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(schemas || []).length > 7 ? 'h-[210px]' : ''}>
{supportSelectAll && (
<CommandItem_Shadcn_
key="select-all"
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
onSelectSchema('*')
setOpen(false)
}}
onClick={() => {
onSelectSchema('*')
setOpen(false)
}}
>
<span>All schemas</span>
{selectedSchemaName === '*' && (
<IconCheck className="text-brand" strokeWidth={2} />
)}
</CommandItem_Shadcn_>
)}
{schemas?.map((schema) => (
<CommandItem_Shadcn_
key={schema.id}
@@ -125,7 +146,7 @@ const SchemaSelector = ({
}}
>
<span>{schema.name}</span>
{schema.name === selectedSchemaName && (
{selectedSchemaName === schema.name && (
<IconCheck className="text-brand" strokeWidth={2} />
)}
</CommandItem_Shadcn_>
@@ -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 <NoPermission isFullPage resourceText="access your project's realtime functionalities" />
}
return <RealtimeInspector />
}
InspectorPage.getLayout = (page) => <RealtimeLayout title="Realtime">{page}</RealtimeLayout>
export default InspectorPage
+1 -1
View File
@@ -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;
}