Merge branch 'master' into feat/compute-and-disk

This commit is contained in:
Jonathan Summers-Muir committed 2024-10-30 19:00:47 +08:00
commit 3bddba3bc5
143 files changed
+3144 -2566

No files matched your search

+3
View File
@@ -3,6 +3,9 @@
/apps/studio/ @supabase/Dashboard
# temporary until we've ironed out all the problems with sync workflow
/apps/docs/content/troubleshooting/ @charislam
/apps/www/ @supabase/marketing
/apps/www/public/images/blog @supabase/marketing
/apps/www/lib/redirects.js
@@ -0,0 +1,51 @@
name: '[Docs] Sync troubleshooting guides to GitHub Discussions'
on:
push:
branches:
- master
paths:
- 'apps/docs/content/troubleshooting/**'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-troubleshooting:
runs-on: ubuntu-latest
env:
SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
apps/docs
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Troubleshooting script
run: npm --prefix=apps/docs run troubleshooting:sync
- name: Create Pull Request
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7.0.5
with:
commit-message: '[bot] sync troubleshooting guides to db'
title: '[bot] sync troubleshooting guides to db'
author: 'github-docs-sync-bot <github-docs-sync-bot@supabase.com>'
branch: 'bot/docs-sync-troubleshooting'
branch-suffix: 'random'
+59 -4
View File
@@ -1798,13 +1798,68 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"multi-select-form": {
name: "multi-select-form",
"multi-select-disabled": {
name: "multi-select-disabled",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-form")),
component: React.lazy(() => import("@/registry/default/example/multi-select-disabled")),
source: "",
files: ["registry/default/example/multi-select-form.tsx"],
files: ["registry/default/example/multi-select-disabled.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-badge-limit-wrap": {
name: "multi-select-badge-limit-wrap",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit-wrap")),
source: "",
files: ["registry/default/example/multi-select-badge-limit-wrap.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-badge-limit": {
name: "multi-select-badge-limit",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit")),
source: "",
files: ["registry/default/example/multi-select-badge-limit.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-combobox": {
name: "multi-select-combobox",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-combobox")),
source: "",
files: ["registry/default/example/multi-select-combobox.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-deletable-badge": {
name: "multi-select-deletable-badge",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-deletable-badge")),
source: "",
files: ["registry/default/example/multi-select-deletable-badge.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-inline-search-input": {
name: "multi-select-inline-search-input",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-inline-search-input")),
source: "",
files: ["registry/default/example/multi-select-inline-search-input.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
@@ -4,6 +4,14 @@ description: Latest updates and announcements.
toc: false
---
## 18th Oct 2024 - Update multi-select
Improved Multi Select with combobox, badge limit, and more examples.
- [Multi Select](/design-system/components/multi-select)
[PR](https://github.com/supabase/supabase/pull/29695)
## 12th June 2024 - Button styles updated
- [`<Button />`](/design-system/docs/components/button) styles updated
@@ -12,31 +12,62 @@ fragment: true
```tsx
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
MultiSelectorContent,
MultiSelectorItem,
} from 'ui-patterns/multi-select'
```
```tsx
<MultiSelector values={value} onValuesChange={setValue}>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select items" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="1">Item 1</MultiSelectorItem>
<MultiSelectorItem value="2">Item 2</MultiSelectorItem>
<MultiSelectorItem value="3">Item 3</MultiSelectorItem>
</MultiSelectorList>
<MultiSelector
open={open}
onOpenChange={setOpen}
selected={selected}
onSelectedChange={onSelectedChange}
>
<MultiSelectorTrigger className="w-72" label="Fruits" />
<MultiSelectorContent sameWidthAsTrigger>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorContent>
</MultiSelector>
```
## Examples
### Form
### Disabled
<ComponentPreview name="multi-select-form" />
<ComponentPreview name="multi-select-disabled" />
### Badge Combo Box
Use `MultiSelectorInput`to add a search input.
<ComponentPreview name="multi-select-combobox" />
### Badge Limit
badgeLimit: `number` | `"wrap"`.
`badgeLimit` prop on the `MultiSelectorTrigger` component can be used to limit the number of badges displayed.
<ComponentPreview name="multi-select-badge-limit" />
### Badge Limit="wrap"
`badgeLimit` prop can also be "wrap" to wrap the badges to the next line.
<ComponentPreview name="multi-select-badge-limit-wrap" />
### Deletable Badge
Add the `deletableBadge` prop on the `MultiSelectorTrigger` component to enable badge removal directly from the badge.
<ComponentPreview name="multi-select-deletable-badge" />
### Inline search input
Use `mode="inline-combobox"` on the `MultiSelectorTrigger` component to add an inline search input.
<ComponentPreview name="multi-select-inline-search-input" />
@@ -0,0 +1,45 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>([
'Apple',
'Banana',
'Cherry',
'Date',
'Elderberrie',
])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
className="w-72"
label="Select fruits"
persistLabel
badgeLimit="wrap"
showIcon={false}
deletableBadge={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
@@ -0,0 +1,48 @@
import { Minus, Plus } from 'lucide-react'
import { useState } from 'react'
import { Button } from 'ui'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>(['Apple', 'Banana', 'Cherry'])
const [limit, setLimit] = useState(2)
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<Button size="tiny" type="default" onClick={() => setLimit(limit - 1)} disabled={limit < 1}>
<Minus size={12} />
</Button>
<span className="text-sm text-foreground/90 peer-checked:line-through font-semibold hover:cursor-pointer">
Limit: {limit}
</span>
<Button size="tiny" type="default" onClick={() => setLimit(limit + 1)}>
<Plus size={12} />
</Button>
</div>
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger className="w-72" label="Select fruits" badgeLimit={limit} />
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
)
}
@@ -0,0 +1,34 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>([])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger className="w-72" label="Select fruits" badgeLimit="wrap" />
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search fruits" showResetIcon />
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
@@ -0,0 +1,37 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>(['Apple', 'Banana'])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
className="w-72"
label="Select fruits"
deletableBadge
badgeLimit="wrap"
showIcon={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
@@ -2,25 +2,42 @@ import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [value, setValue] = useState<string[]>([])
const [selectedValues, setSelectedValues] = useState<string[]>([])
const fruits = [
{ value: 'Apple', isDisabled: false },
{ value: 'Banana', isDisabled: false },
{ value: 'Cherry', isDisabled: false },
{ value: 'Date', isDisabled: false },
{ value: 'Elderberrie', isDisabled: false },
{ value: 'Fig', isDisabled: false },
{ value: 'Grape', isDisabled: false },
{ value: 'Kiwi', isDisabled: true },
{ value: 'Mango', isDisabled: false },
{ value: 'Strawberry', isDisabled: false },
]
return (
<MultiSelector values={value} onValuesChange={setValue} size="small">
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select items" />
</MultiSelectorTrigger>
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
className="w-72"
label="Select fruits"
badgeLimit="wrap"
showIcon={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="1">Item 1</MultiSelectorItem>
<MultiSelectorItem value="2">Item 2</MultiSelectorItem>
<MultiSelectorItem value="3">Item 3</MultiSelectorItem>
{fruits.map(({ value, isDisabled }) => (
<MultiSelectorItem key={value} value={value} disabled={isDisabled}>
{value}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
@@ -0,0 +1,18 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>([])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues} disabled={true}>
<MultiSelectorTrigger className="w-72" label="Select fruits" />
</MultiSelector>
)
}
@@ -1,105 +0,0 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import {
Button,
Form_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
} from 'ui'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
const form = z.object({
value: z.array(z.string()).nonempty('Please select at least one person'),
})
type Form = z.infer<typeof form>
const users = [
{
name: 'ThePrimeagen',
picture: 'https://pbs.twimg.com/profile_images/1759330620160049152/2i_wkOoK_400x400.jpg',
},
{
name: 'Shadcn',
picture: 'https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg',
},
{
name: 'Theo',
picture: 'https://pbs.twimg.com/profile_images/1605762947686375425/lsoGWWty_400x400.jpg',
},
]
const MultiSelectZod = () => {
const multiForm = useForm<Form>({
resolver: zodResolver(form),
defaultValues: form.parse({ value: [users[0].name] }),
})
const onSubmit = (data: Form) => {
toast('Form submitted ', { description: JSON.stringify(data, null, 2) })
}
return (
<Form_Shadcn_ {...multiForm}>
<form onSubmit={multiForm.handleSubmit(onSubmit)} className="space-y-3 grid gap-3 w-full">
<FormField_Shadcn_
control={multiForm.control}
name="value"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Invite people</FormLabel_Shadcn_>
<MultiSelector onValuesChange={field.onChange} values={field.value} size={'small'}>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select people to invite" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{users.map((user) => (
<MultiSelectorItem key={user.name} value={user.name}>
<div className="flex items-center space-x-2">
<Image
src={user.picture}
alt={user.name}
width={32}
height={32}
className="w-8 h-8 rounded-full"
/>
<span>{user.name}</span>
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
<FormDescription_Shadcn_>
Select people to invite to this event
</FormDescription_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Button type="secondary" size="medium" htmlType="submit">
Submit
</Button>
</form>
</Form_Shadcn_>
)
}
export default MultiSelectZod
@@ -0,0 +1,39 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>(['Apple'])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
mode="inline-combobox"
className="w-72"
label="Select fruits"
deletableBadge
badgeLimit="wrap"
showIcon={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
+27 -2
View File
@@ -1036,9 +1036,34 @@ export const examples: Registry = [
files: ['example/multi-select-demo.tsx'],
},
{
name: 'multi-select-form',
name: 'multi-select-disabled',
type: 'components:example',
files: ['example/multi-select-form.tsx'],
files: ['example/multi-select-disabled.tsx'],
},
{
name: 'multi-select-badge-limit-wrap',
type: 'components:example',
files: ['example/multi-select-badge-limit-wrap.tsx'],
},
{
name: 'multi-select-badge-limit',
type: 'components:example',
files: ['example/multi-select-badge-limit.tsx'],
},
{
name: 'multi-select-combobox',
type: 'components:example',
files: ['example/multi-select-combobox.tsx'],
},
{
name: 'multi-select-deletable-badge',
type: 'components:example',
files: ['example/multi-select-deletable-badge.tsx'],
},
{
name: 'multi-select-inline-search-input',
type: 'components:example',
files: ['example/multi-select-inline-search-input.tsx'],
},
{
name: 'tree-view-demo',
@@ -4,6 +4,10 @@ import TroubleshootingPage from '~/features/docs/Troubleshooting.page'
import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Troubleshooting.utils'
import { PROD_URL } from '~/lib/constants'
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
export const revalidate = 86_400
export const dynamicParams = false
export default async function TroubleshootingEntryPage({
params: { slug },
}: {
@@ -16,6 +16,9 @@ import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.ut
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import { PROD_URL } from '~/lib/constants'
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
export const revalidate = 86_400
export default async function GlobalTroubleshootingPage() {
const troubleshootingEntries = await getAllTroubleshootingEntries()
const keywords = await getAllTroubleshootingKeywords()
@@ -963,10 +963,6 @@ export const database: NavMenuConstant = {
name: 'pg_net: Async Networking',
url: '/guides/database/extensions/pg_net',
},
{
name: 'pg_partman: Partition Maintenance',
url: '/guides/database/extensions/pg_partman',
},
{
name: 'pg_plan_filter: Restrict Total Cost',
url: '/guides/database/extensions/pg_plan_filter',
@@ -987,10 +983,6 @@ export const database: NavMenuConstant = {
name: 'PostGIS: Geo queries',
url: '/guides/database/extensions/postgis',
},
{
name: 'pgmq: Queues',
url: '/guides/database/extensions/pgmq',
},
{
name: 'pgsodium (pending deprecation): Encryption Features',
url: '/guides/database/extensions/pgsodium',
@@ -59,6 +59,12 @@ Client libraries like python's [vecs](https://github.com/supabase/vecs) use this
#!/usr/bin/env python3
import vecs
# In practice, do not hard-code your password. Use environment variables.
DB_CONNECTION = "postgresql://<user>:<password>@<host>:<port>/<db_name>"
# create vector store client
vx = vecs.create_client(DB_CONNECTION)
docs = vx.get_or_create_collection(name="docs", dimension=1536)
docs.upsert(vectors=[
@@ -264,6 +264,53 @@ Depending on your application requirements, data conflicts can arise when an ano
2. Overwrite the items in the cart with those from the anonymous user
3. Merge the items in the cart together
### Linking an anonymous user to an existing account
In some cases, you may need to link an anonymous user to an existing account rather than creating a new permanent account. This process requires manual handling of potential conflicts. Here's a general approach:
```javascript
// 1. Sign in anonymously (assuming the user is already signed in anonymously)
const { data: anonData, error: anonError } = await supabase.auth.getSession()
// 2. Attempt to update the user with the existing email
const { data: updateData, error: updateError } = await supabase.auth.updateUser({
email: 'existing_user@example.com',
})
// 3. Handle the error (since the email belongs to an existing user)
if (updateError) {
console.log('This email belongs to an existing user. Please sign in to that account.')
// 4. Sign in to the existing account
const {
data: { user: existingUser },
error: signInError,
} = await supabase.auth.signInWithPassword({
email: 'existing_user@example.com',
password: 'user_password',
})
if (existingUser) {
// 5. Reassign entities tied to the anonymous user
// This step will vary based on your specific use case and data model
const { data: reassignData, error: reassignError } = await supabase
.from('your_table')
.update({ user_id: existingUser.id })
.eq('user_id', anonData.session.user.id)
// 6. Implement your chosen conflict resolution strategy
// This could involve merging data, overwriting, or other custom logic
await resolveDataConflicts(anonData.session.user.id, existingUser.id)
}
}
// Helper function to resolve data conflicts (implement based on your strategy)
async function resolveDataConflicts(anonymousUserId, existingUserId) {
// Implement your conflict resolution logic here
// This could involve ignoring the anonymous user's metadata, overwriting the existing user's metadata, or merging the data of both the anonymous and existing user.
}
```
## Abuse prevention and rate limits
Since anonymous users are stored in your database, bad actors can abuse the endpoint to increase your database size drastically. It is strongly recommended to [enable invisible Captcha or Cloudflare Turnstile](/docs/guides/auth/auth-captcha) to prevent abuse for anonymous sign-ins. An IP-based rate limit is enforced at 30 requests per hour which can be modified in your [dashboard](/dashboard/project/_/auth/rate-limits). You can refer to the full list of rate limits [here](/docs/guides/platform/going-into-prod#rate-limiting-resource-allocation--abuse-prevention).
@@ -27,7 +27,7 @@ The templating system provides the following variables for use:
## Editing email templates
On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/cli/customizing-email-templates).
On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/local-development/customizing-email-templates).
## Mobile Deep Linking
@@ -6,6 +6,17 @@ subtitle: 'Use a custom email provider to send authentication messages'
The Send Email Hook runs before an email is sent and allows for flexibility around email sending. You can use this hook to configure a back-up email provider or add internationalization to your emails.
## Email Sending Behavior
Email sending depends on two settings: Email Provider and Auth Hook status.
| Email Provider | Auth Hook | Result |
| -------------- | --------- | -------------------------------------------------------------------- |
| Enabled | Enabled | Auth Hook handles email sending (SMTP not used) |
| Enabled | Disabled | SMTP handles email sending (custom if configured, default otherwise) |
| Disabled | Enabled | Email Signups Disabled |
| Disabled | Disabled | Email Signups Disabled |
**Inputs**
| Field | Type | Description |
@@ -630,6 +641,11 @@ const templates = {
},
}
function generateConfirmationURL(email_data) {
// TODO: replace the ref with your project ref
return `https://<ref>.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
}
Deno.serve(async (req) => {
const payload = await req.text()
const serverToken = Deno.env.get('POSTMARK_SERVER_TOKEN')
@@ -642,8 +658,9 @@ Deno.serve(async (req) => {
const subject = subjects[language][email_data.email_action_type] || 'Notification'
let template = templates[language][email_data.email_action_type]
const confirmation_url = generateConfirmationURL(email_data)
let htmlBody = template
.replace('{{confirmation_url}}', email_data.confirmation_url)
.replace('{{confirmation_url}}', confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
@@ -736,10 +753,16 @@ const templates = {
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`
};
function generateConfirmationURL(email_data) {
// TODO: replace the ref with your project ref
return `https://<ref>.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
}
async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise<Response> {
const subject = subjects[email_data.email_action_type] || 'Notification';
const confirmation_url = generateConfirmationURL(email_data)
let template = templates[email_data.email_action_type];
let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url)
let htmlBody = template.replace('{{confirmation_url}}', confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
@@ -767,7 +790,8 @@ async function sendEmailWithPostmark(user: any, email_data: any, serverToken: st
async function sendEmailWithSendGrid(user: any, email_data: any, apiKey: string): Promise<Response> {
const subject = subjects[email_data.email_action_type] || 'Notification';
let template = templates[email_data.email_action_type];
let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url)
cont confirmation_url = generateConfirmationURL(email_data)
let htmlBody = template.replace('{{confirmation_url}}', confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
@@ -77,6 +77,20 @@ Future<void> signInWithGithub() async {
}
```
</TabPanel>
<TabPanel id="swift" label="Swift">
When your user signs in, call [signInWithWithOAuth](/docs/reference/swift/auth-signinwithoauth) with `.github` as the `Provider`:
```swift
func signInWithGithub() async throws {
try await supabase.auth.signInWithOAuth(
provider: .github,
redirectTo: URL(string: "my-custom-scheme://my-app-host")
)
}
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
@@ -128,7 +128,7 @@ If you'd like us to add a new quickstart guide, share your [feedback](https://su
- [Prisma](/docs/guides/database/prisma)
- [Drizzle](/docs/guides/database/drizzle)
- [Postgres.js](/docs/guides/database/drizzle)
- [Postgres.js](/docs/guides/database/postgres-js)
### External tools
@@ -1,100 +0,0 @@
---
id: 'pg_partman'
title: 'pg_partman: partition management'
description: 'Automated partition management'
---
[`pg_partman`](https://github.com/pgpartman/pg_partman) is a PostgreSQL extension that enables automated partition management. It removes the need to create and detach partitions for tables partitioned by time or an integer series.
## Enable the extension
To enable `pg_partman`, first enable the extension in your database inside a dedicated schema.
{/* prettier-ignore */}
```sql
create schema partman;
create extension pg_partman schema partman;
```
## Setting up Partitioning
pg_partman supports partitioning based on time (e.g., by day, month, or year) or by a sequential integer. Setup is performed using the `create_parent` function.
For time-based partitioning:
{/* prettier-ignore */}
```sql
select partman.create_parent('schema_name.table_name', 'partition_column', 'time', 'daily');
```
or for integer-based partitioning:
{/* prettier-ignore */}
```sql
select partman.create_parent('schema_name.table_name', 'partition_column', 'integer', '1000');
```
## API
- partman.create_parent: creates a parent table for partitioning
- partman.run_maintenance: ensures partitions are created and dropped as per the defined settings. This should be run periodically, such as via a cron job scheduled by pg_cron
## Example
Let’s set up a simple partitioning example using pg_partman with time-based partitions.
{/* prettier-ignore */}
```sql
-- Create the table
create table public.foo (
id serial primary key,
data text,
created_at timestamp not null
);
-- Create the parent table
select
partman.create_parent(
'public.foo',
'created_at',
'time',
'daily'
);
-- Configure the partitioned table
update
partman.part_config
set
infinite_time_partitions = true,
retention = '3 months',
retention_keep_table=true
where
parent_table = 'public.foo';
```
For more information about available configuration, see the [pg_partman docs](https://github.com/pgpartman/pg_partman/blob/de93f4a104b8566087f4b9b2a0edf25893672da8/doc/pg_partman.md#configuration-tables).
Following these statements, partitions for `public.foo` will be automatically managed as necessary when the `partman.run_maintenance()` function is called.
## Running Maintenance
It is important to call the `partman.run_maintenance()` procedure frequently to ensure partitions are created and detached according to the partitioned table's configuration.
{/* prettier-ignore */}
```sql
call partman.run_maintenance_proc();
```
To automate this task, consider using `pg_cron`.
{/* prettier-ignore */}
```sql
create extension pg_cron;
select
cron.schedule('@hourly', $$call partman.run_maintenance_proc()$$);
```
## Resources
- Official [pg_partman documentation](https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman_howto.md)
@@ -1,707 +0,0 @@
---
id: 'pgmq'
title: 'pgmq: Queues'
description: 'pgmq: Managed queues in Postgres'
---
pgmq is a lightweight message queue built on Postgres.
## Features
- Lightweight - No background worker or external dependencies, just Postgres functions packaged in an extension
- "exactly once" delivery of messages to a consumer within a visibility timeout
- API parity with AWS SQS and RSMQ
- Messages stay in the queue until explicitly removed
- Messages can be archived, instead of deleted, for long-term retention and replayability
## Enable the extension
```sql
create extension pgmq;
```
## Usage [#get-usage]
### Sending Messages
#### send
Send a single message to a queue.
{/* prettier-ignore */}
```sql
pgmq.send(
queue_name text,
msg jsonb,
delay integer default 0
)
returns setof bigint
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------ | :----------------------------------------------------------------- |
| queue_name | text | The name of the queue |
| msg | jsonb | The message to send to the queue |
| delay | integer | Time in seconds before the message becomes visible. Defaults to 0. |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.send('my_queue', '{"hello": "world"}');
send
------
4
```
---
#### send_batch
Send 1 or more messages to a queue.
{/* prettier-ignore */}
```sql
pgmq.send_batch(
queue_name text,
msgs jsonb[],
delay integer default 0
)
returns setof bigint
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------ | :------------------------------------------------------------------ |
| queue_name | text | The name of the queue |
| msgs | jsonb[] | Array of messages to send to the queue |
| delay | integer | Time in seconds before the messages becomes visible. Defaults to 0. |
{/* prettier-ignore */}
```sql
select * from pgmq.send_batch(
'my_queue',
array[
'{"hello": "world_0"}'::jsonb,
'{"hello": "world_1"}'::jsonb
]
);
send_batch
------------
1
2
```
---
### Reading Messages
#### read
Read 1 or more messages from a queue. The VT specifies the delay in seconds between reading and the message becoming invisible to other consumers.
{/* prettier-ignore */}
```sql
pgmq.read(
queue_name text,
vt integer,
qty integer
)
returns setof pgmq.message_record
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------ | :-------------------------------------------------------------- |
| queue_name | text | The name of the queue |
| vt | integer | Time in seconds that the message become invisible after reading |
| qty | integer | The number of messages to read from the queue. Defaults to 1 |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.read('my_queue', 10, 2);
msg_id | read_ct | enqueued_at | vt | message
--------+---------+-------------------------------+-------------------------------+----------------------
1 | 1 | 2023-10-28 19:14:47.356595-05 | 2023-10-28 19:17:08.608922-05 | {"hello": "world_0"}
2 | 1 | 2023-10-28 19:14:47.356595-05 | 2023-10-28 19:17:08.608974-05 | {"hello": "world_1"}
(2 rows)
```
---
#### read_with_poll
Same as read(). Also provides convenient long-poll functionality.
When there are no messages in the queue, the function call will wait for `max_poll_seconds` in duration before returning.
If messages reach the queue during that duration, they will be read and returned immediately.
{/* prettier-ignore */}
```sql
pgmq.read_with_poll(
queue_name text,
vt integer,
qty integer,
max_poll_seconds integer default 5,
poll_interval_ms integer default 100
)
returns setof pgmq.message_record
```
**Parameters:**
| Parameter | Type | Description |
| :--------------- | :------ | :-------------------------------------------------------------------------- |
| queue_name | text | The name of the queue |
| vt | integer | Time in seconds that the message become invisible after reading. |
| qty | integer | The number of messages to read from the queue. Defaults to 1. |
| max_poll_seconds | integer | Time in seconds to wait for new messages to reach the queue. Defaults to 5. |
| poll_interval_ms | integer | Milliseconds between the internal poll operations. Defaults to 100. |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.read_with_poll('my_queue', 1, 1, 5, 100);
msg_id | read_ct | enqueued_at | vt | message
--------+---------+-------------------------------+-------------------------------+--------------------
1 | 1 | 2023-10-28 19:09:09.177756-05 | 2023-10-28 19:27:00.337929-05 | {"hello": "world"}
```
---
#### pop
Reads a single message from a queue and deletes it upon read.
Note: utilization of pop() results in at-most-once delivery semantics if the consuming application does not guarantee processing of the message.
{/* prettier-ignore */}
```sql
pgmq.pop(queue_name text)
returns setof pgmq.message_record
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
{/* prettier-ignore */}
```sql
pgmq=# select * from pgmq.pop('my_queue');
msg_id | read_ct | enqueued_at | vt | message
--------+---------+-------------------------------+-------------------------------+--------------------
1 | 2 | 2023-10-28 19:09:09.177756-05 | 2023-10-28 19:27:00.337929-05 | {"hello": "world"}
```
---
### Deleting/Archiving Messages
#### delete (single)
Deletes a single message from a queue.
{/* prettier-ignore */}
```sql
pgmq.delete (queue_name text, msg_id: bigint)
returns boolean
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :----- | :---------------------------------- |
| queue_name | text | The name of the queue |
| msg_id | bigint | Message ID of the message to delete |
Example:
{/* prettier-ignore */}
```sql
select pgmq.delete('my_queue', 5);
delete
--------
t
```
---
#### delete (batch)
Delete one or many messages from a queue.
{/* prettier-ignore */}
```sql
pgmq.delete (queue_name text, msg_ids: bigint[])
returns setof bigint
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------- | :----------------------------- |
| queue_name | text | The name of the queue |
| msg_ids | bigint[] | Array of message IDs to delete |
Examples:
Delete two messages that exist.
{/* prettier-ignore */}
```sql
select * from pgmq.delete('my_queue', array[2, 3]);
delete
--------
2
3
```
Delete two messages, one that exists and one that does not. Message `999` does not exist.
```sql
select * from pgmq.delete('my_queue', array[6, 999]);
delete
--------
6
```
---
#### purge_queue
Permanently deletes all messages in a queue. Returns the number of messages that were deleted.
```text
purge_queue(queue_name text)
returns bigint
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
Purge the queue when it contains 8 messages;
{/* prettier-ignore */}
```sql
select * from pgmq.purge_queue('my_queue');
purge_queue
-------------
8
```
---
#### archive (single)
Removes a single requested message from the specified queue and inserts it into the queue's archive.
{/* prettier-ignore */}
```sql
pgmq.archive(queue_name text, msg_id bigint)
returns boolean
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :----- | :----------------------------------- |
| queue_name | text | The name of the queue |
| msg_id | bigint | Message ID of the message to archive |
Returns
Boolean value indicating success or failure of the operation.
Example; remove message with ID 1 from queue `my_queue` and archive it:
{/* prettier-ignore */}
```sql
select * from pgmq.archive('my_queue', 1);
archive
---------
t
```
---
#### archive (batch)
Deletes a batch of requested messages from the specified queue and inserts them into the queue's archive.
Returns an array of message ids that were successfully archived.
```text
pgmq.archive(queue_name text, msg_ids bigint[])
RETURNS SETOF bigint
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------- | :------------------------------ |
| queue_name | text | The name of the queue |
| msg_ids | bigint[] | Array of message IDs to archive |
Examples:
Delete messages with ID 1 and 2 from queue `my_queue` and move to the archive.
{/* prettier-ignore */}
```sql
select * from pgmq.archive('my_queue', array[1, 2]);
archive
---------
1
2
```
Delete messages 4, which exists and 999, which does not exist.
{/* prettier-ignore */}
```sql
select * from pgmq.archive('my_queue', array[4, 999]);
archive
---------
4
```
---
### Queue Management
#### create
Create a new queue.
{/* prettier-ignore */}
```sql
pgmq.create(queue_name text)
returns void
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
{/* prettier-ignore */}
```sql
select from pgmq.create('my_queue');
create
--------
```
---
#### create_partitioned
Create a partitioned queue.
{/* prettier-ignore */}
```sql
pgmq.create_partitioned (
queue-ue_name text,
partition_interval text default '10000'::text,
retention_interval text default '100000'::text
)
returns void
```
**Parameters:**
| Parameter | Type | Description |
| :----------------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
| partition_interval | text | The name of the queue |
| retention_interval | text | The name of the queue |
Example:
Create a queue with 100,000 messages per partition, and will retain 10,000,000 messages on old partitions. Partitions greater than this will be deleted.
{/* prettier-ignore */}
```sql
select * from pgmq.create_partitioned(
'my_partitioned_queue',
'100000',
'10000000'
);
create_partitioned
--------------------
```
---
#### create_unlogged
Creates an unlogged table. This is useful when write throughput is more important that durability.
See Postgres documentation for [unlogged tables](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for more information.
{/* prettier-ignore */}
```sql
pgmq.create_unlogged(queue_name text)
returns void
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
{/* prettier-ignore */}
```sql
select pgmq.create_unlogged('my_unlogged');
create_unlogged
-----------------
```
---
#### detach_archive
Drop the queue's archive table as a member of the PGMQ extension. Useful for preventing the queue's archive table from being drop when `drop extension pgmq` is executed.
This does not prevent the further archives() from appending to the archive table.
{/* prettier-ignore */}
```sql
pgmq.detach_archive(queue_name text)
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.detach_archive('my_queue');
detach_archive
----------------
```
---
#### drop_queue
Deletes a queue and its archive table.
{/* prettier-ignore */}
```sql
pgmq.drop_queue(queue_name text)
returns boolean
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.drop_queue('my_unlogged');
drop_queue
------------
t
```
### Utilities
#### set_vt
Sets the visibility timeout of a message to a specified time duration in the future. Returns the record of the message that was updated.
{/* prettier-ignore */}
```sql
pgmq.set_vt(
queue_name text,
msg_id bigint,
vt_offset integer
)
returns pgmq.message_record
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :------ | :-------------------------------------------------------------------- |
| queue_name | text | The name of the queue |
| msg_id | bigint | ID of the message to set visibility time |
| vt_offset | integer | Duration from now, in seconds, that the message's VT should be set to |
Example:
Set the visibility timeout of message 1 to 30 seconds from now.
```sql
select * from pgmq.set_vt('my_queue', 11, 30);
msg_id | read_ct | enqueued_at | vt | message
--------+---------+-------------------------------+-------------------------------+----------------------
1 | 0 | 2023-10-28 19:42:21.778741-05 | 2023-10-28 19:59:34.286462-05 | {"hello": "world_0"}
```
---
#### list_queues
List all the queues that currently exist.
{/* prettier-ignore */}
```sql
list_queues()
RETURNS TABLE(
queue_name text,
created_at timestamp with time zone,
is_partitioned boolean,
is_unlogged boolean
)
```
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.list_queues();
queue_name | created_at | is_partitioned | is_unlogged
----------------------+-------------------------------+----------------+-------------
my_queue | 2023-10-28 14:13:17.092576-05 | f | f
my_partitioned_queue | 2023-10-28 19:47:37.098692-05 | t | f
my_unlogged | 2023-10-28 20:02:30.976109-05 | f | t
```
---
#### metrics
Get metrics for a specific queue.
{/* prettier-ignore */}
```sql
pgmq.metrics(queue_name: text)
returns table(
queue_name text,
queue_length bigint,
newest_msg_age_sec integer,
oldest_msg_age_sec integer,
total_messages bigint,
scrape_time timestamp with time zone
)
```
**Parameters:**
| Parameter | Type | Description |
| :--------- | :--- | :-------------------- |
| queue_name | text | The name of the queue |
**Returns:**
| Attribute | Type | Description |
| :----------------- | :----------------------- | :------------------------------------------------------------------------ |
| queue_name | text | The name of the queue |
| queue_length | bigint | Number of messages currently in the queue |
| newest_msg_age_sec | integer \| null | Age of the newest message in the queue, in seconds |
| oldest_msg_age_sec | integer \| null | Age of the oldest message in the queue, in seconds |
| total_messages | bigint | Total number of messages that have passed through the queue over all time |
| scrape_time | timestamp with time zone | The current timestamp |
Example:
{/* prettier-ignore */}
```sql
select * from pgmq.metrics('my_queue');
queue_name | queue_length | newest_msg_age_sec | oldest_msg_age_sec | total_messages | scrape_time
------------+--------------+--------------------+--------------------+----------------+-------------------------------
my_queue | 16 | 2445 | 2447 | 35 | 2023-10-28 20:23:08.406259-05
```
---
#### metrics_all
Get metrics for all existing queues.
```text
pgmq.metrics_all()
RETURNS TABLE(
queue_name text,
queue_length bigint,
newest_msg_age_sec integer,
oldest_msg_age_sec integer,
total_messages bigint,
scrape_time timestamp with time zone
)
```
**Returns:**
| Attribute | Type | Description |
| :----------------- | :----------------------- | :------------------------------------------------------------------------ |
| queue_name | text | The name of the queue |
| queue_length | bigint | Number of messages currently in the queue |
| newest_msg_age_sec | integer \| null | Age of the newest message in the queue, in seconds |
| oldest_msg_age_sec | integer \| null | Age of the oldest message in the queue, in seconds |
| total_messages | bigint | Total number of messages that have passed through the queue over all time |
| scrape_time | timestamp with time zone | The current timestamp |
{/* prettier-ignore */}
```sql
select * from pgmq.metrics_all();
queue_name | queue_length | newest_msg_age_sec | oldest_msg_age_sec | total_messages | scrape_time
----------------------+--------------+--------------------+--------------------+----------------+-------------------------------
my_queue | 16 | 2563 | 2565 | 35 | 2023-10-28 20:25:07.016413-05
my_partitioned_queue | 1 | 11 | 11 | 1 | 2023-10-28 20:25:07.016413-05
my_unlogged | 1 | 3 | 3 | 1 | 2023-10-28 20:25:07.016413-05
```
### Types
#### message_record
The complete representation of a message in a queue.
| Attribute Name | Type | Description |
| :------------- | :----------------------- | :--------------------------------------------------------------------- |
| msg_id | bigint | Unique ID of the message |
| read_ct | bigint | Number of times the message has been read. Increments on read(). |
| enqueued_at | timestamp with time zone | time that the message was inserted into the queue |
| vt | timestamp with time zone | Timestamp when the message will become available for consumers to read |
| message | jsonb | The message payload |
Example:
{/* prettier-ignore */}
```sql
msg_id | read_ct | enqueued_at | vt | message
--------+---------+-------------------------------+-------------------------------+--------------------
1 | 1 | 2023-10-28 19:06:19.941509-05 | 2023-10-28 19:06:27.419392-05 | {"hello": "world"}
```
## Resources
- Official Docs: [pgmq/api](https://tembo.io/pgmq/#creating-a-queue)
@@ -14,3 +14,4 @@ Supavisor logs are available under [Pooler Logs](/dashboard/project/_/logs/poole
| Subscribe error: \{:error, :worker_not_found} | This log event is emitted when the client tries to connect to the database, but Supavisor does not have the necessary information to route the connection. Try reconnecting to the database as it can take some time for the project information to propagate to Supavisor. | N/A |
| Subscribe error: \{:error, \{:badrpc, \{:error, \{:erpc, :timeout}}}} | This is a timeout error when the communication between different Supavisor nodes takes longer than expected. Try reconnecting to the database. | N/A |
| Terminating with reason :client_termination when state was :busy | This error happens when the client terminates the connection before the connection with the database is completed. | N/A |
| Error: received invalid response to GSSAPI negotiation: S | This error happens due to "gssencmode" parameter not set to disabled. | Follow this [guide](https://github.com/orgs/supabase/discussions/30173) to resolve. |
+1 -1
View File
@@ -25,5 +25,5 @@ See the [self-hosting guides](/docs/guides/self-hosting) for instructions on hos
You can automate deployments using:
- The [Supabase GitHub integration](/dashboard/project/_/settings/integrations) (with branching enabled)
- The [Supabase CLI](/docs/local-development/cli) in your own continuous deployment pipeline
- The [Supabase CLI](/docs/guides/local-development) in your own continuous deployment pipeline
- The [Supabase Terraform provider](/docs/guides/deployment/terraform)
@@ -2,169 +2,14 @@
id: 'functions-dart-edge'
title: 'Dart Edge'
description: 'Write your functions using Dart.'
tocVideo: '53jhgrDONW4'
---
[Dart Edge](https://docs.dartedge.dev/), a project built and maintained by [Invertase](https://invertase.io/), enables Dart code to be run on different edge environments. This guide will walk you through how you can start using Dart Edge to write your backend code using Dart and run it on Supabase.
<Admonition type="caution">
Dart Edge is an experimental project - it's probably not ready for production usage unless you're okay with living on the 'edge'. Open issues on [Dart Edge repo](https://github.com/invertase/dart_edge) if you encounter any issues.
Please be aware that the Dart Edge project is currently not actively maintained due to numerous breaking changes in Dart's development of (WASM) support.
</Admonition>
## Prerequisites
[Dart Edge](https://docs.dartedge.dev/) is an experimental project that enables you to write Supabase Edge Functions using Dart. It's built and maintained by [Invertase](https://invertase.io/).
Make sure you have [the edge CLI](https://docs.dartedge.dev/#getting-started) installed in addition to [the Supabase CLI](https://supabase.com/docs/guides/cli#installation).
```bash
# install the edge CLI
dart pub global activate edge
```
## Setting up your first Dart Edge project
Create a new Dart Edge project using the `new` command on the edge CLI. This will create a familiar boilerplate for your Dart Edge project such as `main.dart` and `pubspec.yaml` file.
```bash
edge new supabase_functions new_project
```
`cd` into the project and initialize a new Supabase project inside the project you just created.
```bash
cd new_project
supabase init
```
Run `pub get` to install the dependencies.
```bash
dart pub get
```
Open the `lib/main.dart` and look at the initial template.
```dart
void main() {
SupabaseFunctions(fetch: (request) {
return Response("Hello from Supabase Edge Functions!");
});
}
```
`SupabaseFunctions` class has a `fetch` handler, which is expected to return a `Response` object, and has a `request` parameter, which contains information about the request such as body and headers.
## Supporting multiple edge functions
By default, Dart Edge will create a single `main.dart` file in your project, which will be compiled into a function called `dart_edge`.
You can override this behavior and create multiple functions by creating an `edge.yaml` file in the root of your project.
Specify how to map which of your dart files should compile to what Supabase edge functions using the `functions` option.
```yaml
supabase:
functions:
api_handler: 'lib/api.dart'
webhook_handler: 'lib/webhooks.dart'
```
## Using Supabase client
Now let’s try to interact with our Supabase database. We will start by installing the dependencies.
### Add dependencies
Add [`supabase`](https://pub.dev/packages/supabase) to interact with Supabase service.
```bash
dart pub add supabase
```
We also need to use a special HTTP client to HTTP requests in the edge environment.
```bash
dart pub add edge_http_client
```
### Initialize `SupabaseClient`
At this point, you can initialize a `SupabaseClient` by passing the [EdgeHttpClient](https://pub.dev/packages/edge_http_client). We can access the Supabase credentials through the [environment variables](https://supabase.com/docs/guides/functions/secrets) using `Deno.env.get()` method. Note that [Supabase credentials are available by default](https://supabase.com/docs/guides/functions/secrets), so no configurations are necessary.
Now we have the following sample functions code to query Supabase database using the Supabase client.
```dart
import 'dart:convert';
import 'package:supabase_functions/supabase_functions.dart';
import 'package:edge_http_client/edge_http_client.dart';
import 'package:supabase/supabase.dart';
void main() {
final supabase = SupabaseClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, // Use service role key to bypass RLS
httpClient: EdgeHttpClient(),
);
SupabaseFunctions(fetch: (request) async {
// You can query `public.users` table for example.
final List users = await supabase.from('users').select().limit(10);
return Response.json(users);
});
}
```
## Run the Functions
### Run locally
Create a `edge.yaml` file if you don't have one yet in the root of your project. Then, add the following configuration to specify the path to your Supabase project.
```yaml
supabase:
project_path: './supabase'
```
Also, currently Dart Edge doesn't work with the latest versions of Flutter. If you do have the latest version of Flutter on your machine, you can use [Flutter Version Management (FVM)](https://fvm.app/) to use an older version of Flutter for your Dart Edge project.
```bash
fvm use 3.7.12 -f
```
You can read more about how to configure FVM for your project in the [FVM documentation](https://fvm.app/documentation/getting-started).
Run the following command to compile your Dart code. This command will start a watcher that will recompile anytime you make changes to your code.
```bash
edge build supabase_functions --dev
# Or with FVM
fvm dart pub run edge build supabase_functions --dev
```
With docker running, start your local Supabase with the following command
```bash
supabase start
```
While you have the above code running, open another terminal and run the following to start the Supabase local development environment.
```bash
supabase functions serve dart_edge --no-verify-jwt
```
You should be able to access your local function here:
[http://localhost:54321/functions/v1/dart_edge](http://localhost:54321/functions/v1/dart_edge)
### Deploy to the Edge
Run the following commands to compile and deploy your function on Supabase Edge functions.
```bash
edge build supabase_functions
supabase functions deploy dart_edge
```
You will be asked to provide the project reference of your Supabase instance to which you want to deploy the function. You can [link your local project to your Supabase instance](https://supabase.com/docs/guides/getting-started/local-development#link-your-project) to avoid having to provide the Supabase reference every time you deploy.
For detailed information on how to set up and use Dart Edge with Supabase, please refer to the [official Dart Edge documentation for Supabase](https://invertase.docs.page/dart_edge/platform/supabase).
@@ -14,8 +14,6 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform."
- Maximum Function Size (after bundling via CLI): 10MB
- Maximum log message length: 10,000 characters
- Log event threshold: 100 events per 10 seconds
- Max Request: 5GB
- Max Response: No limit
## Other limits & restrictions
@@ -53,18 +53,18 @@ hideToc: true
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Query data from the app">
In `src/App.vue`, create a Supabase client using your project URL and public API (anon) key:
In `app.vue`, create a Supabase client using your project URL and public API (anon) key:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
Replace the existing content in your `src/App.vue` file with the following code.
Replace the existing content in your `app.vue` file with the following code.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```vue src/App.vue
```vue app.vue
<script setup>
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>')
@@ -108,3 +108,9 @@ hideToc: true
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="tip">
The community-maintained [@nuxtjs/supabase](https://supabase.nuxtjs.org/) module provides an alternate DX for working with Supabase in Nuxt.
</Admonition>
@@ -7,7 +7,7 @@ description: 'Filter logs with regular expressions'
## Understanding field references
The log tables are queried with subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`.
The log tables are queried with a subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`.
| column | description |
| ------------- | --------------------------- |
@@ -154,6 +154,10 @@ Any changes made to your billing details will only be reflected in your upcoming
We accept credit card payments only. If you cannot pay via credit card, we do offer alternatives for larger upfront payments. Please create a [support ticket](https://supabase.help) in case you’re interested.
#### What credit card brands are supported?
Visa, Mastercard, American Express, Japan Credit Bureau (JCB), China UnionPay (CUP), Cartes Bancaires
#### What currency can I pay in?
All our invoices are issued in USD, but you can pay in any currency as long as your credit card provider allows charging in USD after conversion.
@@ -132,3 +132,5 @@ set default_transaction_read_only = 'off';
### Reducing disk size
Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB.
In the event that your project is already on the latest version of Postgres and cannot be upgraded, a new version of Postgres will be released approximately every week which you can then upgrade to once it becomes available.
@@ -1,15 +1,10 @@
---
title = "How to monitor Postgres and Supavisor connections"
topics = [
"database",
"supavisor",
]
keywords = [
"Grafana",
"connections",
"performance",
"pooler",
]
github_url = "https://github.com/orgs/supabase/discussions/27141"
topics = [ "database", "supavisor" ]
keywords = [ "Grafana", "connections", "performance", "pooler" ]
date_created = "2024-06-09"
database_id = "bdd39473-ae02-41f5-b75d-877a79f361f6"
---
This guide explains how connections impact your Supabase database's performance and how to optimize them for better resource utilization.
@@ -1,13 +1,10 @@
---
title = "Interpreting Supabase Grafana Memory Charts"
topics = [
"database",
]
keywords = [
"Grafana",
"performance",
"memory"
]
github_url = "https://github.com/orgs/supabase/discussions/27021"
topics = [ "database" ]
keywords = [ "Grafana", "performance", "memory" ]
date_created = "2024-06-05"
database_id = "179d70f3-1e26-4346-9ee8-d340fad382a3"
---
> [Supabase Grafana Installation Guide](/docs/guides/platform/metrics#deploying-supabase-grafana)
@@ -298,7 +298,7 @@ async function ApiEndpointSection({ link, section, servicePath }: ApiEndpointSec
<StickyHeader title={endpointDetails.summary} className="col-[1_/_-1]" />
<div className="flex flex-col gap-12">
<div className="flex items-center gap-2">
<span className="font-mono uppercase text-sm whitespace-nowrap bg-foreground text-background rounded-full font-mono font-medium px-2 py-0.5">
<span className="uppercase text-sm whitespace-nowrap bg-foreground text-background rounded-full font-mono font-medium px-2 py-0.5">
{endpointDetails.method}
</span>
<code className="text-foreground-lighter break-all">{endpointDetails.path}</code>
@@ -4,11 +4,15 @@ import Breadcrumbs from '~/components/Breadcrumbs'
import { Feedback } from '~/components/Feedback'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import { MDXRemoteBase } from './MdxBase'
import { type ITroubleshootingEntry } from './Troubleshooting.utils'
import { getTroubleshootingUpdatedDates, type ITroubleshootingEntry } from './Troubleshooting.utils'
import Link from 'next/link'
import { formatError, serializeTroubleshootingSearchParams } from './Troubleshooting.utils.shared'
export default function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) {
export default async function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) {
const dateUpdated = entry.data.database_id.startsWith('pseudo-')
? new Date()
: (await getTroubleshootingUpdatedDates()).get(entry.data.database_id)
return (
<SidebarSkeleton
hideSideNav
@@ -18,9 +22,9 @@ export default function TroubleshootingPage({ entry }: { entry: ITroubleshooting
<Breadcrumbs minLength={1} forceDisplayOnMobile />
<article className="prose max-w-none mt-4">
<h1>{entry.data.title}</h1>
{entry.data.updated_at && (
{dateUpdated && (
<p className="text-sm text-foreground-lighter">
Last edited: {entry.data.updated_at.toLocaleString()}
Last edited: {dateUpdated.toLocaleDateString()}
</p>
)}
<hr className="my-7" aria-hidden />
@@ -0,0 +1,439 @@
/* eslint-disable turbo/no-undeclared-env-vars */
/**
* Sync new troubleshooting entries from the GitHub repo with GitHub
* Discussions.
*/
import { createAppAuth } from '@octokit/auth-app'
import { Octokit } from '@octokit/core'
import { createClient } from '@supabase/supabase-js'
import { createHash } from 'crypto'
import matter from 'gray-matter'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { gfm } from 'micromark-extension-gfm'
import { mdxjs } from 'micromark-extension-mdxjs'
import { readFile, writeFile } from 'node:fs/promises'
import { stringify } from 'smol-toml'
import toml from 'toml'
import {
getAllTroubleshootingEntriesInternal as getAllTroubleshootingEntries,
getArticleSlug,
} from './Troubleshooting.utils.common.mjs'
import 'dotenv/config'
const REPOSITORY_ID = 'MDEwOlJlcG9zaXRvcnkyMTQ1ODcxOTM='
const TROUBLESHOOTING_CATEGORY_ID = 'DIC_kwDODMpXOc4CUvEr'
const REPOSITORY_OWNER = 'supabase'
const REPOSITORY_NAME = 'supabase'
/**
* @typedef {import('./Troubleshooting.utils.common.mjs').TroubleshootingEntry} TroubleshootingEntry
* @typedef {import('./Troubleshooting.utils.common.mjs').TroubleshootingMetadata} TroubleshootingMetadata
*/
/**
* @type {import('@octokit/core').Octokit}
*/
let octokitInstance
/**
* @type {SupabaseClient<import('../../../../packages/common').Database>}
*/
let supabaseAdminClient
function octokit() {
if (!octokitInstance) {
octokitInstance = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: process.env.SEARCH_GITHUB_APP_ID,
installationId: process.env.SEARCH_GITHUB_APP_INSTALLATION_ID,
privateKey: process.env.SEARCH_GITHUB_APP_PRIVATE_KEY,
},
})
}
return octokitInstance
}
export function supabaseAdmin() {
if (!supabaseAdminClient) {
supabaseAdminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
)
}
return supabaseAdminClient
}
async function syncTroubleshootingEntries() {
console.log('[START] Syncing troubleshooting entries to GitHub')
const [troubleshootingEntries, discussions] = await Promise.all([
getAllTroubleshootingEntries(),
getAllTroubleshootingDiscussions(),
])
console.log(`[INFO] Found ${troubleshootingEntries.length} existing entries`)
const tasks = troubleshootingEntries.map(async (entry) => {
const databaseId = entry.data.database_id
if (databaseId.startsWith('pseudo-')) {
// The database entry is faked, so we may need to create a new one.
// There's also an edge case we need to check for: the entry has already
// been created, but the new database ID hasn't been written to the file
// yet.
if (await entryExists(entry)) return
const discussion = entry.data.github_url
? await getGithubIdForDiscussion(discussions, entry)
: await createGithubDiscussion(entry)
let id
try {
id = await insertNewTroubleshootingEntry(entry, discussion)
} catch (error) {
console.error(`[ERROR] Failed to insert new entry for ${entry.data.title}: %O`, error)
console.log(
`[INFO] Rolling back discussion creation for ${entry.data.title} (GitHub ID ${discussion.id})`
)
await rollbackGithubDiscussion(discussion.id)
throw error
}
await updateFileId(entry, id)
} else {
// The database entry already exists, so check for updates.
const contentHasChanged = await updateChecksumIfNeeded(entry)
if (contentHasChanged) {
await updateGithubDiscussion(entry)
}
}
})
const results = await Promise.allSettled(tasks)
let hasErrors = false
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`[ERROR] Failed to insert and/or update for ${troubleshootingEntries[index].filePath}:\n%O`,
result.reason
)
hasErrors = true
}
})
return hasErrors
}
/**
* @param {TroubleshootingEntry} entry
* @throws If the database check fails for some unknown reason
*/
async function entryExists(entry) {
const checksum = calculateChecksum(entry.content)
const { error } = await supabaseAdmin()
.from('troubleshooting_entries')
.select('id')
.eq('checksum', checksum)
.single()
if (error) {
if (error.code === 'PGRST116') {
// No entry found
return false
}
throw error
}
console.log(
`[INFO] Entry for ${entry.data.title} already exists. Not creating a new one to prevent duplicates.`
)
return true
}
/**
* @param {string} content
*/
function calculateChecksum(content) {
// Normalize to ignore changes that don't affect the final displayed content.
const mdast = fromMarkdown(content, {
extensions: [gfm(), mdxjs()],
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
})
const normalized = toMarkdown(mdast, { extensions: [gfmToMarkdown(), mdxToMarkdown()] })
return createHash('sha256').update(normalized).digest('base64')
}
/**
* @param {TroubleshootingEntry} entry
*
* @param {Object} discussion
* @param {string} discussion.id
* @param {string} discussion.url
*
* @throws If the database insertion fails
*/
async function insertNewTroubleshootingEntry(entry, discussion) {
console.log(`[INFO] Inserting entry for ${entry.data.title} into DB`)
const now = new Date().toISOString()
const checksum = calculateChecksum(entry.content)
const { data, error } = await supabaseAdmin()
.from('troubleshooting_entries')
.insert({
api: entry.data.api,
checksum,
date_created: entry.data.date_created?.toISOString() ?? now,
date_updated: now,
errors: entry.data.errors,
github_id: discussion.id,
github_url: discussion.url,
keywords: entry.data.keywords,
title: entry.data.title,
topics: entry.data.topics,
})
.select('id')
.single()
if (error) {
throw error
}
return data.id
}
/**
* @param {TroubleshootingEntry} entry
*
* @throws If the database update fails
*/
async function updateChecksumIfNeeded(entry) {
const { data, error } = await supabaseAdmin()
.from('troubleshooting_entries')
.select('checksum')
.eq('id', entry.data.database_id)
.single()
if (error) {
throw error
}
const newChecksum = calculateChecksum(entry.content)
if (data.checksum !== newChecksum) {
console.log(`[INFO] Content changed for ${entry.data.title}. Updating checksum.`)
const now = new Date().toISOString()
const { error } = await supabaseAdmin()
.from('troubleshooting_entries')
.update({
checksum: newChecksum,
date_updated: now,
})
.eq('id', entry.data.database_id)
if (error) {
throw error
}
return true
}
return false
}
/**
* @param {TroubleshootingEntry} entry
*/
function addCanonicalUrl(entry) {
const docsUrl = 'https://supabase.com/docs/guides/troubleshooting/' + getArticleSlug(entry.data)
const content =
`_This is a copy of a troubleshooting article on Supabase's docs site. It may be missing some details from the original. View the [original article](${docsUrl})._\n\n` +
entry.contentWithoutJsx
return content
}
/**
* @param {TroubleshootingEntry} entry
*/
async function createGithubDiscussion(entry) {
console.log(`[INFO] Creating GitHub discussion for ${entry.data.title}`)
const content = addCanonicalUrl(entry)
const mutation = `
mutation {
createDiscussion(input: {
repositoryId: "${REPOSITORY_ID}",
categoryId: "${TROUBLESHOOTING_CATEGORY_ID}",
body: "${content}",
title: "${entry.data.title}"
}) {
discussion {
id
url
}
}
}
`
const {
createDiscussion: { discussion },
} = await octokit().graphql(mutation)
console.log(`[INFO] Created GitHub discussion for ${entry.data.title}: %s`, discussion.url)
return discussion
}
/**
* @returns {Promise<{id: string, url: string}[]>}
*/
async function getAllTroubleshootingDiscussions() {
const query = `
query getDiscussions($cursor: String) {
repository(owner: "${REPOSITORY_OWNER}", name: "${REPOSITORY_NAME}") {
discussions(first: 100, after: $cursor, categoryId: "${TROUBLESHOOTING_CATEGORY_ID}") {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
url
}
}
}
}
`
const discussions = []
let hasNextPage = true
let cursor
while (hasNextPage) {
const {
repository: {
discussions: { nodes: moreDiscussions, pageInfo },
},
} = await octokit().graphql(query, { cursor })
discussions.push(...moreDiscussions)
hasNextPage = pageInfo.hasNextPage
cursor = pageInfo.endCursor
}
return discussions
}
/**
* @param {{id: string, url: string}[]} discussions
* @param {TroubleshootingEntry} entry
*
* @throws If matching discussion not found
*/
async function getGithubIdForDiscussion(discussions, entry) {
const matchingDiscussion = discussions.find(
(discussion) => discussion.url === entry.data.github_url
)
if (!matchingDiscussion) {
throw new Error(`No matching discussion found for URL: ${entry.data.github_url}`)
}
return matchingDiscussion
}
/**
* @param {TroubleshootingEntry} entry
*
* @throws If stored discussion ID for entry not found
*/
async function updateGithubDiscussion(entry) {
console.log(`[INFO] Updating discussion content for ${entry.data.title}`)
const { data, error } = await supabaseAdmin()
.from('troubleshooting_entries')
.select('github_id')
.eq('id', entry.data.database_id)
.single()
if (error) {
throw error
}
const content = addCanonicalUrl(entry)
const mutation = `
mutation {
updateDiscussion(input: {
discussionId: "${data.github_id}",
body: "${content}",
}) {
discussion {
id
}
}
}
`
await octokit().graphql(mutation)
console.log(`[INFO] Updated discussion content for ${entry.data.title}`)
}
/** @param {string} id */
async function rollbackGithubDiscussion(id) {
try {
const mutation = `
mutation {
deleteDiscussion(input: {
id: "${id}",
}) {
discussion {
id
}
}
}
`
await octokit().graphql(mutation)
console.log(`[INFO] Rolled back discussion creation for ${id}`)
} catch (error) {
console.error(`[ERROR] Failed to rollback discussion creation for ${id}: %O`, error)
}
}
/**
* @param {TroubleshootingEntry} entry
* @param {string} id
*
* @throws Passes through readFile and writeFile errors without catching
*/
async function updateFileId(entry, id) {
console.log(`[INFO] Writing database ID to file for ${entry.filePath}`)
const fileContents = await readFile(entry.filePath, 'utf-8')
const { data, content } = matter(fileContents, {
language: 'toml',
engines: { toml: toml.parse.bind(toml) },
})
data.database_id = id
const newFrontmatter = stringify(data)
const newContent = `---\n${newFrontmatter}\n---\n${content}`
await writeFile(entry.filePath, newContent)
}
async function main() {
try {
const hasErrors = await syncTroubleshootingEntries()
if (hasErrors) {
process.exit(1)
}
} catch (error) {
console.error(`[ERROR] %O`, error)
process.exit(1)
}
}
main()
@@ -15,6 +15,14 @@ import {
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { MultiSelect } from '~/components/MultiSelect.client'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import { type ITroubleshootingMetadata } from './Troubleshooting.utils'
import {
formatError,
@@ -30,7 +38,7 @@ function useTroubleshootingSearchState() {
const setSelectedProducts = useCallback(
(products: string[]) => {
_setState({
products: products.length === 0 ? null : products,
products: products.length === 0 ? [] : products,
})
},
[_setState]
@@ -38,7 +46,7 @@ function useTroubleshootingSearchState() {
const setSelectedErrorCodes = useCallback(
(errorCodes: string[]) => {
_setState({
errorCodes: errorCodes.length === 0 ? null : errorCodes,
errorCodes: errorCodes.length === 0 ? [] : errorCodes,
})
},
[_setState]
@@ -46,7 +54,7 @@ function useTroubleshootingSearchState() {
const setSelectedTags = useCallback(
(tags: string[]) => {
_setState({
tags: tags.length === 0 ? null : tags,
tags: tags.length === 0 ? [] : tags,
})
},
[_setState]
@@ -177,9 +185,6 @@ function TroubleshootingFilterInternal({
reset,
} = useTroubleshootingSearchState()
const [productsOpen, _setProductsOpen] = useState(false)
const [errorsOpen, _setErrorsOpen] = useState(false)
const [tagsOpen, _setTagsOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const allEntries = useRef<HTMLElement[]>([])
@@ -195,112 +200,46 @@ function TroubleshootingFilterInternal({
allEntries.current = entries
}, [])
const setProductsOpen = useCallback(
(open: boolean) => {
_setProductsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setProductsOpen]
)
const setErrorsOpen = useCallback(
(open: boolean) => {
_setErrorsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setErrorsOpen]
)
const setTagsOpen = useCallback(
(open: boolean) => {
_setTagsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setTagsOpen]
)
const convertToItem = useCallback(
(arr: string[], { capitalize = false }: { capitalize?: boolean } = {}) =>
arr.map((item) => ({
value: item,
label: capitalize ? item[0].toUpperCase() + item.slice(1) : item,
})),
[]
)
return (
<>
<h2 className="sr-only">Search and filter</h2>
<div className={cn('flex flex-wrap gap-2 items-center', className)}>
<MultiSelect
open={productsOpen}
onOpenChange={setProductsOpen}
selected={convertToItem(selectedProducts, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedProducts, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedProducts(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Products" />
<MultiSelect.Content sameWidthAsTrigger>
{products.map((product) => (
<MultiSelect.Item
item={{ value: product, label: product[0].toUpperCase() + product.slice(1) }}
/>
))}
</MultiSelect.Content>
</MultiSelect>
<MultiSelect
open={errorsOpen}
onOpenChange={setErrorsOpen}
selected={convertToItem(selectedErrorCodes, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedErrorCodes, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedErrorCodes(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Error codes" />
<MultiSelect.Content sameWidthAsTrigger>
{errors.map((error) => (
<MultiSelect.Item item={{ value: formatError(error), label: formatError(error) }} />
))}
</MultiSelect.Content>
</MultiSelect>
<MultiSelect
open={tagsOpen}
onOpenChange={setTagsOpen}
selected={convertToItem(selectedTags, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedTags, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedTags(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Tags" />
<MultiSelect.Content sameWidthAsTrigger>
{keywords.map((keyword) => (
<MultiSelect.Item item={{ value: keyword, label: keyword }} />
))}
</MultiSelect.Content>
</MultiSelect>
<MultiSelector values={selectedProducts} onValuesChange={setSelectedProducts}>
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Products" />
<MultiSelector.Content>
<MultiSelector.List>
{products?.map((product) => (
<MultiSelector.Item key={`product-${product}`} value={product}>
{product}
</MultiSelector.Item>
))}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
<MultiSelector values={selectedErrorCodes} onValuesChange={setSelectedErrorCodes}>
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Error codes" />
<MultiSelector.Content>
<MultiSelector.List>
{errors?.map((error) => (
<MultiSelector.Item key={`error-${error.code}`} value={error.code}>
{error.code}
</MultiSelector.Item>
))}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
<MultiSelector values={selectedTags} onValuesChange={setSelectedTags}>
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Tags" />
<MultiSelector.Content>
<MultiSelector.List>
{keywords?.map((keyword) => (
<MultiSelector.Item key={`keyword-${keyword}`} value={keyword}>
{keyword}
</MultiSelector.Item>
))}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
<div className="relative">
<Input_Shadcn_
id="troubleshooting-search"
+13 -6
View File
@@ -2,12 +2,20 @@ import { Wrench } from 'lucide-react'
import Link from 'next/link'
import { type PropsWithChildren, useCallback } from 'react'
import { type ITroubleshootingEntry, getArticleSlug } from './Troubleshooting.utils'
import {
type ITroubleshootingEntry,
getArticleSlug,
getTroubleshootingUpdatedDates,
} from './Troubleshooting.utils'
import { TroubleshootingFilter } from './Troubleshooting.ui.client'
import { formatError, TROUBLESHOOTING_DATA_ATTRIBUTES } from './Troubleshooting.utils.shared'
import { cn } from 'ui'
export function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry }) {
export async function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry }) {
const dateUpdated = entry.data.database_id.startsWith('pseudo-')
? new Date()
: (await getTroubleshootingUpdatedDates()).get(entry.data.database_id)
const keywords = [...entry.data.topics, ...(entry.data.keywords ?? [])]
const attributes = {
[TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE]:
@@ -58,14 +66,13 @@ export function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry
))}
</div>
<div className="basis-l8 flex-shrink-0 flex-grow-0 truncate text-sm text-foreground-lighter">
{entry.data.updated_at &&
{dateUpdated &&
(() => {
const date = new Date(entry.data.updated_at)
const options = { month: 'short', day: 'numeric' } as Intl.DateTimeFormatOptions
if (date.getFullYear() !== new Date().getFullYear()) {
if (dateUpdated.getFullYear() !== new Date().getFullYear()) {
options.year = 'numeric'
}
return date.toLocaleDateString(undefined, options)
return dateUpdated.toLocaleDateString(undefined, options)
})()}
</div>
</div>
@@ -0,0 +1,185 @@
/**
* This file is for utils needed in both the Next.js app build and the
* troubleshooting sync script. Because of unsolved problems with imports, the
* script is a mjs file instead of a ts file. Any dependencies that are needed
* in both places are defined here, and then typed in Troubleshooting.utils.ts
* as required.
*/
import matter from 'gray-matter'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { gfm } from 'micromark-extension-gfm'
import { mdxjs } from 'micromark-extension-mdxjs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import toml from 'toml'
import { visit } from 'unist-util-visit'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
export const TROUBLESHOOTING_DIRECTORY = join(process.cwd(), 'content/troubleshooting')
/**
* @typedef {Object} TroubleshootingEntry
* @property {string} filePath
* @property {string} content
* @property {string} contentWithoutJsx
* @property {TroubleshootingMetadata} data
*/
/**
* @typedef {Object} TroubleshootingMetadata
* @property {string} title
* @property {string[]} topics
* @property {string[]} [keywords]
* @property {Object} [api]
* @property {string[]} [api.sdk]
* @property {string[]} [api.management_api]
* @property {string[]} [api.cli]
* @property {AssociatedError[]} [errors]
* @property {string} database_id
* @property {string} [github_url]
* @property {Date} [date_created]
*/
/**
* @typedef {Object} AssociatedError
* @property {number} [http_status_code]
* @property {string} [code]
* @property {string} [message]
*/
export const TroubleshootingSchema = z
.object({
title: z.string(),
topics: z.array(
z.enum([
'ai',
'auth',
'branching',
'cli',
'database',
'functions',
'platform',
'realtime',
'self-hosting',
'storage',
'studio',
'supavisor',
'terraform',
])
),
keywords: z.array(z.string()).optional(),
api: z
.object({
sdk: z.array(z.string()).optional(),
management_api: z.array(z.string()).optional(),
cli: z.array(z.string()).optional(),
})
.strict()
.optional(),
errors: z
.array(
z
.object({
http_status_code: z.number().optional(),
code: z.string().optional(),
message: z.string().optional(),
})
.strict()
)
.optional(),
database_id: z.string().default(`pseudo-${uuidv4()}`),
github_url: z.string().url().optional(),
date_created: z.date({ coerce: true }).optional(),
})
.strict()
/*
* @param {unknown} troubleshootingMetadata
*/
function validateTroubleshootingMetadata(troubleshootingMetadata) {
return TroubleshootingSchema.safeParse(troubleshootingMetadata)
}
/*
* @returns {Promise<TroubleshootingEntry[]>}
*/
export async function getAllTroubleshootingEntriesInternal() {
const troubleshootingDirectoryContents = await readdir(TROUBLESHOOTING_DIRECTORY, {
recursive: true,
})
const troubleshootingFiles = troubleshootingDirectoryContents.map(async (entry) => {
const isHidden = entry.startsWith('_')
if (isHidden) return null
const filePath = join(TROUBLESHOOTING_DIRECTORY, entry)
const isFile = (await stat(filePath)).isFile()
if (!isFile) return null
const fileContents = await readFile(filePath, 'utf-8')
const { content, data: frontmatter } = matter(fileContents, {
language: 'toml',
engines: { toml: toml.parse.bind(toml) },
})
const parseResult = validateTroubleshootingMetadata(frontmatter)
if ('error' in parseResult) {
console.error(
`Error validating troubleshooting metadata\nEntry:%O\nError:%O`,
frontmatter,
parseResult.error
)
return null
}
const mdxTree = fromMarkdown(content, {
extensions: [gfm(), mdxjs()],
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
})
visit(mdxTree, (node) => {
if ('children' in node) {
node.children = node.children.filter(
(child) =>
![
'mdxJsxFlowExpression',
'mdxJsxTextExpression',
'mdxFlowExpression',
'mdxTextExpression',
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxJsxExpressionAttribute',
'mdxJsxAttribute',
'mdxJsxAttributeValueExpression',
'mdxjsEsm',
].includes(child.type)
)
}
})
const contentWithoutJsx = toMarkdown(mdxTree, {
extensions: [gfmToMarkdown()],
})
return {
filePath,
content,
contentWithoutJsx,
data: parseResult.data,
}
})
return (await Promise.all(troubleshootingFiles)).filter(Boolean)
}
/**
* @param {TroubleshootingMetadata} meta
*/
export function getArticleSlug(meta) {
const slugifiedTitle = meta.title.toLowerCase().replace(/\s+/g, '-')
const escapedTitle = encodeURIComponent(slugifiedTitle)
return escapedTitle
}
+40 -124
View File
@@ -1,137 +1,39 @@
import matter from 'gray-matter'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { mdxjs } from 'micromark-extension-mdxjs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import toml from 'toml'
import { visit } from 'unist-util-visit'
import { v4 as uuidv4 } from 'uuid'
import { cache } from 'react'
import { z } from 'zod'
import { DOCS_DIRECTORY } from 'lib/docs'
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
import { supabaseAdmin } from '~/lib/supabaseAdmin'
import {
getAllTroubleshootingEntriesInternal,
getArticleSlug as getArticleSlugInternal,
TroubleshootingSchema,
TROUBLESHOOTING_DIRECTORY,
} from './Troubleshooting.utils.common.mjs'
import { formatError } from './Troubleshooting.utils.shared'
const TROUBLESHOOTING_DIRECTORY = join(DOCS_DIRECTORY, 'content/troubleshooting')
// We don't have strictNullChecks on because we still have too many violations
// in the code base, so required types aren't typed properly by Zod.
export type ITroubleshootingMetadata = z.infer<typeof TroubleshootingSchema> &
Pick<Required<z.infer<typeof TroubleshootingSchema>>, 'title' | 'topics' | 'database_id'>
const TroubleshootingSchema = z
.object({
title: z.string(),
topics: z.array(
z.enum([
'ai',
'auth',
'branching',
'cli',
'database',
'functions',
'platform',
'realtime',
'self-hosting',
'storage',
'studio',
'supavisor',
'terraform',
])
),
keywords: z.array(z.string()).optional(),
api: z
.object({
sdk: z.array(z.string()).optional(),
management_api: z.array(z.string()).optional(),
cli: z.array(z.string()).optional(),
})
.strict()
.optional(),
errors: z
.array(
z
.object({
http_status_code: z.number().optional(),
code: z.string().optional(),
message: z.string().optional(),
})
.strict()
)
.optional(),
database_id: z.string().uuid().default(uuidv4()),
created_at: z.date({ coerce: true }).optional(),
updated_at: z.date({ coerce: true }).optional(),
})
.strict()
export type ITroubleshootingMetadata = z.infer<typeof TroubleshootingSchema>
const validateTroubleshootingMetadata = (troubleshootingMetadata: unknown) => {
return TroubleshootingSchema.safeParse(troubleshootingMetadata)
export interface ITroubleshootingEntry {
filePath: string
content: string
contentWithoutJsx: string
data: ITroubleshootingMetadata
}
async function getAllTroubleshootingEntriesInternal() {
const troubleshootingDirectoryContents = await readdir(TROUBLESHOOTING_DIRECTORY, {
recursive: true,
})
const troubleshootingFiles = troubleshootingDirectoryContents.map(async (entry) => {
const isHidden = entry.startsWith('_')
if (isHidden) return null
export const getArticleSlug = getArticleSlugInternal
const isFile = (await stat(join(TROUBLESHOOTING_DIRECTORY, entry))).isFile()
if (!isFile) return null
const fileContents = await readFile(join(TROUBLESHOOTING_DIRECTORY, entry), 'utf-8')
const { content, data: frontmatter } = matter(fileContents, {
language: 'toml',
engines: { toml: toml.parse.bind(toml) },
})
const parseResult = validateTroubleshootingMetadata(frontmatter)
if ('error' in parseResult) {
console.error(`Error validating troubleshooting metadata for ${entry}:`, parseResult.error)
return null
}
const mdxTree = fromMarkdown(content, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
visit(mdxTree, (node) => {
if ('children' in node) {
node.children = node.children.filter(
(child) =>
![
'mdxJsxFlowExpression',
'mdxJsxTextExpression',
'mdxFlowExpression',
'mdxTextExpression',
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxJsxExpressionAttribute',
'mdxJsxAttribute',
'mdxJsxAttributeValueExpression',
'mdxjsEsm',
].includes(child.type)
)
}
})
const contentWithoutJsx = toMarkdown(mdxTree)
return {
content,
contentWithoutJsx,
data: parseResult.data,
}
})
return (await Promise.all(troubleshootingFiles)).filter(Boolean)
async function getAllTroubleshootingEntriesTyped() {
const result = (await getAllTroubleshootingEntriesInternal()) as ITroubleshootingEntry[]
return result
}
export const getAllTroubleshootingEntries = cache_fullProcess_withDevCacheBust(
getAllTroubleshootingEntriesInternal,
getAllTroubleshootingEntriesTyped,
TROUBLESHOOTING_DIRECTORY,
() => JSON.stringify([])
)
export type ITroubleshootingEntry = Awaited<ReturnType<typeof getAllTroubleshootingEntries>>[number]
export async function getAllTroubleshootingKeywords() {
const entries = await getAllTroubleshootingEntries()
@@ -185,8 +87,22 @@ export async function getAllTroubleshootingErrors() {
return Array.from(allErrors).sort(sortErrors)
}
export function getArticleSlug(entry: ITroubleshootingMetadata) {
const slugifiedTitle = entry.title.toLowerCase().replace(/\s+/g, '-')
const escapedTitle = encodeURIComponent(slugifiedTitle)
return escapedTitle
async function getTroubleshootingUpdatedDatesInternal() {
const databaseIds = (await getAllTroubleshootingEntries())
.map((entry) => entry.data.database_id)
.filter((id) => !id.startsWith('pseudo-'))
const { data, error } = await supabaseAdmin()
.from('troubleshooting_entries')
.select('id, date_updated')
.in('id', databaseIds)
if (error) {
console.error(error)
}
return (data ?? []).reduce((acc, entry) => {
acc.set(entry.id, new Date(entry.date_updated))
return acc
}, new Map<string, Date>())
}
export const getTroubleshootingUpdatedDates = cache(getTroubleshootingUpdatedDatesInternal)
+18
View File
@@ -0,0 +1,18 @@
import 'server-only'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { type Database } from 'common'
let supabaseAdminClient: SupabaseClient<Database> | null = null
export function supabaseAdmin() {
if (!supabaseAdminClient) {
supabaseAdminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
)
}
return supabaseAdminClient
}
+2
View File
@@ -18,6 +18,7 @@
"test:smoke": "npm run codegen:references && vitest -t \"prod smoke test\"",
"embeddings": "tsx scripts/search/generate-embeddings.ts",
"embeddings:refresh": "npm run embeddings -- --refresh",
"troubleshooting:sync": "node features/docs/Troubleshooting.script.mjs",
"last-changed": "tsx scripts/last-changed.ts",
"last-changed:reset": "npm run last-changed -- --reset",
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
@@ -127,6 +128,7 @@
"openapi-types": "^12.1.3",
"simple-git": "^3.24.0",
"slugify": "^1.6.6",
"smol-toml": "^1.3.0",
"tsconfig": "*",
"tsx": "^4.6.2",
"typescript": "~5.5.0",
+3 -5
View File
@@ -1570,8 +1570,7 @@
},
"500": { "description": "Failed to retrieve project's Postgres config" }
},
"tags": ["Database"],
"security": [{ "bearer": [] }]
"tags": ["Database"]
},
"put": {
"operationId": "v1-update-postgres-config",
@@ -1605,8 +1604,7 @@
"403": { "description": "" },
"500": { "description": "Failed to update project's Postgres config" }
},
"tags": ["Database"],
"security": [{ "bearer": [] }]
"tags": ["Database"]
}
},
"/v1/projects/{ref}/config/database/pgbouncer": {
@@ -1936,7 +1934,7 @@
},
"/v1/projects/{ref}/functions": {
"post": {
"operationId": "createFunction",
"operationId": "v1-create-a-function",
"summary": "Create a function",
"description": "Creates a function and adds it to the specified project.",
"parameters": [
+6
View File
@@ -225,6 +225,12 @@
"type": "category",
"title": "Edge Functions",
"items": [
{
"id": "v1-create-a-function",
"title": "Create a function",
"slug": "v1-create-a-function",
"type": "operation"
},
{
"id": "v1-delete-a-function",
"title": "Delete a function",
+2 -2
View File
@@ -3041,7 +3041,7 @@ functions:
)
"""
)
.order("name", ascending: false, foreignTable: "cities")
.order("name", ascending: false, referencedTable: "cities")
.execute()
```
data:
@@ -3152,7 +3152,7 @@ functions:
)
"""
)
.limit(1, foreignTable: "cities")
.limit(1, referencedTable: "cities")
.execute()
```
data:
@@ -4604,12 +4604,7 @@
"description": "Failed to retrieve project's Postgres config"
}
},
"tags": ["Database"],
"security": [
{
"bearer": []
}
]
"tags": ["Database"]
},
"put": {
"operationId": "v1-update-postgres-config",
@@ -4814,12 +4809,7 @@
"description": "Failed to update project's Postgres config"
}
},
"tags": ["Database"],
"security": [
{
"bearer": []
}
]
"tags": ["Database"]
}
},
"/v1/projects/{ref}/config/database/pgbouncer": {
@@ -7832,7 +7822,7 @@
},
"/v1/projects/{ref}/functions": {
"post": {
"operationId": "createFunction",
"operationId": "v1-create-a-function",
"summary": "Create a function",
"description": "Creates a function and adds it to the specified project.",
"parameters": [
@@ -2,22 +2,15 @@ import { Toggle } from 'ui'
import Panel from 'components/ui/Panel'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { useFlag } from 'hooks/ui/useFlag'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
const AnalyticsSettings = () => {
const snap = useAppStateSnapshot()
const enablePostHogTelemetry = useFlag('enablePosthogChanges')
const { mutate: sendReset } = useSendResetMutation()
const consentFlag = enablePostHogTelemetry
? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH
: LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT
const onToggleOptIn = () => {
const value = !snap.isOptedInTelemetry ? 'true' : 'false'
snap.setIsOptedInTelemetry(value === 'true', consentFlag)
snap.setIsOptedInTelemetry(value === 'true')
if (value === 'false') sendReset()
}
@@ -26,8 +26,18 @@ const customSchemeRegex = /^([a-zA-Z][a-zA-Z0-9+.-]*):(?:\/{1,3})?([a-zA-Z0-9_.-
// Exclude simple domain names without protocol
const excludeSimpleDomainRegex = /^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$/
// combine the above regexes
export const urlRegex = new RegExp(
`(?!${excludeSimpleDomainRegex.source})((${baseUrlRegex.source})|(${localhostRegex.source})|(${appRegex.source})|(${chromeExtensionRegex.source})|(${customSchemeRegex.source}))`,
'i'
)
// combine the above regexes, with optional exclusion of options
// usage: urlRegex() or urlRegex({ excludeSimpleDomains: false })
export function urlRegex(
options: { excludeSimpleDomains?: boolean } = { excludeSimpleDomains: true }
): RegExp {
const { excludeSimpleDomains } = options
const excludeSimpleDomainPart = excludeSimpleDomains
? `(?!${excludeSimpleDomainRegex.source})`
: ''
return new RegExp(
`${excludeSimpleDomainPart}((${baseUrlRegex.source})|(${localhostRegex.source})|(${appRegex.source})|(${chromeExtensionRegex.source})|(${customSchemeRegex.source}))`,
'i'
)
}
@@ -570,7 +570,7 @@ const EXTERNAL_PROVIDER_AZURE = {
then: (schema) => schema.required('Secret Value is required'),
otherwise: (schema) => schema,
}),
EXTERNAL_AZURE_URL: string().matches(urlRegex, 'Must be a valid URL').optional(),
EXTERNAL_AZURE_URL: string().matches(urlRegex(), 'Must be a valid URL').optional(),
}),
misc: {
iconKey: 'microsoft-icon',
@@ -812,7 +812,7 @@ const EXTERNAL_PROVIDER_GITLAB = {
then: (schema) => schema.required('Client Secret is required'),
otherwise: (schema) => schema,
}),
EXTERNAL_GITLAB_URL: string().matches(urlRegex, 'Must be a valid URL').optional(),
EXTERNAL_GITLAB_URL: string().matches(urlRegex(), 'Must be a valid URL').optional(),
}),
misc: {
iconKey: 'gitlab-icon',
@@ -1007,8 +1007,8 @@ const EXTERNAL_PROVIDER_KEYCLOAK = {
EXTERNAL_KEYCLOAK_URL: string().when('EXTERNAL_KEYCLOAK_ENABLED', {
is: true,
then: (schema) =>
schema.matches(urlRegex, 'Must be a valid URL').required('Realm URL is required'),
otherwise: (schema) => schema.matches(urlRegex, 'Must be a valid URL'),
schema.matches(urlRegex(), 'Must be a valid URL').required('Realm URL is required'),
otherwise: (schema) => schema.matches(urlRegex(), 'Must be a valid URL'),
}),
}),
misc: {
@@ -1317,7 +1317,7 @@ const EXTERNAL_PROVIDER_WORKOS = {
validationSchema: object().shape({
EXTERNAL_WORKOS_ENABLED: boolean().required(),
EXTERNAL_WORKOS_URL: string()
.matches(urlRegex, 'Must be a valid URL')
.matches(urlRegex(), 'Must be a valid URL')
.when('EXTERNAL_WORKOS_ENABLED', {
is: true,
then: (schema) => schema.required('WorkOS URL is required'),
@@ -1408,7 +1408,7 @@ const PROVIDER_SAML = {
},
validationSchema: object().shape({
SAML_ENABLED: boolean().required(),
SAML_EXTERNAL_URL: string().matches(urlRegex, 'Must be a valid URL').optional(),
SAML_EXTERNAL_URL: string().matches(urlRegex(), 'Must be a valid URL').optional(),
SAML_ALLOW_ENCRYPTED_ASSERTIONS: boolean().optional(),
}),
misc: {
@@ -40,7 +40,7 @@ export const AddNewURLModal = ({ visible, allowList, onClose }: AddNewURLModalPr
value: z
.string()
.min(1, 'Please provide a value')
.regex(urlRegex, 'Please provide a valid URL')
.regex(urlRegex(), 'Please provide a valid URL')
.refine((value) => !allowList.includes(value), {
message: 'URL already exists in the allow list',
}),
@@ -76,7 +76,7 @@ const SmtpForm = () => {
},
then: (schema) =>
schema
.matches(urlRegex, 'Must be a valid URL or IP address')
.matches(urlRegex({ excludeSimpleDomains: false }), 'Must be a valid URL or IP address')
.required('Host URL is required.'),
otherwise: (schema) => schema,
}),
@@ -115,6 +115,20 @@ const phoneProviders = providers.phone.map((x) => {
return key
})
function toPrettyJsonString(value: unknown): string | undefined {
if (!value) return undefined
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map((item) => toPrettyJsonString(item)).join(' ')
try {
return JSON.stringify(value)
} catch (error) {
// ignore the error
}
return undefined
}
export function getDisplayName(user: User, fallback = '-'): string {
const {
custom_claims,
@@ -149,39 +163,44 @@ export function getDisplayName(user: User, fallback = '-'): string {
first_name: cc_first_name,
} = custom_claims ?? {}
const last =
const last = toPrettyJsonString(
familyName ||
family_name ||
surname ||
lastName ||
last_name ||
ccFamilyName ||
cc_family_name ||
ccSurname ||
ccLastName ||
cc_last_name
family_name ||
surname ||
lastName ||
last_name ||
ccFamilyName ||
cc_family_name ||
ccSurname ||
ccLastName ||
cc_last_name
)
const first =
const first = toPrettyJsonString(
givenName ||
given_name ||
firstName ||
first_name ||
ccGivenName ||
cc_given_name ||
ccFirstName ||
cc_first_name
given_name ||
firstName ||
first_name ||
ccGivenName ||
cc_given_name ||
ccFirstName ||
cc_first_name
)
return (
displayName ||
display_name ||
ccDisplayName ||
cc_display_name ||
fullName ||
full_name ||
ccFullName ||
cc_full_name ||
(first && last && `${first} ${last}`) ||
fallback
toPrettyJsonString(
displayName ||
display_name ||
ccDisplayName ||
cc_display_name ||
fullName ||
full_name ||
ccFullName ||
cc_full_name ||
(first && last && `${first} ${last}`) ||
last ||
first
) || fallback
)
}
@@ -19,6 +19,8 @@ import DatePickers from '../Settings/Logs/Logs.DatePickers'
import { DatetimeHelper } from '../Settings/Logs/Logs.types'
import dayjs from 'dayjs'
import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks'
import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
const INTERVALS: DatetimeHelper[] = [
{
@@ -154,6 +156,19 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset}
</span>
)
}
// Selected Log Detail
const [selectedLogId, setSelectedLogId] = useSelectedLog()
const {
data: selectedLogDetail,
isLoading: selectedLogDetailLoading,
error: selectedLogDetailError,
} = useWarehouseLogDetailQuery({
ref: projectRef,
collectionName: collection?.name,
logId: selectedLogId ?? undefined,
})
return (
<div className="relative flex flex-col flex-grow h-full">
<ShimmerLine active={isLoading} />
@@ -248,9 +263,11 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset}
hasEditorValue={false}
projectRef={projectRef}
isLoading={isLoading}
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
selectedLog={selectedLogDetail?.result[0] ?? undefined}
selectedLogError={selectedLogDetailError ?? undefined}
isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading}
data={results}
params={{ sql }}
maxHeight="calc(100vh - 139px)"
showHeader={false}
EmptyState={
<ProductEmptyState title="No events found" size="large">
@@ -277,20 +294,18 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset}
</LoadingOpacity>
{!isError && (
<div className="border-t flex flex-row justify-between p-2">
<div className="flex items-center gap-2">
<div className="border-t flex flex-row justify-between">
<div className="flex items-center gap-2 p-2">
{results.length > 0 && (
<>
<Button
onClick={loadMore}
icon={<Rewind />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load more
</Button>
</>
<Button
onClick={loadMore}
icon={<Rewind />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load more
</Button>
)}
{filters.offset !== 0 && (
<>
@@ -1,5 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { includes, noop, sortBy } from 'lodash'
import { Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react'
import { useRouter } from 'next/router'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -7,12 +8,13 @@ import Table from 'components/to-be-cleaned/Table'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Edit3, FileText, MoreVertical, Trash } from 'lucide-react'
import { useAppStateSnapshot } from 'state/app-state'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
@@ -33,6 +35,7 @@ const FunctionList = ({
}: FunctionListProps) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const { setAiAssistantPanel } = useAppStateSnapshot()
const { data: functions } = useDatabaseFunctionsQuery({
projectRef: selectedProject?.ref,
@@ -116,11 +119,21 @@ const FunctionList = ({
</DropdownMenuItem>
)}
<DropdownMenuItem className="space-x-2" onClick={() => editFunction(x)}>
<Edit3 size={14} />
<Edit2 size={14} />
<p>Edit function</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
}}
>
<Edit size={14} />
<p>Edit function with Assistant</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={() => deleteFunction(x)}>
<Trash stroke="red" size={14} />
<Trash size={14} className="text-destructive" />
<p>Delete function</p>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -5,6 +5,7 @@ import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import Table from 'components/to-be-cleaned/Table'
@@ -17,18 +18,10 @@ import { useSchemasQuery } from 'data/database/schemas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import {
AiIconAnimation,
Button,
Input,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useAppStateSnapshot } from 'state/app-state'
import { AiIconAnimation, Input } from 'ui'
import ProtectedSchemaWarning from '../../ProtectedSchemaWarning'
import FunctionList from './FunctionList'
import { useAppStateSnapshot } from 'state/app-state'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface FunctionsListProps {
createFunction: () => void
@@ -136,39 +129,47 @@ const FunctionsList = ({
<div className="flex items-center gap-x-2">
{!isLocked && (
<ButtonTooltip
disabled={!canCreateFunctions}
onClick={() => createFunction()}
tooltip={{
content: {
side: 'bottom',
text: !canCreateFunctions
? 'You need additional permissions to create functions'
: undefined,
},
}}
>
Create a new function
</ButtonTooltip>
)}
{enableFunctionsAssistant && (
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
<>
<ButtonTooltip
disabled={!canCreateFunctions}
onClick={() => createFunction()}
tooltip={{
content: {
side: 'bottom',
text: !canCreateFunctions
? 'You need additional permissions to create functions'
: undefined,
},
}}
>
Create a new function
</ButtonTooltip>
{enableFunctionsAssistant && (
<ButtonTooltip
type="default"
disabled={!canCreateFunctions}
className="px-1 pointer-events-auto"
icon={
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
}
onClick={() => setAiAssistantPanel({ open: true, editor: 'functions' })}
onClick={() =>
setAiAssistantPanel({
open: true,
editor: 'functions',
entity: undefined,
})
}
tooltip={{
content: {
side: 'bottom',
text: !canCreateFunctions
? 'You need additional permissions to create functions'
: 'Create with Supabase Assistant',
},
}}
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
{!canCreateFunctions
? 'You need additional permissions to create functions'
: 'Create with Supabase Assistant'}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
</>
)}
</div>
</div>
@@ -125,9 +125,7 @@ const TriggerList = ({
{canUpdateTriggers ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1">
<MoreVertical />
</Button>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-36">
<DropdownMenuItem className="space-x-2" onClick={() => editTrigger(x)}>
@@ -1,11 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AnimatePresence, motion } from 'framer-motion'
import { HelpCircle, InfoIcon, RotateCcw } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { useParams } from 'common'
import DiskSpaceBar from 'components/interfaces/DiskManagement/ui/DiskSpaceBar'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -19,9 +13,15 @@ import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { AnimatePresence, motion } from 'framer-motion'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { GB } from 'lib/constants'
import { ExternalLink, HelpCircle, InfoIcon, RotateCcw } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -42,8 +42,18 @@ import {
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
import { Markdown } from '../Markdown'
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
import {
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
} from './DiskManagement.utils'
import { getDiskStorageSchema } from './DiskManagementPanelSchema'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
import { BillingChangeBadge } from './ui/BillingChangeBadge'
import { DiskCountdownRadial } from './ui/DiskCountdownRadial'
import {
@@ -54,19 +64,12 @@ import {
PLAN_DETAILS,
THROUGHPUT_RANGE,
} from './ui/DiskManagement.constants'
import {
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
} from './DiskManagement.utils'
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
import { DiskManagementPlanUpgradeRequired } from './ui/SpendCapDisabledSection'
import {
DiskManagementDiskSizeReadReplicas,
DiskManagementIOPSReadReplicas,
DiskManagementThroughputReadReplicas,
} from './ui/DiskManagementReadReplicas'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
export function DiskManagementPanelForm() {
const { project } = useProjectContext()
@@ -161,6 +164,7 @@ export function DiskManagementPanelForm() {
totalSize: size_gb,
computeSize: undefined,
}
const DiskStorageSchema = getDiskStorageSchema(size_gb)
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(CreateDiskStorageSchema(defaultValues.totalSize)),
defaultValues,
@@ -617,8 +621,31 @@ export function DiskManagementPanelForm() {
label="Disk Size"
layout="horizontal"
description={
includedDiskGB > 0 &&
`Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`
<>
{includedDiskGB > 0 &&
`Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`}
{field.value < size_gb && (
<Admonition
className="mt-2"
type="default"
title="Reducing your project's disk size?"
description={
<Markdown
className="[&>p]:!leading-normal"
content={`Your disk size will automatically "right-size" when you [upgrade your project](/project/${projectRef}/settings/infrastructure).`}
/>
}
>
<div className="flex items-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<Link href="https://supabase.com/docs/guides/platform/database-size#reducing-disk-size">
Documentation
</Link>
</Button>
</div>
</Admonition>
)}
</>
}
>
<div className="mt-1 relative flex gap-2 items-center">
@@ -717,7 +744,7 @@ export function DiskManagementPanelForm() {
<DiskCountdownRadial />
)}
{isPlanUpgradeRequired && <DiskManagementPlanUpgradeRequired />}
<SpendCapDisabledSection />
<Card className="bg-surface-100 rounded-t-none">
<CardContent className="flex items-center pb-0 py-3 px-8 gap-3 justify-end">
@@ -0,0 +1,108 @@
import { z } from 'zod'
import { DISK_LIMITS, DiskType } from './ui/DiskManagement.constants'
const baseSchema = z.object({
storageType: z.enum(['io2', 'gp3']).describe('Type of storage: io2 or gp3'),
totalSize: z
.number()
.min(8, { message: 'Allocated disk size must be at least 8 GB.' })
.describe('Allocated disk size in GB'),
provisionedIOPS: z.number().describe('Provisioned IOPS for storage type'),
throughput: z.number().optional().describe('Throughput in MB/s for gp3'),
})
export const getDiskStorageSchema = (currentDiskSize: number) =>
baseSchema.superRefine((data, ctx) => {
const { storageType, totalSize, provisionedIOPS, throughput } = data
if (totalSize < currentDiskSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Disk size cannot be reduced from current configuration',
path: ['totalSize'],
})
}
if (storageType === 'io2') {
// Validation rules for io2
const maxIOPS = Math.min(1000 * totalSize, 256000)
if (provisionedIOPS < 100) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provisioned IOPS must be at least 100',
path: ['provisionedIOPS'],
})
} else if (provisionedIOPS > maxIOPS) {
if (totalSize >= 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Provisioned IOPS must be at most ${maxIOPS}.`,
path: ['provisionedIOPS'],
})
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid IOPS value due to invalid disk size`,
path: ['provisionedIOPS'],
})
}
}
if (throughput !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Throughput is not configurable for io2.',
path: ['throughput'],
})
}
if (totalSize > DISK_LIMITS[DiskType.IO2].maxStorage) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Allocated disk size must not exceed 61,440 GB',
path: ['totalSize'],
})
}
} else if (storageType === 'gp3') {
// Validation rules for gp3
const maxIOPS = Math.min(500 * totalSize, 16000)
const maxThroughput = Math.min(0.25 * provisionedIOPS, 1000)
if (provisionedIOPS < 3000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Provisioned IOPS must be at least 3000`,
path: ['provisionedIOPS'],
})
} else if (provisionedIOPS > maxIOPS) {
if (totalSize >= 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Provisioned IOPS must be at most ${maxIOPS}`,
path: ['provisionedIOPS'],
})
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid IOPS value due to invalid disk size`,
path: ['provisionedIOPS'],
})
}
}
if (throughput !== undefined && (throughput < 125 || throughput > maxThroughput)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Throughput must be set between 125 and ${maxThroughput?.toLocaleString()} MB/s.`,
path: ['throughput'],
})
}
if (totalSize > DISK_LIMITS[DiskType.GP3].maxStorage) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Allocated disk size must not exceed 16,384 GB',
path: ['totalSize'],
})
}
}
})
export type DiskStorageSchemaType = z.infer<ReturnType<typeof getDiskStorageSchema>>
@@ -15,7 +15,7 @@ const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { anonKey, serviceKey } = getAPIKeys(settings)
const endpoint = settings?.app_config?.endpoint ?? ''
const endpoint = `https://${settings?.app_config?.endpoint ?? ''}`
// [Joshen] ShowApiKey should really be a boolean, its confusing
const defaultApiKey =
@@ -16,7 +16,7 @@ export default function Introduction({ selectedLang }: Props) {
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const endpoint = settings?.app_config?.endpoint ?? ''
const endpoint = `https://${settings?.app_config?.endpoint ?? ''}`
const isPublicSchemaEnabled = config?.db_schema
.split(',')
@@ -20,7 +20,7 @@ export default function UserManagement({ selectedLang, showApiKey }: UserManagem
const keyToShow = showApiKey ? showApiKey : 'SUPABASE_KEY'
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const endpoint = settings?.app_config?.endpoint ?? ''
const endpoint = `https://${settings?.app_config?.endpoint ?? ''}`
return (
<>
@@ -29,7 +29,7 @@ const RpcContent = ({
}: RpcContentProps) => {
const { ref: projectRef } = useParams()
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const endpoint = settings?.app_config?.endpoint ?? ''
const endpoint = `https://${settings?.app_config?.endpoint ?? ''}`
const meta = rpcs[rpcId]
const pathKey = `/rpc/${rpcId}`
@@ -12,7 +12,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useSecretsDeleteMutation } from 'data/secrets/secrets-delete-mutation'
import { ProjectSecret, useSecretsQuery } from 'data/secrets/secrets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Button } from 'ui'
import { Badge, Button } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import AddNewSecretModal from './AddNewSecretModal'
import EdgeFunctionSecret from './EdgeFunctionSecret'
@@ -94,7 +94,12 @@ const EdgeFunctionSecrets = () => {
<Table
head={[
<Table.th key="secret-name">Name</Table.th>,
<Table.th key="secret-value">Digest</Table.th>,
<Table.th key="secret-value" className="flex items-center gap-x-2">
Digest{' '}
<Badge color="scale" className="font-mono">
SHA256
</Badge>
</Table.th>,
<Table.th key="actions" />,
]}
body={
@@ -66,7 +66,7 @@ const httpRequestSchema = z.object({
.string()
.trim()
.min(1, 'Please provide a URL')
.regex(urlRegex, 'Please provide a valid URL')
.regex(urlRegex(), 'Please provide a valid URL')
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
@@ -49,7 +49,7 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
<>
<div className="bg-surface-100 border-default overflow-hidden border shadow px-5 py-4 flex flex-row rounded-md space-x-4">
<div>
<Clock size={24} className="text-foreground-muted mt-0.5" />
<Clock size={20} className="text-foreground-muted mt-0.5 translate-y-0.5" />
</div>
<div className="flex flex-col flex-0 overflow-y-auto w-full">
<div className="flex flex-row justify-between items-center">
@@ -66,7 +66,7 @@ export const CronJobsListing = () => {
<div className="w-full space-y-4">
<FormHeader
title="Cron jobs"
description="Use cron jobs to schedule and automate tasks such as running SQL queries or maintenance routines at specified intervals."
description="Schedule and automate tasks like running queries or maintenance routines at specified intervals"
actions={
<>
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
@@ -45,7 +45,7 @@ const FORM_ID = 'log-drain-destination-form'
const formUnion = z.discriminatedUnion('type', [
z.object({
type: z.literal('webhook'),
url: z.string().regex(urlRegex, 'Endpoint URL is required and must be a valid URL'),
url: z.string().regex(urlRegex(), 'Endpoint URL is required and must be a valid URL'),
http: z.enum(['http1', 'http2']),
gzip: z.boolean(),
headers: z.record(z.string(), z.string()).optional(),
@@ -78,20 +78,20 @@ const BillingMetric = ({
return (
<HoverCard openDelay={50} closeDelay={200}>
<HoverCardTrigger>
<div className="flex items-center justify-between cursor-pointer">
<div>
<HoverCardTrigger asChild>
<div className="flex items-center justify-between">
<Link href={`/org/${slug}/usage#${metric.anchor}`} className="block w-full group">
{metric.anchor ? (
<Link href={`/org/${slug}/usage#${metric.anchor}`}>
<div className="group flex items-center space-x-1">
<p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer">
{metric.name}
</p>
{usageMeta.available_in_plan && (
<div className="group flex items-center gap-1">
<p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer">
{metric.name}
</p>
{usageMeta.available_in_plan && (
<span className="transition inline-block group-hover:transform group-hover:translate-x-0.5">
<ChevronRight strokeWidth={1.5} size={16} className="transition" />
)}
</div>
</Link>
</span>
)}
</div>
) : (
<p className="text-xs text-foreground-light flex space-x-1">{metric.name}</p>
)}
@@ -101,7 +101,7 @@ const BillingMetric = ({
) : usageMeta.available_in_plan && !usageMeta.unlimited && relativeToSubscription ? (
<span className="text-sm">{percentageLabel}</span>
) : null}
</div>
</Link>
{usageMeta.available_in_plan ? (
<div>
@@ -365,7 +365,7 @@ export const TAX_IDS: TaxId[] = [
type: 'kr_brn',
country: 'Korea',
placeholder: '123-45-67890',
countryIso2: 'Kr',
countryIso2: 'KR',
},
{
name: 'LI UID',
@@ -6,21 +6,21 @@ const pricingMetricBytes = [
PricingMetric.STORAGE_SIZE,
]
const pricingMetricNotHrs = [
PricingMetric.FUNCTION_INVOCATIONS,
PricingMetric.LOG_DRAIN_EVENTS,
PricingMetric.MONTHLY_ACTIVE_USERS,
PricingMetric.MONTHLY_ACTIVE_SSO_USERS,
PricingMetric.MONTHLY_ACTIVE_THIRD_PARTY_USERS,
PricingMetric.REALTIME_MESSAGE_COUNT,
PricingMetric.REALTIME_PEAK_CONNECTIONS,
PricingMetric.STORAGE_IMAGES_TRANSFORMED,
]
export const formatUsage = (
pricingMetric: PricingMetric,
allocation: { usage: number; hours?: number }
) => {
if (allocation.hours) {
return (
allocation.usage.toLocaleString() +
' (' +
Math.round(allocation.usage / allocation.hours).toLocaleString() +
'x' +
allocation.hours.toLocaleString() +
' hours)'
)
}
if (pricingMetricBytes.includes(pricingMetric)) {
const formattedUsage = +(allocation.usage / 1e9).toFixed(2).toLocaleString()
@@ -30,6 +30,17 @@ export const formatUsage = (
} else {
return formattedUsage
}
}
if (allocation.hours && !pricingMetricNotHrs.includes(pricingMetric)) {
return (
allocation.usage.toLocaleString() +
' (' +
Math.round(allocation.usage / allocation.hours).toLocaleString() +
'x' +
allocation.hours.toLocaleString() +
' hours)'
)
} else {
return allocation.usage.toLocaleString()
}
@@ -2,6 +2,7 @@ import type { OAuthScope } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Edit, ExternalLink, Upload } from 'lucide-react'
import { useParams } from 'common'
import {
@@ -23,12 +24,10 @@ import {
Input,
Modal,
SidePanel,
Upload,
cn,
} from 'ui'
import AuthorizeRequesterDetails from '../AuthorizeRequesterDetails'
import { ScopesPanel } from './Scopes'
import { Edit, ExternalLink } from 'lucide-react'
export interface PublishAppSidePanelProps {
visible: boolean
@@ -3,8 +3,8 @@ import RGL, { WidthProvider } from 'react-grid-layout'
import { useParams } from 'common'
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
const ReactGridLayout = WidthProvider(RGL)
@@ -60,7 +60,7 @@ const GridResize = ({
containerPadding={[0, 0]}
compactType="horizontal"
>
{editableReport.layout.map((x: any, i: number) => {
{editableReport.layout.map((x: any) => {
return (
<div
key={x.id}
@@ -77,17 +77,13 @@ const GridResize = ({
customDateFormat={'MMM D, YYYY'}
>
{!disableUpdate && (
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
icon={<X />}
className="ml-2 px-1"
onClick={() => onRemoveChart({ metric: { key: x.attribute } })}
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">Remove chart</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<ButtonTooltip
type="text"
icon={<X />}
className="ml-2 px-1"
onClick={() => onRemoveChart({ metric: { key: x.attribute } })}
tooltip={{ content: { side: 'bottom', text: 'Remove chart' } }}
/>
)}
</ChartHandler>
@@ -304,28 +304,35 @@ export const PostgrestConfig = () => {
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size={'small'}
size="small"
disabled={!canUpdatePostgrestConfig || !isDataApiEnabledInForm}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select schemas for Data API..." />
</MultiSelectorTrigger>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select schemas for Data API..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
{schema.length <= 0 ? (
<MultiSelectorList>
<MultiSelectorItem key={'empty'} value={'no'}>
<MultiSelectorList>
{schema.length <= 0 ? (
<MultiSelectorItem key="empty" value="no">
no
</MultiSelectorItem>
</MultiSelectorList>
) : (
<MultiSelectorList>
{schema.map((x, i) => (
<MultiSelectorItem key={x.id + '-' + i} value={x.name}>
{x.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
)}
) : (
<>
{schema.map((x) => (
<MultiSelectorItem
key={x.id + '-' + x.name}
value={x.name}
>
{x.name}
</MultiSelectorItem>
))}
</>
)}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)}
@@ -355,7 +355,7 @@ export const ConnectionPooling = () => {
<FormDescription_Shadcn_ className="col-start-5 col-span-8">
Please refer to our{' '}
<a
href="https://supabase.com/docs/guides/platform/custom-postgres-config#pooler-config"
href="https://supabase.com/docs/guides/database/connection-management#configuring-supavisors-pool-size"
target="_blank"
rel="noreferrer"
className="underline"
@@ -393,7 +393,7 @@ export const ConnectionPooling = () => {
<FormDescription_Shadcn_ className="col-start-5 col-span-8">
Please refer to our{' '}
<a
href="https://supabase.com/docs/guides/platform/custom-postgres-config#pooler-config"
href="https://supabase.com/docs/guides/database/connection-management#configuring-supavisors-pool-size"
target="_blank"
rel="noreferrer"
className="underline"
@@ -101,10 +101,9 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate
<div className="flex items-center space-x-2">
<Button
type="default"
className="self-end"
onClick={onCancelCustomDomain}
loading={isDeleting}
disabled={isDeleting}
className="self-end"
>
Cancel
</Button>
@@ -195,7 +195,8 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {
<Button
type="default"
onClick={onCancelCustomDomain}
disabled={isDeleting || isReverifyLoading || isValidating}
loading={isDeleting}
disabled={isReverifyLoading || isValidating}
className="self-end"
>
Cancel
@@ -207,7 +207,7 @@ const InstanceConfigurationUI = () => {
}, [isSuccessReplicas, isSuccessLoadBalancers, nodes, edges, view])
return (
<div className="nowheel">
<div className="nowheel border-y">
<div
className={`h-[500px] w-full relative ${
isSuccessReplicas && !isLoadingProject ? '' : 'flex items-center justify-center px-28'
@@ -1,10 +1,4 @@
import { MousePointerClick, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Loading } from 'components/ui/Loading'
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
import useSingleLog from 'hooks/analytics/useSingleLog'
import {
Button,
CodeBlock,
@@ -17,197 +11,60 @@ import {
import AuthSelectionRenderer from './LogSelectionRenderers/AuthSelectionRenderer'
import DatabaseApiSelectionRender from './LogSelectionRenderers/DatabaseApiSelectionRender'
import DatabasePostgresSelectionRender from './LogSelectionRenderers/DatabasePostgresSelectionRender'
import DefaultExplorerSelectionRenderer from './LogSelectionRenderers/DefaultExplorerSelectionRenderer'
import DefaultPreviewSelectionRenderer from './LogSelectionRenderers/DefaultPreviewSelectionRenderer'
import FunctionInvocationSelectionRender from './LogSelectionRenderers/FunctionInvocationSelectionRender'
import FunctionLogsSelectionRender from './LogSelectionRenderers/FunctionLogsSelectionRender'
import type { LogData, LogsEndpointParams, QueryType } from './Logs.types'
import { isDefaultLogPreviewFormat, isUnixMicro, unixMicroToIsoTimestamp } from './Logs.utils'
import type { LogData, QueryType } from './Logs.types'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { WarehouseSelectionRenderer } from './LogSelectionRenderers/WarehouseSelectionRenderer'
export interface LogSelectionProps {
log: LogData | null
log?: LogData
onClose: () => void
queryType?: QueryType
projectRef: string
params: Partial<LogsEndpointParams>
collectionName?: string
isLoading: boolean
error?: string | object
}
const LogSelection = ({
projectRef,
log: partialLog,
onClose,
queryType,
params = {},
collectionName,
}: LogSelectionProps) => {
const { logData: fullLog, isLoading } = useSingleLog(
projectRef,
queryType,
params,
partialLog?.id
)
const [sql, setSql] = useState('')
const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectionProps) => {
const LogDetails = () => {
if (error) return <LogErrorState error={error} />
if (!log) return <LogDetailEmptyState />
// if (!log.metadata) return <DefaultPreviewSelectionRenderer log={log} />
const warehouseQueryEnabled = queryType === 'warehouse'
const {
refetch: refetchWarehouseData,
data: warehouseQueryData,
isLoading: warehouseQueryLoading,
} = useWarehouseQueryQuery(
{
ref: projectRef,
sql,
},
{
enabled: queryType === 'warehouse',
onError: (error) => {
toast.error(error.message)
},
}
)
useEffect(() => {
const newSql = `select id, timestamp, event_message, metadata from \`${collectionName}\`
where id = '${partialLog?.id}' and timestamp > '2024-01-01' limit 1`
setSql(newSql)
}, [collectionName, partialLog?.id])
useEffect(() => {
if (queryType === 'warehouse') {
refetchWarehouseData()
}
}, [
warehouseQueryData,
collectionName,
projectRef,
partialLog?.id,
refetchWarehouseData,
queryType,
])
const Formatter = () => {
switch (queryType) {
case 'warehouse':
if (!warehouseQueryData) return null
return <DefaultPreviewSelectionRenderer log={warehouseQueryData.result?.[0] || {}} />
return <WarehouseSelectionRenderer log={log} />
case 'api':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
return <DatabaseApiSelectionRender log={fullLog} />
return <DatabaseApiSelectionRender log={log} />
case 'database':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
return <DatabasePostgresSelectionRender log={fullLog} />
return <DatabasePostgresSelectionRender log={log} />
case 'fn_edge':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
return <FunctionInvocationSelectionRender log={fullLog} />
return <FunctionInvocationSelectionRender log={log} />
case 'functions':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
return <FunctionLogsSelectionRender log={fullLog} />
return <FunctionLogsSelectionRender log={log} />
case 'auth':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
return <AuthSelectionRenderer log={fullLog} />
return <AuthSelectionRenderer log={log} />
default:
if (queryType && fullLog && isDefaultLogPreviewFormat(fullLog)) {
return <DefaultPreviewSelectionRenderer log={fullLog} />
}
if (queryType && !fullLog) {
return null
}
if (!partialLog) return null
return <DefaultExplorerSelectionRenderer log={partialLog} />
return <DefaultPreviewSelectionRenderer log={log} />
}
}
const selectionText = useMemo(() => {
if (fullLog && queryType) {
return `Log ID
${fullLog.id}\n
Log Timestamp (UTC)
${isUnixMicro(fullLog.timestamp) ? unixMicroToIsoTimestamp(fullLog.timestamp) : fullLog.timestamp}\n
Log Event Message
${fullLog.event_message}\n
Log Metadata
${JSON.stringify(fullLog.metadata, null, 2)}
`
}
return JSON.stringify(fullLog || partialLog, null, 2)
}, [fullLog, partialLog, queryType])
const rawLog = useMemo(() => {
if (queryType === 'warehouse') {
return warehouseQueryData?.result?.[0] || {}
}
return fullLog || partialLog
}, [queryType, warehouseQueryData, fullLog, partialLog])
return (
<div
className={cn(
'relative flex h-full flex-grow flex-col border border-l border-overlay',
'overflow-y-scroll bg-studio'
)}
>
<div
className={cn(
'absolute flex h-full w-full flex-col items-center justify-center gap-2 overflow-y-scroll bg-studio text-center opacity-0 transition-all',
{
'z-0 opacity-0': partialLog,
'z-10 opacity-100': !partialLog,
}
)}
>
<div
className={
`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 ` +
(partialLog || isLoading ? '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-control px-2">
<div className="h-0.5 w-2/3 rounded-full bg-surface-300"></div>
<div className="absolute right-1 -bottom-4">
<MousePointerClick size="24" strokeWidth={1} />
</div>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-foreground">Select an Event</h3>
<p className="text-xs text-foreground-lighter">
{queryType === 'warehouse'
? 'Select an Event to view the complete JSON payload'
: 'Select an Event to view the code snippet (pretty view) or complete JSON payload (raw view).'}
</p>
</div>
</div>
</div>
<div className="relative flex-grow flex flex-col bg-surface-100">
<Tabs_Shadcn_ defaultValue="details" className="flex flex-col">
<div className="relative flex h-full flex-grow flex-col overflow-y-scroll bg-surface-100 border-t">
<div className="relative flex-grow flex flex-col h-full">
<Tabs_Shadcn_ defaultValue="details" className="flex flex-col h-full">
<TabsList_Shadcn_ className="px-2 pt-2">
<TabsTrigger_Shadcn_ className="px-3" value="details">
Details
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ className="px-3" value="raw">
<TabsTrigger_Shadcn_ disabled={!log} className="px-3" value="raw">
Raw
</TabsTrigger_Shadcn_>
<Button
@@ -218,24 +75,23 @@ const LogSelection = ({
<X size={14} strokeWidth={2} className="text-foreground-lighter" />
</Button>
</TabsList_Shadcn_>
<div className="flex-grow">
{isLoading || (warehouseQueryEnabled && warehouseQueryLoading) ? (
<div className="flex-1 h-full">
{isLoading ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : (
<>
<TabsContent_Shadcn_ className="bg-surface-100 space-y-6" value="details">
<Formatter />
<TabsContent_Shadcn_ className="space-y-6 h-full" value="details">
<LogDetails />
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="raw">
{isLoading && 'Loading...'}
<CodeBlock
hideLineNumbers
language="json"
className="prose w-full pt-0 max-w-full border-none"
>
{JSON.stringify(rawLog, null, 2)}
{JSON.stringify(log, null, 2)}
</CodeBlock>
</TabsContent_Shadcn_>
</>
@@ -248,3 +104,40 @@ const LogSelection = ({
}
export default LogSelection
function LogDetailEmptyState({
title = 'Select an Event',
message = 'Select an Event to view the complete JSON payload',
}: {
title?: string
message?: string
}) {
return (
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center gap-2 overflow-y-scroll text-center transition-all px-4'
)}
>
<div
className={cn(
'flex w-full max-w-sm flex-col items-center justify-center gap-6 text-center transition-all delay-300 duration-500'
)}
>
<div className="relative flex h-4 w-32 items-center rounded border border-control px-2">
<div className="h-0.5 w-2/3 rounded-full bg-surface-300"></div>
<div className="absolute right-1 -bottom-4">
<MousePointerClick size="24" strokeWidth={1} />
</div>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-foreground">{title}</h3>
<p className="text-xs text-foreground-lighter">{message}</p>
</div>
</div>
</div>
)
}
function LogErrorState({ error }: { error?: string | object }) {
return <pre>{JSON.stringify(error, null, 2)}</pre>
}
@@ -33,8 +33,6 @@ const DetailedJsonRow = ({
export const WarehouseSelectionRenderer = ({ log }: any) => {
return (
<div className="overflow-hidden overflow-x-auto space-y-6">
<pre>{JSON.stringify(log, null, 2)}</pre>
<hr />
{Object.entries(log).map(([key, value], index) => {
return (
<div
@@ -9,7 +9,6 @@ export default {
export const Functions = (args: any) => (
<div>
<LogTable
params={{}}
queryType="functions"
projectRef="123"
data={[
@@ -30,15 +30,16 @@ import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresC
import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer'
import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
import LogSelection, { LogSelectionProps } from './LogSelection'
import LogSelection from './LogSelection'
import type { LogData, LogQueryError, QueryType } from './Logs.types'
import { isDefaultLogPreviewFormat } from './Logs.utils'
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
import { LogsTableEmptyState } from './LogsTableEmptyState'
import { ResponseError } from 'types'
interface Props {
data?: Array<LogData | Object>
data?: LogData[]
onHistogramToggle?: () => void
isHistogramShowing?: boolean
isLoading?: boolean
@@ -46,17 +47,19 @@ interface Props {
showDownload?: boolean
queryType?: QueryType
projectRef: string
params: LogSelectionProps['params']
onRun?: () => void
onSave?: () => void
hasEditorValue?: boolean
maxHeight?: string
className?: string
collectionName?: string // Used for warehouse queries
warehouseError?: string
EmptyState?: ReactNode
showHeader?: boolean
showHistogramToggle?: boolean
selectedLog?: LogData
isSelectedLogLoading?: boolean
selectedLogError?: LogQueryError | ResponseError
onSelectedLogChange?: (log: LogData | null) => void
}
type LogMap = { [id: string]: LogData }
@@ -73,35 +76,44 @@ const LogTable = ({
isLoading,
error,
projectRef,
params,
onRun,
onSave,
hasEditorValue,
maxHeight,
className,
collectionName,
EmptyState,
showHeader = true,
showHistogramToggle = true,
selectedLog,
isSelectedLogLoading,
selectedLogError,
onSelectedLogChange,
}: Props) => {
const { profile } = useProfile()
const { show: showContextMenu } = useContextMenu()
const [cellPosition, setCellPosition] = useState<any>()
const [focusedLog, setFocusedLog] = useState<LogData | null>(null)
const [selectionOpen, setSelectionOpen] = useState(false)
useEffect(() => {
if (selectedLog || isSelectedLogLoading) {
setSelectionOpen(true)
}
}, [selectedLog, isSelectedLogLoading])
const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'log_sql', owner_id: profile?.id },
subject: { id: profile?.id },
})
const firstRow: LogData | undefined = data?.[0] as LogData
const firstRow = data[0]
// move timestamp to the first column, if it exists
function getFirstRow() {
if (!firstRow) return {}
const { timestamp, ...rest } = firstRow || {}
const { timestamp, ...rest } = firstRow
if (!timestamp) return firstRow
@@ -172,31 +184,22 @@ const LogTable = ({
}
}
const stringData = JSON.stringify(data)
const stringData = useMemo(() => JSON.stringify(data), [data])
const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
const deduped = [...new Set(data)] as LogData[]
if (!hasId) {
return [deduped, {} as LogMap]
return [deduped, {}]
}
const map = deduped.reduce((acc: LogMap, d: LogData) => {
acc[d.id] = d
return acc
}, {}) as LogMap
}, {})
return [deduped, map]
}, [data, hasId])
useEffect(() => {
if (!data) return
const found = data.find((datum) => isEqual(datum, focusedLog))
if (!found) {
// close selection panel if not found in dataset
setFocusedLog(null)
}
}, [data, focusedLog, stringData])
const logDataRows = useMemo(() => {
if (hasId && hasTimestamp) {
return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
@@ -346,37 +349,44 @@ const LogTable = ({
else return <LogsTableEmptyState />
}
if (!data) return null
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
function onRowClick(row: LogData) {
setSelectedRow(row)
onSelectedLogChange?.(row)
}
if (!data) return null
return (
<section className={'flex w-full flex-col h-screen'} style={{ maxHeight }}>
<section className={'h-full flex w-full flex-col flex-1'}>
{!queryType && <LogsExplorerTableHeader />}
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={focusedLog ? 60 : 100}>
<ResizablePanel defaultSize={selectedLog ? 60 : 100}>
<DataGrid
role="table"
style={{ height: '100%' }}
className={cn('flex-1 flex-grow h-full', {
className={cn('flex-1 flex-grow h-full border-none', {
'data-grid--simple-logs': queryType,
'data-grid--logs-explorer': !queryType,
})}
rowHeight={40}
headerRowHeight={queryType ? 0 : 28}
onSelectedCellChange={(row) => {
setFocusedLog(row.row as LogData)
setCellPosition(row)
}}
selectedRows={new Set([])}
onCellClick={(row) => {
onRowClick(row.row)
}}
columns={columns}
rowClass={(row: LogData) =>
[
'font-mono tracking-tight',
isEqual(row, focusedLog)
? '!bg-surface-300 rdg-row--focused'
: ' !bg-studio hover:!bg-surface-100 cursor-pointer',
].join(' ')
}
rowClass={(row: LogData) => {
return cn(
'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer',
{
'!bg-surface-200 rdg-row--focused': isEqual(row, selectedRow),
}
)
}}
rows={logDataRows}
rowKeyGetter={(r) => {
if (!hasId) return JSON.stringify(r)
@@ -404,15 +414,20 @@ const LogTable = ({
document.body
)}
</ResizablePanel>
<ResizableHandle />
{focusedLog && (
<ResizablePanel defaultSize={40}>
<ResizableHandle withHandle />
{selectionOpen && (
<ResizablePanel minSize={40} defaultSize={50}>
<LogSelection
isLoading={isSelectedLogLoading || false}
projectRef={projectRef}
onClose={() => setFocusedLog(null)}
log={focusedLog}
onClose={() => {
onSelectedLogChange?.(null)
setSelectionOpen(false)
}}
log={selectedLog}
error={selectedLogError}
queryType={queryType}
params={params}
collectionName={collectionName}
/>
</ResizablePanel>
@@ -19,6 +19,8 @@ import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './L
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
import { ensureNoTimestampConflict, maybeShowUpgradePrompt } from './Logs.utils'
import UpgradePrompt from './UpgradePrompt'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import useSingleLog from 'hooks/analytics/useSingleLog'
/**
* Acts as a container component for the entire log display
@@ -73,6 +75,18 @@ export const LogsPreviewer = ({
setParams,
} = useLogsPreview({ projectRef, table, filterOverride })
const [selectedLogId, setSelectedLogId] = useSelectedLog()
const {
data: selectedLog,
isLoading: isSelectedLogLoading,
error: selectedLogError,
} = useSingleLog({
projectRef,
id: selectedLogId ?? undefined,
queryType,
paramsToMerge: params,
})
const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(
params.iso_timestamp_start as string
)
@@ -182,15 +196,8 @@ export const LogsPreviewer = ({
}
}
const noResults = logData.length === 0 && !error
const footerHeight = noResults ? '0px' : '48px'
const navBarHeight = '48px'
const filterPanelHeight = '58px'
const chartHeight = showChart && !noResults ? '114px' : '0px'
const maxHeight = `calc(100vh - ${navBarHeight} - ${filterPanelHeight} - ${footerHeight} - ${chartHeight})`
return (
<div>
<div className="flex-1 flex flex-col h-full">
<PreviewFilterPanel
csvData={logData}
isLoading={isLoading}
@@ -228,10 +235,10 @@ export const LogsPreviewer = ({
<div
className={
'transition-all duration-500 ' +
(showChart && logData.length > 0 ? 'mb-4 h-24 pt-4 opacity-100' : 'h-0 opacity-0')
(showChart && logData.length > 0 ? 'mb-4 h-28 opacity-100' : 'h-0 opacity-0')
}
>
<div className={condensedLayout ? 'px-4' : ''}>
<div className={condensedLayout ? 'px-3' : ''}>
{showChart && (
<LogEventChart
className={cn({
@@ -249,29 +256,28 @@ export const LogsPreviewer = ({
)}
</div>
</div>
<div className="relative flex flex-col flex-grow pt-4">
<div className="relative flex flex-col flex-grow flex-1 overflow-auto">
<ShimmerLine active={isLoading} />
<LoadingOpacity active={isLoading}>
<LogTable
maxHeight={maxHeight}
projectRef={projectRef}
isLoading={isLoading}
data={logData}
queryType={queryType}
isHistogramShowing={showChart}
onHistogramToggle={() => setShowChart(!showChart)}
params={params}
error={error}
EmptyState={EmptyState}
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
selectedLog={selectedLog}
isSelectedLogLoading={isSelectedLogLoading}
selectedLogError={selectedLogError ?? undefined}
/>
</LoadingOpacity>
</div>
{!error && logData.length > 0 && (
<div className="border-t flex flex-row justify-between p-2">
<div className="border-t flex flex-row items-center gap-3 p-2">
<Button
className={cn({
'opacity-0': isLoadingOlder || isLoading,
})}
onClick={loadOlder}
icon={<Rewind />}
type="default"
@@ -280,6 +286,9 @@ export const LogsPreviewer = ({
>
Load older
</Button>
<div className="text-sm text-foreground-lighter">
Showing <span className="font-mono">{logData.length}</span> results
</div>
<div className="flex flex-row justify-end mt-2">
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
</div>
@@ -121,9 +121,7 @@ const PreviewFilterPanel = ({
const handleInputSearch = (query: string) => onSearch('search-input-change', { query })
return (
<div
className={'flex w-full items-center justify-between' + (condensedLayout ? ' px-4 pt-4' : '')}
>
<div className={'flex w-full items-center justify-between' + (condensedLayout ? ' p-3' : '')}>
<div className="flex flex-row items-center gap-x-2">
<form
id="log-panel-search"
@@ -7,6 +7,7 @@ import { Badge, Button, cn } from 'ui'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { BASE_PATH } from 'lib/constants'
import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types'
import Link from 'next/link'
interface ForeignKeyProps {
foreignKey: ForeignKey
@@ -77,13 +78,13 @@ export const ForeignKeyRow = ({
/>
}
>
<EditorTablePageLink
projectRef={ref}
id={String(foreignKey.tableId)}
onClick={() => closePanel()}
<Link
target="_blank"
rel="norefererer"
href={`/project/${ref}/editor/${foreignKey.tableId}`}
>
{foreignKey.schema}.{foreignKey.table}
</EditorTablePageLink>
</Link>
</Button>
</div>
</div>
@@ -4,6 +4,7 @@ import { PropsWithChildren, useEffect } from 'react'
import PartnerIcon from 'components/ui/PartnerIcon'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { withAuth } from 'hooks/misc/withAuth'
import { useFlag } from 'hooks/ui/useFlag'
@@ -12,7 +13,6 @@ import { IS_PLATFORM } from 'lib/constants'
import SettingsLayout from '../SettingsLayout/SettingsLayout'
import type { SidebarSection } from './AccountLayout.types'
import WithSidebar from './WithSidebar'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
export interface AccountLayoutProps {
title: string
@@ -28,12 +28,11 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren<Accou
const selectedOrganization = useSelectedOrganization()
const navLayoutV2 = useFlag('navigationLayoutV2')
const enablePostHogTelemetry = useFlag('enablePosthogChanges')
const { mutateAsync: sendReset } = useSendResetMutation()
const signOut = useSignOut()
const onClickLogout = async () => {
if (enablePostHogTelemetry) await sendReset()
await sendReset()
await signOut()
await router.push('/sign-in')
}
@@ -11,7 +11,7 @@ import { useContentQuery } from 'data/content/content-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useFlag } from 'hooks/ui/useFlag'
import { ArrowUpRight, ChevronRight, FilePlus, Plus } from 'lucide-react'
import { ArrowUpRight, ChevronRight, DatabaseIcon, FilePlus, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
@@ -30,6 +30,7 @@ import {
Separator,
} from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideBarFilters,
InnerSideBarFilterSearchInput,
InnerSideMenuItem,
@@ -276,14 +277,16 @@ export function LogsSidebarMenuV2() {
</div>
)}
{savedQueries.length === 0 && (
<div className="mx-4">
<Alert_Shadcn_ className="p-3">
<AlertTitle_Shadcn_ className="text-xs">No queries created yet</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs">
You can create and save queries from the "Create query" button in the top left.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
<InnerSideBarEmptyPanel
className="mx-4"
title="No queries created yet"
description="Create and save your queries to use them in the explorer"
actions={
<Button asChild type="default">
<Link href={`/project/${ref}/logs/explorer`}>Create query</Link>
</Button>
}
/>
)}
{savedQueries.map((query) => (
<SavedQueriesItem item={query} key={query.id} />
@@ -1,20 +1,22 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { IS_PLATFORM } from 'common'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { useFlag } from 'hooks/ui/useFlag'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
import { generateProjectIntegrationsMenu } from './ProjectIntegrationsMenu.utils'
import { useFlag } from 'hooks/ui/useFlag'
export interface ProjectIntegrationsLayoutProps {
title?: string
}
const ProjectIntegrationsMenu = () => {
const cronUiEnabled = useFlag('cronUi')
// if running on self-hosted, cron UI should be always enabled
const cronUiEnabled = useFlag('cronUi') || !IS_PLATFORM
const project = useSelectedProject()
const router = useRouter()
@@ -24,7 +26,7 @@ const ProjectIntegrationsMenu = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
})
console.log({ data })
const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined
const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined
@@ -0,0 +1,45 @@
import { Command } from 'lucide-react'
import { ReactNode } from 'react'
import { detectOS } from 'lib/helpers'
import { cn } from 'ui'
interface CommandOptionProps {
icon: ReactNode
label: string
shortcut: string
onClick: () => void
}
export const CommandOption = ({ icon, label, shortcut, onClick }: CommandOptionProps) => {
const os = detectOS()
return (
<div
className="px-2 py-1 transition hover:bg-surface-100 flex items-center justify-between rounded cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-x-2">
{icon}
<p className="text-sm">{label}</p>
</div>
<div
className={cn(
'flex items-center gap-1',
'h-6 py-1.5 px-2 leading-none',
'bg-surface-100 text-foreground-lighter',
'border border-default rounded-md',
'shadow-xs shadow-background-surface-100'
)}
>
{/* Issue with `os` and hydration fail */}
{os === 'macos' || true ? (
<Command size={11.5} strokeWidth={1.5} />
) : (
<p className="text-xs">CTRL</p>
)}
<p className="text-xs font-mono">{shortcut}</p>
</div>
</div>
)
}
@@ -7,8 +7,13 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
useIsAPIDocsSidePanelEnabled,
useIsDatabaseFunctionsAssistantEnabled,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useFlag } from 'hooks/ui/useFlag'
import { useSignOut } from 'lib/auth'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
@@ -16,6 +21,7 @@ import { detectOS } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -26,13 +32,20 @@ import {
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
HoverCardContent_Shadcn_,
HoverCardTrigger_Shadcn_,
HoverCard_Shadcn_,
Separator,
Theme,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
cn,
singleThemes,
} from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import { useProjectContext } from '../ProjectContext'
import { CommandOption } from './CommandOption'
import {
generateOtherRoutes,
generateProductRoutes,
@@ -41,9 +54,6 @@ import {
} from './NavigationBar.utils'
import { NavigationIconButton } from './NavigationIconButton'
import NavigationIconLink from './NavigationIconLink'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
export const ICON_SIZE = 20
export const ICON_STROKE_WIDTH = 1.5
@@ -62,6 +72,7 @@ const NavigationBar = () => {
const navLayoutV2 = useFlag('navigationLayoutV2')
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const isFunctionsAssistantEnabled = useIsDatabaseFunctionsAssistantEnabled()
const [userDropdownOpen, setUserDropdownOpenState] = useState(false)
const [allowNavPanelToExpand] = useLocalStorageQuery(
@@ -108,7 +119,7 @@ const NavigationBar = () => {
)
}
const SearchButton = (
const CommandButton = !isFunctionsAssistantEnabled ? (
<NavigationIconButton
size="tiny"
onClick={() => {
@@ -138,6 +149,41 @@ const NavigationBar = () => {
>
Search
</NavigationIconButton>
) : (
<HoverCard_Shadcn_ openDelay={10}>
<HoverCardTrigger_Shadcn_ asChild>
<NavigationIconButton
size="tiny"
type="text"
icon={<Command size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />}
>
Commands
</NavigationIconButton>
</HoverCardTrigger_Shadcn_>
<HoverCardContent_Shadcn_ side="right" className="w-48 p-1 flex flex-col gap-y-1">
<CommandOption
icon={
<div className="px-1">
<Search size={16} />
</div>
}
label="Search"
shortcut="K"
onClick={() => {
setCommandMenuOpen(true)
snap.setNavigationPanelOpen(false)
}}
/>
<CommandOption
icon={
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
}
label="Assistant"
shortcut="I"
onClick={() => snap.setAiAssistantPanel({ open: true, editor: null })}
/>
</HoverCardContent_Shadcn_>
</HoverCard_Shadcn_>
)
const UserAccountButton = (
@@ -312,13 +358,13 @@ const NavigationBar = () => {
<>
{!allowNavPanelToExpand && (
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>{SearchButton}</TooltipTrigger_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>{CommandButton}</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="right">
<span>Search</span>
<span>Commands</span>
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
{allowNavPanelToExpand && SearchButton}
{allowNavPanelToExpand && CommandButton}
</>
)}
@@ -170,9 +170,7 @@ const ChartHandler = ({
</div>
{chartStyle === 'bar' ? (
<BarChart
YAxisProps={{
width: 1,
}}
YAxisProps={{ width: 1 }}
data={(chartData?.data ?? []) as any}
format={format || chartData?.format}
xAxisKey={'period_start'}
@@ -13,7 +13,9 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
Button,
@@ -45,7 +47,12 @@ const StorageSettings = () => {
isSuccess,
isError,
} = useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM })
const { isFreeTier } = config || {}
const organization = useSelectedOrganization()
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const isFreeTier = isSuccessSubscription && subscription.plan.id === 'free'
const [initialValues, setInitialValues] = useState({
fileSizeLimit: 0,
@@ -142,7 +149,7 @@ const StorageSettings = () => {
type="number"
{...field}
className="w-full"
disabled={!canUpdateStorageSettings}
disabled={isFreeTier || !canUpdateStorageSettings}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
@@ -223,7 +230,7 @@ const StorageSettings = () => {
type="default"
htmlType="reset"
onClick={() => form.reset()}
disabled={!canUpdateStorageSettings || isUpdating}
disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating}
>
Cancel
</Button>
@@ -231,7 +238,7 @@ const StorageSettings = () => {
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateStorageSettings || isUpdating}
disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating}
>
Save
</Button>
@@ -15,17 +15,24 @@ import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types'
import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation'
import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query'
import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSchemasForAi } from 'hooks/misc/useSchemasForAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants'
import { useFlag } from 'hooks/ui/useFlag'
import {
BASE_PATH,
IS_PLATFORM,
OPT_IN_TAGS,
TELEMETRY_ACTIONS,
TELEMETRY_CATEGORIES,
TELEMETRY_LABELS,
} from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Alert_Shadcn_,
AlertDescription_Shadcn_,
Button,
cn,
DropdownMenu,
@@ -53,6 +60,7 @@ import { ContextBadge } from './ContextBadge'
import { EntitiesDropdownMenu } from './EntitiesDropdownMenu'
import { Message } from './Message'
import { SchemasDropdownMenu } from './SchemasDropdownMenu'
import { useEntityDefinitionQuery } from 'data/database/entity-definition-query'
const ANIMATION_DURATION = 0.3
@@ -79,8 +87,9 @@ export const AIAssistant = ({
const selectedOrganization = useSelectedOrganization()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const disablePrompts = useFlag('disableAssistantPrompts')
const { aiAssistantPanel } = useAppStateSnapshot()
const { editor } = aiAssistantPanel
const { editor, entity } = aiAssistantPanel
const inputRef = useRef<HTMLTextAreaElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
@@ -106,6 +115,13 @@ export const AIAssistant = ({
selectedSchemas.length === 0 &&
selectedTables.length === 0
const { data: existingDefinition } = useEntityDefinitionQuery({
id: entity?.id,
type: editor,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data } = useEntityDefinitionsQuery(
{
schemas: selectedSchemas,
@@ -115,17 +131,26 @@ export const AIAssistant = ({
{ enabled: includeSchemaMetadata }
)
const entityDefinitions = includeSchemaMetadata
? selectedTables.length === 0
? data?.map((def) => def.sql.trim())
const tableDefinitions =
selectedTables.length === 0
? data?.map((def) => def.sql.trim()) ?? []
: data
?.filter((def) => {
return selectedTables.some((table) => {
return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`)
})
})
.map((def) => def.sql.trim())
: undefined
.map((def) => def.sql.trim()) ?? []
const entityDefinitions = includeSchemaMetadata ? tableDefinitions : undefined
const { mutate: sendEvent } = useSendEventMutation()
const sendTelemetryEvent = (action: string) => {
sendEvent({
action,
category: TELEMETRY_CATEGORIES.AI_ASSISTANT,
label: TELEMETRY_LABELS.QUICK_SQL_EDITOR,
})
}
const {
messages: chatMessages,
@@ -134,7 +159,7 @@ export const AIAssistant = ({
} = useChat({
id,
api: `${BASE_PATH}/api/ai/sql/generate-v2`,
body: { entityDefinitions, context: selectedDatabaseEntity },
body: { entityDefinitions, context: selectedDatabaseEntity, existingSql: existingDefinition },
onError: (error) => setAssistantError(JSON.parse(error.message).error),
})
@@ -172,6 +197,7 @@ export const AIAssistant = ({
append(payload)
setAssistantError(undefined)
setLastSentMessage(payload)
sendTelemetryEvent(TELEMETRY_ACTIONS.PROMPT_SUBMITTED)
}
const toggleSchema = (schema: string) => {
@@ -180,6 +206,7 @@ export const AIAssistant = ({
} else {
const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b))
setSelectedSchemas(newSelectedSchemas)
sendTelemetryEvent(TELEMETRY_ACTIONS.SCHEMA_CONTEXT_ADDED)
}
}
@@ -192,6 +219,7 @@ export const AIAssistant = ({
(a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name)
)
setSelectedTables(newselectedTables)
sendTelemetryEvent(TELEMETRY_ACTIONS.TABLE_CONTEXT_ADDED)
}
}
@@ -205,6 +233,7 @@ export const AIAssistant = ({
if (prompt) {
setValue(prompt)
sendMessageToAssistant(prompt)
sendTelemetryEvent(TELEMETRY_ACTIONS.QUICK_PROMPT_SELECTED(type))
}
}
@@ -420,6 +449,13 @@ export const AIAssistant = ({
)}
</AnimatePresence>
<div className="flex flex-col gap-y-2">
{disablePrompts && (
<Admonition
type="default"
title="Assistant has been temporarily disabled"
description="Give us a moment while we work on bringing the Assistant back online"
/>
)}
<div className="w-full border rounded">
<div className="py-2 px-3 border-b flex gap-2 flex-wrap">
<DropdownMenu>
@@ -429,7 +465,7 @@ export const AIAssistant = ({
<Button
type="default"
icon={<Plus />}
className={noContextAdded ? '' : 'px-1.5 !space-x-0'}
className={cn(noContextAdded ? '' : 'px-1.5 !space-x-0')}
>
<span className={noContextAdded ? '' : 'sr-only'}>Add context</span>
</Button>
@@ -582,7 +618,7 @@ export const AIAssistant = ({
'[&>textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
)}
loading={isLoading}
disabled={isLoading}
disabled={disablePrompts || isLoading}
placeholder={
hasMessages ? 'Reply to the assistant...' : 'How can we help you today?'
}
@@ -593,7 +629,13 @@ export const AIAssistant = ({
sendMessageToAssistant(value)
}}
/>
{!hasMessages && (
<div className="text-xs text-foreground-lighter text-opacity-60 bg-control px-3 pb-2">
The Assistant is in Alpha and your prompts might be rate limited
</div>
)}
</div>
<AnimatePresence>
{!hasMessages && (
<motion.div
@@ -1,3 +1,4 @@
import { CommonDatabaseEntity } from 'state/app-state'
import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types'
const PLACEHOLDER_PREFIX = `-- Press tab to use this code
@@ -5,12 +6,17 @@ const PLACEHOLDER_PREFIX = `-- Press tab to use this code
const PLACEHOLDER_LIMIT = `Just three examples will do.`
export const generateTitle = (editor?: SupportedAssistantEntities | null) => {
export const generateTitle = (
editor?: SupportedAssistantEntities | null,
entity?: CommonDatabaseEntity
) => {
switch (editor) {
case 'functions':
return 'Create a new function'
if (entity === undefined) return 'Create a new function'
else return `Edit function: ${entity.name}`
case 'rls-policies':
return 'Create a new RLS policy'
if (entity === undefined) return 'Create a new RLS policy'
else return `Edit RLS policy: ${entity.name}`
default:
return 'SQL Scratch Pad'
}
@@ -27,10 +33,15 @@ export const generateCTA = (editor?: SupportedAssistantEntities | null) => {
}
}
export const generatePlaceholder = (editor?: SupportedAssistantEntities | null) => {
export const generatePlaceholder = (
editor?: SupportedAssistantEntities | null,
entity?: CommonDatabaseEntity,
existingDefinition?: string
) => {
switch (editor) {
case 'functions':
return `${PLACEHOLDER_PREFIX}
if (entity === undefined) {
return `${PLACEHOLDER_PREFIX}
CREATE FUNCTION *schema*.*function_name*(*param1 type*, *param2 type*)\n
&nbsp;&nbsp;RETURNS *return_type*\n
&nbsp;&nbsp;LANGUAGE *plpgsql*\n
@@ -44,6 +55,26 @@ BEGIN\n
END;\n
$$;
`
} else {
return `${PLACEHOLDER_PREFIX}
-- To rename the function\n
ALTER FUNCTION *${entity.name}* RENAME TO *new_name*;\n
&nbsp;\n
-- To change the schema of the function\n
ALTER FUNCTION *${entity.name}* SET SCHEMA *new_schema*;\n
&nbsp;\n
-- To update the function body or the arguments, use\n
-- the create or replace statement instead\n
${existingDefinition
?.replaceAll(
'\n ',
`\n\
&nbsp;&nbsp;`
)
.replaceAll('\n', '\n\n')
.trim()}
`
}
case 'rls-policies':
return `${PLACEHOLDER_PREFIX}
CREATE POLICY *name* ON *table_name*\n
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import { useChat } from 'ai/react'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils'
import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel'
import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants'
@@ -17,11 +18,18 @@ import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation'
import { databasePoliciesKeys } from 'data/database-policies/keys'
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { sqlKeys } from 'data/sql/keys'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { usePrevious } from 'hooks/deprecated'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { SqlEditor } from 'icons'
import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants'
import {
BASE_PATH,
LOCAL_STORAGE_KEYS,
TELEMETRY_ACTIONS,
TELEMETRY_CATEGORIES,
TELEMETRY_LABELS,
} from 'lib/constants'
import { detectOS, uuidv4 } from 'lib/helpers'
import { useAppStateSnapshot } from 'state/app-state'
import {
@@ -40,7 +48,7 @@ import CodeEditor from '../CodeEditor/CodeEditor'
import { AIAssistant } from './AIAssistant'
import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils'
import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useEntityDefinitionQuery } from 'data/database/entity-definition-query'
export const AiAssistantPanel = () => {
const os = detectOS()
@@ -51,7 +59,7 @@ export const AiAssistantPanel = () => {
const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot()
const isEnabled = useIsDatabaseFunctionsAssistantEnabled()
const { open, editor, content } = aiAssistantPanel
const { open, editor, content, entity } = aiAssistantPanel
const previousEditor = usePrevious(editor)
const [isAcknowledged, setIsAcknowledged] = useLocalStorage(
@@ -66,11 +74,18 @@ export const AiAssistantPanel = () => {
const [showWarning, setShowWarning] = useState<boolean>(false)
const [debugThread, setDebugThread] = useState<MessageWithDebug[]>([])
const { data: existingDefinition } = useEntityDefinitionQuery({
id: entity?.id,
type: editor,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
// [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able
// I don't really see any negatives of keeping it open (or benefits from hiding) tbh
// const [showAssistant, setShowAssistant] = useState(true)
const title = generateTitle(editor)
const placeholder = generatePlaceholder(editor)
const title = generateTitle(editor, entity)
const placeholder = generatePlaceholder(editor, entity, existingDefinition)
const ctaText = generateCTA(editor)
const editorRef = useRef<IStandaloneCodeEditor | undefined>()
const editorModel = editorRef.current?.getModel()
@@ -80,6 +95,15 @@ export const AiAssistantPanel = () => {
(error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? []
const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor)
const { mutate: sendEvent } = useSendEventMutation()
const sendTelemetryEvent = (action: string) => {
sendEvent({
action,
category: TELEMETRY_CATEGORIES.AI_ASSISTANT,
label: TELEMETRY_LABELS.QUICK_SQL_EDITOR,
})
}
const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: async (res) => {
// [Joshen] If in a specific editor context mode, assume that intent was to create/update
@@ -112,7 +136,7 @@ export const AiAssistantPanel = () => {
id: chatId,
api: `${BASE_PATH}/api/ai/sql/generate-v2`,
// [Joshen] Don't need entity definitions if calling here cause support action
// via AI here is just to explain code segment, no need for context
// via AI here is just to explain code segment, no need for entity definitions context
body: {},
})
@@ -156,6 +180,7 @@ export const AiAssistantPanel = () => {
})
setDebugThread([...debugThread, assistantMessageBefore])
sendTelemetryEvent(TELEMETRY_ACTIONS.FIX_WITH_ASSISTANT)
try {
const { solution, sql } = await debugSql({
@@ -190,6 +215,7 @@ export const AiAssistantPanel = () => {
${value}
`.trim(),
})
sendTelemetryEvent(TELEMETRY_ACTIONS.EXPLAIN_CODE)
}
const updateEditorWithCheckForDiff = useCallback(
@@ -218,6 +244,17 @@ export const AiAssistantPanel = () => {
[editorModel]
)
const onTogglePanel = () => {
if (open) {
// [Joshen] Save the code content when closing - should allow users to continue from
// where they left off IF they are opening the assistant again in the same "editor" context
const existingValue = editorRef.current?.getValue() ?? ''
setAiAssistantPanel({ open: false, content: existingValue })
} else {
setAiAssistantPanel({ open: true, editor: null, entity: undefined })
}
}
useEffect(() => {
if (open) {
// [Joshen] Only reset the assistant if the editor changed
@@ -247,14 +284,12 @@ export const AiAssistantPanel = () => {
// [Joshen] Just to test the concept of a universal assistant of sorts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.code === 'KeyI') {
setAiAssistantPanel({ open: true, editor: null })
}
if (e.metaKey && e.code === 'KeyI') onTogglePanel()
}
if (project !== undefined && isEnabled) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, isEnabled])
}, [project, isEnabled, open])
// [Joshen] Whenever the deps change recalculate the height of the editor
useEffect(() => {
@@ -263,19 +298,7 @@ export const AiAssistantPanel = () => {
}, [error, showWarning, isExecuting, numResults, showResults])
return (
<Sheet
open={open}
onOpenChange={() => {
if (open) {
// [Joshen] Save the code content when closing - should allow users to continue from
// where they left off IF they are opening the assistant again in the same "editor" context
const existingValue = editorRef.current?.getValue() ?? ''
setAiAssistantPanel({ open: false, content: existingValue })
} else {
setAiAssistantPanel({ open: true })
}
}}
>
<Sheet open={open} onOpenChange={() => onTogglePanel()}>
<SheetContent showClose={true} className={cn('flex gap-0 w-[1200px]')}>
{/* Assistant */}
<AIAssistant
@@ -334,6 +357,7 @@ export const AiAssistantPanel = () => {
actions={{
runQuery: { enabled: true, callback: () => onExecuteSql() },
explainCode: { enabled: true, callback: onExplainSql },
closeAssistant: { enabled: true, callback: () => onTogglePanel() },
}}
/>
</div>
@@ -404,11 +428,7 @@ export const AiAssistantPanel = () => {
</div>
)}
<SheetFooter className="bg-surface-100 flex items-center !justify-end px-5 py-4 w-full border-t">
<Button
type="default"
disabled={isExecuting}
onClick={() => setAiAssistantPanel({ open: false })}
>
<Button type="default" disabled={isExecuting} onClick={() => onTogglePanel()}>
Cancel
</Button>
<Button
@@ -10,6 +10,7 @@ import { Loading } from '../Loading'
import { alignEditor } from './CodeEditor.utils'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { formatQuery } from 'data/sql/format-sql-query'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
type CodeEditorActions = { enabled: boolean; callback: (value: any) => void }
const DEFAULT_ACTIONS = {
@@ -17,6 +18,7 @@ const DEFAULT_ACTIONS = {
explainCode: { enabled: false, callback: noop },
formatDocument: { enabled: true, callback: noop },
placeholderFill: { enabled: true },
closeAssistant: { enabled: false, callback: noop },
}
interface CodeEditorProps {
@@ -37,6 +39,7 @@ interface CodeEditorProps {
formatDocument: CodeEditorActions
placeholderFill: Omit<CodeEditorActions, 'callback'>
explainCode: CodeEditorActions
closeAssistant: CodeEditorActions
}>
editorRef?: MutableRefObject<editor.IStandaloneCodeEditor | undefined>
onInputChange?: (value?: string) => void
@@ -66,7 +69,7 @@ const CodeEditor = ({
const editorRef = editorRefProps || ref
const monacoRef = useRef<Monaco>()
const { runQuery, placeholderFill, formatDocument, explainCode } = {
const { runQuery, placeholderFill, formatDocument, explainCode, closeAssistant } = {
...DEFAULT_ACTIONS,
...actions,
}
@@ -168,6 +171,15 @@ const CodeEditor = ({
})
}
if (closeAssistant.enabled) {
editor.addAction({
id: 'close-assistant',
label: 'Close Assistant',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
run: () => closeAssistant.callback(),
})
}
const model = editor.getModel()
if (model) {
const position = model.getPositionAt((value ?? '').length)
+10 -18
View File
@@ -11,7 +11,6 @@ import { useSendPageLeaveMutation } from 'data/telemetry/send-page-leave-mutatio
import { useSendPageMutation } from 'data/telemetry/send-page-mutation'
import { usePrevious } from 'hooks/deprecated'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { ConsentToast } from 'ui-patterns/ConsentToast'
@@ -34,13 +33,11 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
const consentToastId = useRef<string | number>()
const previousPathname = usePrevious(router.pathname)
const enablePostHogTelemetry = useFlag('enablePosthogChanges')
const consentFlag = enablePostHogTelemetry
? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH
: LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT
const consentFlag = LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT
const consent = typeof window !== 'undefined' ? localStorage.getItem(consentFlag) : null
const trackTelemetryPH = enablePostHogTelemetry && consent === 'true'
const trackTelemetryPH = consent === 'true'
const { mutate: sendPage } = useSendPageMutation()
const { mutateAsync: sendPageLeave } = useSendPageLeaveMutation()
@@ -48,27 +45,22 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
const { mutate: sendGroupsReset } = useSendGroupsResetMutation()
useEffect(() => {
if (typeof window !== 'undefined' && enablePostHogTelemetry !== undefined) {
if (typeof window !== 'undefined') {
const onAcceptConsent = () => {
snap.setIsOptedInTelemetry(true, consentFlag)
snap.setIsOptedInTelemetry(true)
if (consentToastId.current) toast.dismiss(consentToastId.current)
}
const onOptOut = () => {
snap.setIsOptedInTelemetry(false, consentFlag)
snap.setIsOptedInTelemetry(false)
if (consentToastId.current) toast.dismiss(consentToastId.current)
}
const hasAcknowledgedConsent = localStorage.getItem(
enablePostHogTelemetry
? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH
: LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT
)
const hasAcknowledgedConsent = localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
snap.setIsOptedInTelemetry(
typeof hasAcknowledgedConsent === 'string'
? hasAcknowledgedConsent === 'true'
: hasAcknowledgedConsent,
consentFlag
: hasAcknowledgedConsent
)
if (IS_PLATFORM && hasAcknowledgedConsent === null) {
@@ -86,7 +78,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enablePostHogTelemetry])
}, [])
useEffect(() => {
function handleRouteChange() {
@@ -163,7 +155,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
useEffect(() => {
const handleBeforeUnload = async () => {
if (enablePostHogTelemetry && snap.isOptedInTelemetry) await sendPageLeave()
if (snap.isOptedInTelemetry) await sendPageLeave()
}
window.addEventListener('beforeunload', handleBeforeUnload)
@@ -0,0 +1,19 @@
import { useWarehouseQueryQuery } from './warehouse-query'
export function useWarehouseLogDetailQuery({
ref,
collectionName,
logId,
columns = ['id', 'timestamp'],
}: {
ref: string
collectionName?: string
logId?: string
columns?: string[]
}) {
const enabled = !!logId && !!collectionName
const sql = `select ${columns.join(',')} from \`${collectionName}\`
where id = '${logId}' and timestamp > '2024-01-01' limit 1`
return useWarehouseQueryQuery({ ref, sql }, { enabled })
}
@@ -21,7 +21,7 @@ export type CheckCNAMERecordResponse = {
// [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98
export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) {
const res = await fetch(`https://1.1.1.1/dns-query?name=${domain}&type=CNAME`, {
const res = await fetch(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, {
method: 'GET',
headers: { accept: 'application/dns-json' },
})
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more