mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
c5593f7a5e
commit
e02181aba8
36 files changed
+1774
-37
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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, '&').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 '<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
|
||||
+22
@@ -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>
|
||||
)
|
||||
}
|
||||
+154
@@ -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,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>
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user