Merge pull request #13252 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-03-28 13:55:53 +08:00
commit df64b1a251
21 files changed
+613 -204

No files matched your search

@@ -62,8 +62,9 @@ A web interface for connecting to external data is coming to Supabase Studio in
- [Firebase](https://supabase.github.io/wrappers/firebase/)
- [Stripe](https://supabase.github.io/wrappers/stripe/)
- [BigQuery](https://supabase.github.io/wrappers/bigquery/)
- [AWS S3](https://supabase.github.io/wrappers/s3/)
- Airtable - Coming Soon
- BigQuery - Coming Soon
- Clickhouse - Coming Soon
## Resources
@@ -34,7 +34,7 @@ After developing your project and deciding it's Production Ready, you should run
- Perform load testing (preferably on a staging env)
- Tools like [k6](https://k6.io/) can simulate traffic from many different users.
- Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io.
- If you are expecting a surge in traffic (for a big launch), let the team know by sending your Project Ref to us (support@supabase.io) with more details about your launch. We'll keep an eye on your project.
- If you are expecting a surge in traffic (for a big launch) [contact support](https://app.supabase.com/support/new) with more details about your launch. We'll keep an eye on your project.
## Availability
@@ -47,14 +47,14 @@ After developing your project and deciding it's Production Ready, you should run
- Database backups are not available for download on the free tier.
- You can set up your own backup systems using tools like [pg_dump](https://www.postgresqltutorial.com/postgresql-backup-database/) or [wal-g](https://github.com/wal-g/wal-g).
- Nightly backups for Pro tier projects are available on the Supabase dashboard for up to 7 days.
- Upgrading to the Supabase Pro Tier will give you access to email support on support@supabase.io
- Upgrading to the Supabase Pro Tier will give you [access to our support team](https://app.supabase.com/support/new).
## Rate Limiting, Resource Allocation, & Abuse Prevention
- Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform
- If you're expecting high load events including production launches or heavy load testing, or prolonged high resource usage please give us at least 2 weeks notice. You can do this by opening a ticket via the [support form](https://app.supabase.com/support/new).
### Rate Limits
### Auth Rate Limits
- The table below shows the rate limit quotas on the following authentication endpoints:
@@ -69,6 +69,11 @@ After developing your project and deciding it's Production Ready, you should run
| Token refresh requests | `/auth/v1/token` | IP Address | 360 requests per hour (with bursts up to 30 requests) |
| Create or Verify an MFA challenge | `/auth/v1/factors/:id/challenge` `/auth/v1/factors/:id/verify` | IP Address | 15 requests per minute (with bursts up to 30 requests) |
### Realtime Rate Limits
- Review the [Realtime rate limits](/docs/guides/realtime/rate-limits).
- If you need rate limits increased you can always [contact support](https://app.supabase.com/support/new).
### Abuse Prevention
- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Please refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method.
@@ -8,7 +8,7 @@ export const meta = {
sidebar_label: 'Extensions',
}
Supabase Realtime provides multiplayer features so that you can build real-time applications. The server is has several extensions which are built to solve a specific task.
Supabase Realtime provides multiplayer features so that you can build real-time applications. The server has several built-in extensions to solve specific tasks.
- [Broadcast](/docs/guides/realtime/broadcast): for sending rapid, ephemeral messages to other connected clients. A typical use-case is tracking mouse movements.
- [Presence](/docs/guides/realtime/presence): for sending user state between connected clients. A typical use-case is simply an "online" status, which will disappear when a user is disconnected.
@@ -76,6 +76,7 @@ function Listbox({
useFormContext()
if (values && !value) {
value = values[id || name]
defaultValue = values[id || name]
}
@@ -3,16 +3,18 @@ import { useState, useRef, useEffect } from 'react'
import { PostgresTrigger } from '@supabase/postgres-meta'
import { Button, SidePanel, Form, Input, Listbox, Checkbox, Radio, Badge, Modal } from 'ui'
import { useStore } from 'hooks'
import { useParams, useStore } from 'hooks'
import { tryParseJson, uuidv4 } from 'lib/helpers'
import HTTPRequestFields from './HTTPRequestFields'
import { isValidHttpUrl } from './Hooks.utils'
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
import ConfirmationModal from 'components/ui/ConfirmationModal'
export interface EditHookPanelProps {
visible: boolean
@@ -24,6 +26,7 @@ export type HTTPArgument = { id: string; name: string; value: string }
const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
// [Joshen] Need to change to use RQ once Alaister's PR goes in
const { ref } = useParams()
const { meta, ui } = useStore()
const submitRef: any = useRef()
const [isEdited, setIsEdited] = useState(false)
@@ -40,16 +43,25 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
const [httpParameters, setHttpParameters] = useState<HTTPArgument[]>([])
const { project } = useProjectContext()
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const { mutateAsync: createDatabaseTrigger } = useDatabaseTriggerCreateMutation()
const { mutateAsync: updateDatabaseTrigger } = useDatabaseTriggerUpdateMutation()
const tables = meta.tables.list()
const tables = meta.tables.list().sort((a, b) => (a.schema > b.schema ? 0 : -1))
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const isEdgeFunction = (url: string) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`)
const initialValues = {
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id ?? '',
function_name: selectedHook?.function_name ?? 'http_request',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: selectedHook?.function_args?.[1] ?? 'GET',
http_method: selectedHook?.function_args?.[1] ?? 'POST',
function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '')
? 'supabase_function'
: 'http_request',
}
useEffect(() => {
@@ -107,7 +119,7 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
errors['table_id'] = 'Please select a table for which your webhook will trigger from'
}
if (values.function_name === 'http_request') {
if (values.function_type === 'http_request') {
// For HTTP requests
if (!values.http_url) {
errors['http_url'] = 'Please provide a URL'
@@ -116,8 +128,11 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
} else if (!isValidHttpUrl(values.http_url)) {
errors['http_url'] = 'Please provide a valid URL'
}
} else if (values.function_name === 'supabase_function') {
} else if (values.function_type === 'supabase_function') {
// For Supabase Edge Functions
if (values.http_url.includes('undefined')) {
errors['http_url'] = 'No edge functions available for selection'
}
}
if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true)
@@ -137,6 +152,20 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
return ui.setNotification({ category: 'error', message: 'Unable to find selected table' })
}
const serviceTimeoutMs = '1000'
const headers = httpHeaders
.filter((header) => header.name && header.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
const parameters = httpParameters
.filter((param) => param.name && param.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
const payload: any = {
events,
activation: 'AFTER',
@@ -146,34 +175,15 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
table: selectedTable.name,
schema: selectedTable.schema,
table_id: values.table_id,
function_name: values.function_name,
function_name: 'http_request',
function_schema: 'supabase_functions',
function_args: [],
}
if (values.function_name === 'http_request') {
const serviceTimeoutMs = '1000'
const headers = httpHeaders
.filter((header) => header.name && header.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
const parameters = httpParameters
.filter((param) => param.name && param.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
payload.function_args = [
function_args: [
values.http_url,
values.http_method,
JSON.stringify(headers),
JSON.stringify(parameters),
serviceTimeoutMs,
]
} else if (values.function_name === 'supabase_function') {
payload.function_args = []
],
}
if (selectedHook === undefined) {
@@ -266,7 +276,28 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
}
>
<Form validateOnBlur initialValues={initialValues} onSubmit={onSubmit} validate={validate}>
{() => {
{({ values, resetForm, errors }: any) => {
useEffect(() => {
if (values.function_type === 'http_request') {
if (selectedHook !== undefined) {
const [url, method] = selectedHook.function_args
const updatedValues = { ...values, http_url: url, http_method: method }
resetForm({ values: updatedValues, initialValues: updatedValues })
} else {
const updatedValues = { ...values, http_url: '' }
resetForm({ values: updatedValues, initialValues: updatedValues })
}
} else if (values.function_type === 'supabase_function') {
const fnSlug = (functions ?? [])[0]?.slug
const defaultFunctionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${fnSlug}`
const updatedValues = {
...values,
http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [values.function_type])
return (
<div>
<FormSection
@@ -288,11 +319,11 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
className="lg:!col-span-4"
description={
<p className="text-sm text-scale-1000">
Select which table and events will trigger your function hook
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire hook
Conditions to fire webhook
</FormSectionLabel>
}
>
@@ -320,8 +351,8 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
label={table.name}
>
<div className="flex items-center space-x-2">
<p>{table.name}</p>
<p className="text-scale-1100">{table.schema}</p>
<p className="text-scale-1000">{table.schema}</p>
<p className="text-scale-1200">{table.name}</p>
</div>
</Listbox.Option>
))}
@@ -350,20 +381,19 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">
Hook configuration
Webhook configuration
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group
id="function_name"
name="function_name"
label="Type of hook"
id="function_type"
name="function_type"
label="Type of webhook"
type="cards"
>
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
disabled={webhook.disabled}
key={webhook.value}
id={webhook.value}
value={webhook.value}
@@ -374,11 +404,6 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-scale-1200">{webhook.label}</p>
{webhook.disabled ? (
<Badge color="amber">Coming soon</Badge>
) : (
<Badge color="green">Alpha</Badge>
)}
</div>
<p className="text-scale-1000">{webhook.description}</p>
</div>
@@ -392,11 +417,14 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeader={() =>
setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
}
onAddHeader={(header?: any) => {
if (header) setHttpHeaders(httpHeaders.concat(header))
else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
}}
onUpdateHeader={(idx, property, value) =>
setHttpHeaders(
httpHeaders.map((header, i) => {
@@ -1,11 +1,28 @@
import { Button, Listbox, Input, SidePanel, IconTrash, IconPlus } from 'ui'
import Link from 'next/link'
import {
Button,
Listbox,
Input,
SidePanel,
IconTrash,
IconPlus,
Dropdown,
IconChevronDown,
} from 'ui'
import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
import { HTTPArgument } from './EditHookPanel'
import { useParams, useStore } from 'hooks'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import clsx from 'clsx'
import { uuidv4 } from 'lib/helpers'
import { useProjectApiQuery } from 'data/config/project-api-query'
interface HTTPRequestFieldsProps {
type: 'http_request' | 'supabase_function'
errors: any
httpHeaders: HTTPArgument[]
httpParameters: HTTPArgument[]
onAddHeader: () => void
onAddHeader: (header?: any) => void
onUpdateHeader: (idx: number, property: string, value: string) => void
onRemoveHeader: (idx: number) => void
onAddParameter: () => void
@@ -14,6 +31,8 @@ interface HTTPRequestFieldsProps {
}
const HTTPRequestFields = ({
type,
errors,
httpHeaders = [],
httpParameters = [],
onAddHeader,
@@ -23,10 +42,29 @@ const HTTPRequestFields = ({
onUpdateParameter,
onRemoveParameter,
}: HTTPRequestFieldsProps) => {
const { ui } = useStore()
const { ref } = useParams()
const { data: settings } = useProjectApiQuery({ projectRef: ref })
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const edgeFunctions = functions ?? []
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
: '[YOUR ANON KEY]'
return (
<>
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">HTTP Request</FormSectionLabel>}
header={
<FormSectionLabel className="lg:!col-span-4">
{type === 'http_request'
? 'HTTP Request'
: type === 'supabase_function'
? 'Edge Function'
: ''}
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox id="http_method" name="http_method" size="medium" label="Method">
@@ -37,13 +75,42 @@ const HTTPRequestFields = ({
POST
</Listbox.Option>
</Listbox>
<Input
id="http_url"
name="http_url"
label="URL"
placeholder="http://api.com/path/resource"
descriptionText="URL of the HTTP request. Must include HTTP/HTTPS"
/>
{type === 'http_request' ? (
<Input
id="http_url"
name="http_url"
label="URL"
placeholder="http://api.com/path/resource"
descriptionText="URL of the HTTP request. Must include HTTP/HTTPS"
/>
) : type === 'supabase_function' && edgeFunctions.length === 0 ? (
<div className="space-y-1">
<p className="text-sm text-scale-1100">Select which edge function to trigger</p>
<div className="px-4 py-4 border rounded bg-scale-500 border-scale-700 flex items-center justify-between space-x-4">
<p className="text-sm">No edge functions created yet</p>
<Link href={`/project/${ref}/functions`}>
<a>
<Button>Create an edge function</Button>
</a>
</Link>
</div>
{errors.http_url && <p className="text-sm text-red-900">{errors.http_url}</p>}
</div>
) : type === 'supabase_function' && edgeFunctions.length > 0 ? (
<Listbox id="http_url" name="http_url" label="Select which edge function to trigger">
{edgeFunctions.map((fn) => {
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const functionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${fn.slug}`
return (
<Listbox.Option key={fn.id} id={functionUrl} value={functionUrl} label={fn.name}>
{fn.name}
</Listbox.Option>
)
})}
</Listbox>
) : null}
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
@@ -77,10 +144,67 @@ const HTTPRequestFields = ({
/>
</div>
))}
<div>
<Button type="dashed" size="tiny" icon={<IconPlus />} onClick={onAddHeader}>
<div className="flex items-center">
<Button
type="default"
size="tiny"
icon={<IconPlus />}
className={clsx(type === 'supabase_function' && 'rounded-r-none px-3')}
onClick={onAddHeader}
>
Add a new header
</Button>
{type === 'supabase_function' && (
<Dropdown
align="end"
side="bottom"
overlay={[
<Dropdown.Item
key="add-auth-header"
onClick={() =>
onAddHeader({
id: uuidv4(),
name: ' Authorization',
value: `Bearer ${anonKey}`,
})
}
>
<div className="space-y-1">
<p className="block text-scale-1200">
Add authorization header with anon key
</p>
<p className="text-scale-1000">
Required if your edge function enforces JWT verification
</p>
</div>
</Dropdown.Item>,
<Dropdown.Separator key="separator" />,
<Dropdown.Item
key="add-source-header"
onClick={() =>
onAddHeader({
id: uuidv4(),
name: ' x-supabase-webhook-source',
value: `[Use a secret value]`,
})
}
>
<div className="space-y-1">
<p className="block text-scale-1200">Add custom source header</p>
<p className="text-scale-1000">
Useful to verify that the edge function was triggered from this webhook
</p>
</div>
</Dropdown.Item>,
]}
>
<Button
type="default"
className="rounded-l-none px-[4px] py-[5px]"
icon={<IconChevronDown />}
/>
</Dropdown>
)}
</div>
</div>
</FormSectionContent>
@@ -35,14 +35,12 @@ export const HOOK_EVENTS = [
export const AVAILABLE_WEBHOOK_TYPES = [
{
disabled: false,
value: 'http_request',
icon: `${BASE_PATH}/img/function-providers/http-request.png`,
label: 'HTTP Request',
description: 'Send an HTTP request to any URL.',
},
{
disabled: true,
value: 'supabase_function',
icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`,
label: 'Supabase Edge Functions',
@@ -1,3 +1,4 @@
import Image from 'next/image'
import { FC } from 'react'
import { includes } from 'lodash'
import { observer } from 'mobx-react-lite'
@@ -5,7 +6,8 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Badge, Button, Dropdown, IconMoreVertical, IconTrash, IconEdit3 } from 'ui'
import { checkPermissions } from 'hooks'
import { checkPermissions, useParams, useStore } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import Table from 'components/to-be-cleaned/Table'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query'
@@ -23,92 +25,106 @@ const HookList: FC<Props> = ({
editHook = () => {},
deleteHook = () => {},
}) => {
const { ui } = useStore()
const { ref } = useParams()
const { project } = useProjectContext()
const { data: hooks } = useDatabaseHooks({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const filteredHooks = (hooks ?? []).filter(
(x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) && x.schema === schema
)
const canUpdateWebhook = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
function onEdit(trigger: any) {
editHook(trigger)
}
function onDelete(trigger: any) {
deleteHook(trigger)
}
return (
<>
{filteredHooks.map((x: any) => (
<Table.tr key={x.id}>
<Table.td className="space-x-2">
<p>{x.name}</p>
</Table.td>
<Table.td className="hidden space-x-2 lg:table-cell">
<p>{x.table}</p>
</Table.td>
<Table.td className="hidden xl:table-cell">
<p>
{filteredHooks.map((x: any) => {
const isEdgeFunction = (url: string) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`)
const [url, method] = x.function_args
return (
<Table.tr key={x.id}>
<Table.td>
<div className="flex items-center space-x-4">
<Image
src={
isEdgeFunction(url)
? `${BASE_PATH}/img/function-providers/supabase-severless-function.png`
: `${BASE_PATH}/img/function-providers/http-request.png`
}
layout="fixed"
width="20"
height="20"
title={isEdgeFunction(url) ? 'Supabase Edge Function' : 'HTTP Request'}
/>
<p title={x.name}>{x.name}</p>
</div>
</Table.td>
<Table.td className="hidden space-x-2 lg:table-cell">
<p title={x.table}>{x.table}</p>
</Table.td>
<Table.td className="hidden space-x-1 xl:table-cell">
{x.events.map((event: string) => (
<Badge key={event}>{event}</Badge>
))}
</p>
</Table.td>
<Table.td className="hidden xl:table-cell">
<p>
<Badge>{x.function_args[0]}</Badge>
</p>
</Table.td>
<Table.td className="text-right">
<div className="flex justify-end gap-4">
{canUpdateWebhook ? (
<Dropdown
side="left"
overlay={
<>
<Dropdown.Item icon={<IconEdit3 size="tiny" />} onClick={() => onEdit(x)}>
Edit hook
</Dropdown.Item>
<Dropdown.Item
icon={<IconTrash stroke="red" size="tiny" />}
onClick={() => onDelete(x)}
</Table.td>
<Table.td className="hidden xl:table-cell">
<p className="truncate" title={url}>
<code className="font-mono text-xs">{method}</code>: {url}
</p>
</Table.td>
<Table.td className="text-right">
<div className="flex justify-end gap-4">
{canUpdateWebhook ? (
<Dropdown
side="left"
overlay={
<>
<Dropdown.Item icon={<IconEdit3 size="tiny" />} onClick={() => editHook(x)}>
Edit hook
</Dropdown.Item>
<Dropdown.Item
icon={<IconTrash stroke="red" size="tiny" />}
onClick={() => deleteHook(x)}
>
Delete hook
</Dropdown.Item>
</>
}
>
<Button as="span" type="default" icon={<IconMoreVertical />} className="px-1" />
</Dropdown>
) : (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button as="span" disabled type="default" icon={<IconMoreVertical />} />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
Delete hook
</Dropdown.Item>
</>
}
>
<Button as="span" type="default" icon={<IconMoreVertical />} />
</Dropdown>
) : (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button as="span" disabled type="default" icon={<IconMoreVertical />} />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to update webhooks
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</Table.td>
</Table.tr>
))}
<span className="text-xs text-scale-1200">
You need additional permissions to update webhooks
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</Table.td>
</Table.tr>
)
})}
</>
)
}
@@ -29,19 +29,19 @@ const SchemaTable: FC<Props> = ({
className="table-fixed"
head={
<>
<Table.th key="name" className="space-x-4">
Name
<Table.th key="name" className="w-[20%]">
<p className="translate-x-[36px]">Name</p>
</Table.th>
<Table.th key="table" className="hidden lg:table-cell">
<Table.th key="table" className="w-[15%] hidden lg:table-cell">
Table
</Table.th>
<Table.th key="events" className="hidden xl:table-cell">
<Table.th key="events" className="w-[24%] hidden xl:table-cell">
Events
</Table.th>
<Table.th key="webhook" className="hidden xl:table-cell">
Webhook
</Table.th>
<Table.th key="buttons" className="w-1/6"></Table.th>
<Table.th key="buttons" className="w-[5%]"></Table.th>
</>
}
body={
@@ -36,46 +36,42 @@ const CustomDomainConfig = () => {
return (
<section>
<FormHeader title="Custom Domains" description="Present a branded experience to your users" />
{data?.status === '0_no_hostname_configured' ? (
{isLoading ? (
<Panel>
<Panel.Content className="space-y-6">
<CustomDomainsShimmerLoader />
</Panel.Content>
</Panel>
) : isError ? (
<Panel>
<Panel.Content className="space-y-6">
<div className="flex items-center justify-center space-x-2 py-8">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">
Failed to retrieve custom domain configuration. Please try again later or{' '}
<Link href={`/support/new?ref=${ref}&category=sales`}>
<a className="underline">contact support</a>
</Link>
.
</p>
</div>
</Panel.Content>
</Panel>
) : data?.status === '0_no_hostname_configured' ? (
<CustomDomainsConfigureHostname />
) : data?.status === '0_not_allowed' ? (
<UpgradeToPro
icon={<IconAlertCircle size={18} strokeWidth={1.5} />}
primaryText="Custom domains are a Pro plan add-on"
projectRef={ref as string}
secondaryText={
tier === PRICING_TIER_PRODUCT_IDS.FREE
? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected'
: 'To configure a custom domain for your project, please enable the add-on'
}
/>
) : (
<Panel>
{isLoading && (
<Panel.Content className="space-y-6">
<CustomDomainsShimmerLoader />
</Panel.Content>
)}
{data?.status === '0_not_allowed' && (
<Panel.Content className="space-y-6">
<UpgradeToPro
icon={<IconAlertCircle size={18} strokeWidth={1.5} />}
primaryText="Custom domains are a Pro plan add-on"
projectRef={ref as string}
secondaryText={
tier === PRICING_TIER_PRODUCT_IDS.FREE
? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected'
: 'To configure a custom domain for your project, please enable the add-on'
}
/>
</Panel.Content>
)}
{isError && (
<Panel.Content className="space-y-6">
<div className="flex items-center justify-center space-x-2 py-8">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">
Failed to retrieve custom domain configuration. Please try again later or{' '}
<Link href={`/support/new?ref=${ref}&category=sales`}>
<a className="underline">contact support</a>
</Link>
.
</p>
</div>
</Panel.Content>
)}
{isSuccess && (
<div className="flex flex-col">
{(data.status === '2_initiated' || data.status === '3_challenge_verified') && (
@@ -26,7 +26,6 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
name: 'Functions',
key: 'functions',
url: `/project/${ref}/database/functions`,
label: 'ALPHA',
items: [],
},
{
@@ -1,3 +1,4 @@
import SVG from 'react-inlinesvg'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { noop, partition } from 'lodash'
@@ -5,7 +6,9 @@ import { observer } from 'mobx-react-lite'
import {
Button,
Dropdown,
IconCheck,
IconChevronDown,
IconChevronsDown,
IconCopy,
IconEdit,
IconLoader,
@@ -22,13 +25,15 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import type { PostgresSchema } from '@supabase/postgres-meta'
import { checkPermissions, useStore, useParams } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import { checkPermissions, useStore, useParams, useLocalStorage } from 'hooks'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { Entity } from 'data/entity-types/entity-type-query'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import InfiniteList from 'components/ui/InfiniteList'
import clsx from 'clsx'
export interface TableEditorMenuProps {
selectedSchema?: string
@@ -51,6 +56,10 @@ const TableEditorMenu = ({
const { id } = useParams()
const [searchText, setSearchText] = useState<string>('')
const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
'table-editor-sort',
'alphabetical'
)
const { project } = useProjectContext()
const {
@@ -68,6 +77,7 @@ const TableEditorMenu = ({
connectionString: project?.connectionString,
schema: selectedSchema,
search: searchText || undefined,
sort,
},
{
keepPreviousData: true,
@@ -251,9 +261,74 @@ const TableEditorMenu = ({
<p style={{ fontVariantNumeric: 'tabular-nums' }}>({totalCount})</p>
)}
</div>
<button className="cursor-pointer" onClick={refreshTables}>
<IconRefreshCw className={isRefetching ? 'animate-spin' : ''} size={14} />
</button>
<div className="flex gap-3 items-center">
<Dropdown
size="small"
side="bottom"
align="start"
style={{ zIndex: 1 }}
overlay={[
<Dropdown.Item
key="alphabetical"
icon={
sort === 'alphabetical' ? (
<IconCheck size="tiny" />
) : (
<div className="w-[14px] h-[14px]" />
)
}
onClick={() => {
setSort('alphabetical')
}}
>
Alphabetical
</Dropdown.Item>,
<Dropdown.Item
key="grouped-alphabetical"
icon={
sort === 'grouped-alphabetical' ? (
<IconCheck size="tiny" />
) : (
<div className="w-[14px] h-[14px]" />
)
}
onClick={() => {
setSort('grouped-alphabetical')
}}
>
Entity Type
</Dropdown.Item>,
]}
>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<div className="text-scale-900 transition-colors hover:text-scale-1200">
<IconChevronsDown size={18} strokeWidth={1} />
</div>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs">Sort By</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</Dropdown>
<button
className="cursor-pointer text-scale-900 transition-colors hover:text-scale-1200"
onClick={refreshTables}
>
<IconRefreshCw className={isRefetching ? 'animate-spin' : ''} size={14} />
</button>
</div>
</div>
</>
}
@@ -307,6 +382,13 @@ const EntityListItem = ({
isLoadingTableMetadata = false,
}: EntityListItemProps) => {
const isActive = Number(id) === entity.id
const formatTooltipText = (entityType: string) => {
return Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entityType)?.[0]
?.toLowerCase()
?.split('_')
?.join(' ')
}
return (
<ProductMenuItem
@@ -315,11 +397,55 @@ const EntityListItem = ({
hoverText={entity.comment ? entity.comment : entity.name}
isActive={isActive}
icon={
<div className="flex items-center justify-center text-xs h-4 w-4 rounded-[2px] text-scale-1100 bg-scale-800">
{Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entity.type)?.[0]?.[0]
?.toUpperCase()}
</div>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger className="w-full flex items-center">
{entity.type === ENTITY_TYPE.TABLE ? (
<SVG
className="table-icon"
src={`${BASE_PATH}/img/icons/table-icon.svg`}
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
preProcessor={(code: any) =>
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
}
/>
) : entity.type === ENTITY_TYPE.VIEW ? (
<SVG
className="view-icon"
src={`${BASE_PATH}/img/icons/view-icon.svg`}
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
preProcessor={(code: any) =>
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
}
/>
) : (
<div
className={clsx(
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
entity.type === ENTITY_TYPE.FOREIGN_TABLE && 'text-yellow-900 bg-yellow-500',
entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && 'text-purple-1000 bg-purple-500',
entity.type === ENTITY_TYPE.PARTITIONED_TABLE && 'text-scale-1100 bg-scale-800'
)}
>
{Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entity.type)?.[0]?.[0]
?.toUpperCase()}
</div>
)}
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200 capitalize">
{formatTooltipText(entity.type)}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
}
action={
entity.type === ENTITY_TYPE.TABLE &&
@@ -9,6 +9,7 @@ export type EntityTypesVariables = {
search?: string
limit?: number
page?: number
sort?: 'alphabetical' | 'grouped-alphabetical'
} & Pick<ExecuteSqlVariables, 'connectionString'>
export type EntityTypesResponse = {
@@ -26,9 +27,13 @@ export async function getEntityTypes(
search,
limit = 100,
page = 0,
sort = 'alphabetical',
}: EntityTypesVariables,
signal?: AbortSignal
) {
const innerOrderBy = sort === 'alphabetical' ? `c.relname asc` : `"type_sort" asc, c.relname asc`
const outerOrderBy = sort === 'alphabetical' ? `r.name asc` : `r.type_sort asc, r.name asc`
const sql = /* SQL */ `
with records as (
select
@@ -36,6 +41,13 @@ export async function getEntityTypes(
nc.nspname as "schema",
c.relname as "name",
c.relkind as "type",
case c.relkind
when 'r' then 1
when 'v' then 2
when 'm' then 3
when 'f' then 4
when 'p' then 5
end as "type_sort",
obj_description(c.oid) as "comment",
count(*) over() as "count"
from
@@ -54,7 +66,7 @@ export async function getEntityTypes(
)
and nc.nspname = '${schema}'
${search ? `and c.relname ilike '%${search}%'` : ''}
order by "name" asc
order by ${innerOrderBy}
limit ${limit}
offset ${page * limit}
)
@@ -68,7 +80,7 @@ export async function getEntityTypes(
'type', r.type,
'comment', r.comment
)
order by r.name asc
order by ${outerOrderBy}
), '[]'::jsonb),
'count', coalesce(min(r.count), 0)
) "data"
@@ -98,6 +110,7 @@ export const useEntityTypesQuery = <TData = EntityTypesData>(
schema = 'public',
search,
limit = 100,
sort,
}: Omit<EntityTypesVariables, 'page'>,
{
enabled = true,
@@ -105,10 +118,10 @@ export const useEntityTypesQuery = <TData = EntityTypesData>(
}: UseInfiniteQueryOptions<EntityTypesData, EntityTypesError, TData> = {}
) =>
useInfiniteQuery<EntityTypesData, EntityTypesError, TData>(
entityTypeKeys.list(projectRef, search),
entityTypeKeys.list(projectRef, { schema, search, sort, limit }),
({ signal, pageParam }) =>
getEntityTypes(
{ projectRef, connectionString, schema, search, limit, page: pageParam },
{ projectRef, connectionString, schema, search, limit, page: pageParam, sort },
signal
),
{
+9 -2
View File
@@ -1,4 +1,11 @@
export const entityTypeKeys = {
list: (projectRef: string | undefined, search?: string) =>
['projects', projectRef, 'entity-types', ...(search ? [{ search }] : [])] as const,
list: (
projectRef: string | undefined,
params?: {
schema?: string
search?: string
sort?: 'alphabetical' | 'grouped-alphabetical'
limit?: number
}
) => ['projects', projectRef, 'entity-types', ...(params ? [params] : [])] as const,
}
+1
View File
@@ -4,5 +4,6 @@ export * from './misc/useOptimisticSqlSnippetCreate'
export * from './misc/useImmutableValue'
export * from './misc/useParams'
export * from './misc/useCheckPermissions'
export * from './misc/useLocalStorage'
export * from './deprecated'
export * from './ui'
+44
View File
@@ -0,0 +1,44 @@
// Reference: https://usehooks.com/useLocalStorage/
import { useState } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue
}
try {
// Get from local storage by key
const item = window.localStorage.getItem(key)
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue
} catch (error) {
// If error also return initialValue
console.log(error)
return initialValue
}
})
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value
// Save state
setStoredValue(valueToStore)
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
// A more advanced implementation would handle the error case
console.log(error)
}
}
return [storedValue, setValue] as const
}
+7 -1
View File
@@ -58,7 +58,7 @@ export const AuthContext = createContext<AuthContext>({
export type AuthProviderProps = {}
export const AuthProvider = ({ children }: PropsWithChildren<AuthProviderProps>) => {
const { ui } = useStore()
const { ui, app } = useStore()
const [session, setSession] = useState<Session | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -121,6 +121,12 @@ export const AuthProvider = ({ children }: PropsWithChildren<AuthProviderProps>)
useProfileQuery({
onSuccess(profile) {
ui.setProfile(profile)
// [Joshen] Temp fix: For new users, the GET profile call also creates a default org
// But because the dashboard's logged in state is using gotrue as the source of truth
// the home page loads before the GET profile call completes (and consequently before the
// creation of the default org). Hence why calling org load here
app.organizations.load()
},
// Never rerun the query
staleTime: Infinity,
@@ -15,6 +15,7 @@ import { NextPageWithLayout } from 'types'
import { Accordion, Button, IconAlertCircle, IconCheckCircle, Tabs } from 'ui'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import Link from 'next/link'
const QueryPerformanceReport: NextPageWithLayout = () => {
const { project } = useProjectContext()
@@ -62,13 +63,19 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
const showIndexWarning =
indexHitRate && tableHitRate && (indexHitRate <= 0.99 || tableHitRate <= 0.99)
const HeaderText = `Identify the queries that consume the most time and database resources.
It relies on the \`pg_stat_statements\` table. Read more about [examining query performance]((https://supabase.com/docs/guides/platform/performance#examining-query-performance)).
Consider resetting the analysis after optimizing any queries.
`
const headerText = (
<p className="whitespace-pre-wrap prose text-sm max-w-2xl text-scale-1000">
Identify the queries that consume the most time and database resources.
{'\n\n'}It relies on the <code>pg_stat_statements</code> table. Read more about{' '}
<Link
href="https://supabase.com/docs/guides/platform/performance#examining-query-performance"
passHref
>
<a target="_blank">examining query performance</a>
</Link>
.{'\n\n'}Consider resetting the analysis after optimizing any queries.
</p>
)
const TimeConsumingHelperText = `This table lists queries ordered by their cumulative total execution time.
@@ -84,7 +91,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
Look for queries with high or mean execution times. These are often good candidates for optimization.
`
const panelClassNames = 'text-sm max-w-none flex flex-col gap-8 py-4'
const helperTextClassNames = 'prose text-sm max-w-2xl text-scale-1000'
@@ -156,7 +162,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
</Accordion>
)}
<ReactMarkdown className={helperTextClassNames} children={HeaderText} />
{headerText}
<div className="mb-8">
<Button type="default" onClick={() => setShowResetgPgStatStatements(true)}>
+11
View File
@@ -0,0 +1,11 @@
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(1.000000, 1.000000)">
<rect fill="#349969" stroke="#133929" stroke-width="2" x="0" y="0" width="18" height="18" rx="2"></rect>
<line x1="0" y1="6" x2="18" y2="6" stroke="#133929" stroke-width="2"></line>
<line x1="0" y1="12" x2="18" y2="12" stroke="#133929" stroke-width="2"></line>
<line x1="6" y1="18" x2="6" y2="6" stroke="#133929" stroke-width="2"></line>
<line x1="12" y1="18" x2="12" y2="6" stroke="#133929" stroke-width="2"></line>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 822 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(1.000000, 1.000000)">
<rect stroke-width="2" x="0" y="0" width="18" height="18" rx="2"></rect>
<path d="M3,9.36363636 C3,9.36363636 5.18181818,5 9,5 C12.8181818,5 15,9.36363636 15,9.36363636 C15,9.36363636 12.8181818,13.7272727 9,13.7272727 C5.18181818,13.7272727 3,9.36363636 3,9.36363636 Z" stroke-width="1.5"></path>
<circle stroke-width="1.5" cx="9" cy="9.36363636" r="1.63636364"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+18
View File
@@ -413,3 +413,21 @@
}
}
}
.view-icon {
rect {
@apply fill-blue-500 stroke-blue-500;
}
path, circle {
@apply stroke-blue-900 fill-blue-500;;
}
}
.table-icon {
rect {
@apply fill-green-1000 stroke-green-500;
}
line {
@apply stroke-green-500;
}
}