mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
Merge branch 'master' into feat/compute-and-disk
This commit is contained in:
commit
3bddba3bc5
143 files changed
+3144
-2566
No files matched your search
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. |
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
+16
-3
@@ -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>
|
||||
|
||||
+38
-37
@@ -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}`
|
||||
|
||||
+7
-2
@@ -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(),
|
||||
|
||||
+13
-13
@@ -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>
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-2
@@ -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>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+1
-2
@@ -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>
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
}
|
||||
-2
@@ -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"
|
||||
|
||||
+6
-5
@@ -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} />
|
||||
|
||||
+5
-3
@@ -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
|
||||
RETURNS *return_type*\n
|
||||
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
|
||||
\n
|
||||
-- To change the schema of the function\n
|
||||
ALTER FUNCTION *${entity.name}* SET SCHEMA *new_schema*;\n
|
||||
\n
|
||||
-- To update the function body or the arguments, use\n
|
||||
-- the create or replace statement instead\n
|
||||
${existingDefinition
|
||||
?.replaceAll(
|
||||
'\n ',
|
||||
`\n\
|
||||
`
|
||||
)
|
||||
.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)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user