mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
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={
|
||||
|
||||
+33
-37
@@ -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
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
@@ -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)}>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user