Merge branch 'master' into fix/analytics-self-hosted

This commit is contained in:
Ali Waseem authored and GitHub committed 2025-11-04 08:32:57 -07:00
commit 0a36f02b40
641 files changed
+7055 -4438

No files matched your search

+8
View File
@@ -128,6 +128,7 @@ export const MyPageComponent = () => (
- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`).
- Keep imports from `ui` with `_Shadcn_` suffixes.
- Forms should generally be wrapped in a Card unless specified
- If the submit button is outside the form, add a new variable named formId outside the component, and set it as property id on the form element and formId on the button.
### Example (single field)
@@ -143,6 +144,8 @@ const profileSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters'),
})
const formId = `profile-form`
export function ProfileForm() {
const form = useForm<z.infer<typeof profileSchema>>({
resolver: zodResolver(profileSchema),
@@ -200,6 +203,11 @@ export function ProfileForm() {
- Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list
## React Query
- When doing a mutation, always use the mutate function. Always use onSuccess and onError with a toast.success and toast.error.
- Use mutateAsync only if the mutation is part of multiple async actions. Wrap the mutateAsync call with try/catch block and add toast.success and toast.error.
## Tables
- Use the generic ui table components for most tables
+5 -5
View File
@@ -58,13 +58,13 @@ The design system _references_ components rather than housing them. That’s an
With that out of the way, there are several parts of this design system that need to be manually updated after components have been added or removed (from documentation). These include:
- `config/docs.ts`: list of components in the sidebar
- `content/docs`: where the actual component documentation `.mdx` file lives
- `content/docs`: the actual component documentation
- `registry/examples.ts`: list of example components
- `registry/default/example`: where the actual example component(s) live
- `registry/charts.ts`: Chart components
- `registry/fragments.ts`: Fragment components
- `registry/default/example`: the actual example components
- `registry/charts.ts`: chart components
- `registry/fragments.ts`: fragment components
You will need to rebuild the design system’s registry each time a new example component is added. In other words: whenever a new file enters `registry`, it needs to be rebuilt. You can do that via:
You may need to rebuild the design system’s registry. You can do that via:
```bash
cd apps/design-system
+33
View File
@@ -2293,6 +2293,39 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"empty-state-missing-route": {
name: "empty-state-missing-route",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/empty-state-missing-route")),
source: "",
files: ["registry/default/example/empty-state-missing-route.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"empty-state-zero-items-table": {
name: "empty-state-zero-items-table",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/empty-state-zero-items-table")),
source: "",
files: ["registry/default/example/empty-state-zero-items-table.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"empty-state-initial-state": {
name: "empty-state-initial-state",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/empty-state-initial-state")),
source: "",
files: ["registry/default/example/empty-state-initial-state.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"chart-area-axes": {
name: "chart-area-axes",
type: "components:block",
+5 -22
View File
@@ -6,28 +6,6 @@ interface DocsConfig {
}
export const docsConfig: DocsConfig = {
// mainNav: [
// {
// title: 'Documentation',
// href: '/docs',
// },
// {
// title: 'Components',
// href: '/docs/components/accordion',
// },
// {
// title: 'Themes',
// href: '/themes',
// },
// {
// title: 'Examples',
// href: '/examples',
// },
// {
// title: 'Blocks',
// href: '/blocks',
// },
// ],
sidebarNav: [
{
title: 'Getting Started',
@@ -77,6 +55,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/ui-patterns/navigation',
items: [],
},
{
title: 'Empty States',
href: '/docs/ui-patterns/empty-states',
items: [],
},
],
},
{
@@ -0,0 +1,38 @@
---
title: Empty states
description: Convey the absence of data and provide clear instruction for what to do about it.
---
At a minimum, empty states convey the fact that there is nothing to list, perform, or display on the current page. They should also provide a clear call to action for the user to take.
## Missing route
Users may accidentally navigate to a non-existent dynamic route, such as a non-existent bucket in [Storage](https://supabase.com/dashboard/project/_/storage) or a non-existent table in the [Table Editor](https://supabase.com/dashboard/project/_/editor). In these cases, follow the pattern of a centered [Admonition](../fragments/admonition) as shown below..
<ComponentPreview name="empty-state-missing-route" peekCode wide />
## Zero results
Tabular information without results—or perhaps no data to begin with—should have an empty state that matches the larger presentation.
For instance, a [Table](../components/table) may just display a single row just like it would if it had data. Dulling the TableHead text color and removing the TableCell hover state can further reinforce the lack of usable data.
<ComponentPreview name="empty-state-zero-items-table" peekCode wide />
The treatment for other layouts, such as the list of users in [Authentication](https://supabase.com/dashboard/project/_/auth/users), should match their own general styling.
## Initial state
Perhaps the user has not yet created any data yet. They might be a feature for the first time. In these cases, the empty state should provide the briefest information about the lack of data, putting more focus on the value proposition and primary action.
<ComponentPreview name="empty-state-initial-state" peekCode wide />
Keep in mind that this empty state will likely appear after a visual loading state. Consider layout shift and button placement during and after the transition.
## Components
There is not yet a shared empty state UI component. The context and needs for each placement differ enough to warrant custom components for each placement. That said, we should aim to make these as consistent as possible over time. See the below examples that might share common logic in a future centralized component.
## External references
- [_Empty States_ on GitHub Primer](https://primer.style/product/ui-patterns/empty-states/)
+18 -7
View File
@@ -26,16 +26,27 @@ const nextConfig = {
return [
...(BASE_PATH.length
? [
{
source: '/',
destination: BASE_PATH,
basePath: false,
permanent: false,
},
]
{
source: '/',
destination: BASE_PATH,
basePath: false,
permanent: false,
},
]
: []),
]
},
// Turbopack configuration to handle .md files with raw-loader
// This mirrors the webpack configuration added by withContentlayer
// and ensures both bundlers can process content files properly
turbopack: {
rules: {
'*.md': {
loaders: ['raw-loader'],
as: '*.js',
},
},
},
}
export default withContentlayer(nextConfig)
+2 -1
View File
@@ -29,6 +29,7 @@
"next-contentlayer2": "0.4.6",
"next-themes": "^0.3.0",
"react": "catalog:",
"react-day-picker": "^9.11.1",
"react-docgen": "^7.0.3",
"react-dom": "catalog:",
"react-hook-form": "^7.45.0",
@@ -53,8 +54,8 @@
"@types/lodash.template": "4.5.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"config": "workspace:^",
"concurrently": "^8.2.2",
"config": "workspace:^",
"mdast-util-toc": "^6.1.1",
"postcss": "^8.5.3",
"rimraf": "^4.1.3",
@@ -1,13 +1,17 @@
'use client'
import * as React from 'react'
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from 'ui'
import { Calendar } from 'ui'
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import {
Button,
Calendar,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
export default function DatePickerDemo() {
const [date, setDate] = React.useState<Date>()
@@ -21,8 +25,8 @@ export default function DatePickerDemo() {
'w-[280px] justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
icon={<CalendarIcon className="h-4 w-4" />}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger_Shadcn_>
@@ -62,9 +62,9 @@ export default function DatePickerForm() {
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground'
)}
icon={<CalendarIcon className="h-4 w-4 opacity-50" />}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl_Shadcn_>
</PopoverTrigger_Shadcn_>
@@ -1,14 +1,16 @@
'use client'
import * as React from 'react'
import { addDays, format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from 'ui'
import { Calendar } from 'ui'
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import {
Button,
Calendar,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
@@ -28,8 +30,8 @@ export default function DatePickerWithPresets() {
'w-[280px] justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
icon={<CalendarIcon className="h-4 w-4" />}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger_Shadcn_>
@@ -1,14 +1,18 @@
'use client'
import * as React from 'react'
import { addDays, format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
// @ts-expect-error
import * as React from 'react'
import { DateRange } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button } from 'ui'
import { Calendar } from 'ui'
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import {
Button,
Calendar,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
export default function DatePickerWithRange({ className }: React.HTMLAttributes<HTMLDivElement>) {
const [date, setDate] = React.useState<DateRange | undefined>({
@@ -27,8 +31,8 @@ export default function DatePickerWithRange({ className }: React.HTMLAttributes<
'w-[300px] justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
icon={<CalendarIcon className="h-4 w-4" />}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
@@ -0,0 +1,22 @@
import { Button } from 'ui'
import { Plus } from 'lucide-react'
import { BucketAdd } from 'icons'
export default function EmptyStateInitialState() {
return (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-4 items-center text-center gap-1 text-balance">
<div className="flex flex-col gap-3 items-center text-center">
<BucketAdd size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col gap-1">
<h3>Create a vector bucket</h3>
<p className="text-foreground-light text-sm">
Store, index, and query your vector embeddings at scale.
</p>
</div>
</div>
<Button size="tiny" type="primary" className="w-fit" icon={<Plus size={14} />}>
Create bucket
</Button>
</aside>
)
}
@@ -0,0 +1,22 @@
import { Admonition } from 'ui-patterns/admonition'
import Link from 'next/link'
import { Button } from 'ui'
const bucketId = 'user_avatars'
export default function EmptyStateMissingRoute() {
return (
<div className="flex items-center justify-center w-full h-full">
<Admonition
type="default"
className="max-w-md"
title="Unable to find bucket"
description={`${bucketId ? `The bucket “${bucketId}”` : 'This bucket'} doesn’t seem to exist.`}
>
<Button asChild type="default" className="mt-2">
<Link href="/">Head back</Link>
</Button>
</Admonition>
</div>
)
}
@@ -0,0 +1,25 @@
import { Card, Table, TableBody, TableHead, TableHeader, TableRow, TableCell } from 'ui'
export default function EmptyStateZeroItemsTable() {
return (
<Card className="w-full">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-foreground-muted">Table name</TableHead>
<TableHead className="text-foreground-muted">Date created</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No tables yet</p>
<p className="text-sm text-foreground-lighter">Connect a table from your database</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
)
}
+15
View File
@@ -1263,4 +1263,19 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/logs-bar-chart.tsx'],
},
{
name: 'empty-state-missing-route',
type: 'components:example',
files: ['example/empty-state-missing-route.tsx'],
},
{
name: 'empty-state-zero-items-table',
type: 'components:example',
files: ['example/empty-state-zero-items-table.tsx'],
},
{
name: 'empty-state-initial-state',
type: 'components:example',
files: ['example/empty-state-initial-state.tsx'],
},
]
@@ -25,7 +25,7 @@
Add the Postgres binary to your system PATH.
In Control Panel, under the Advanced tab of System Properties, click Environment Variables. Edit the Path variable by adding the path the SQL binary you just installed.
In Control Panel, under the Advanced tab of System Properties, click Environment Variables. Edit the Path variable by adding the path to the SQL binary you just installed.
The path will look something like this, though it may differ slightly depending on your installed version:
@@ -71,20 +71,34 @@ For mobile applications, you might need to link or redirect to a specific page w
Certain email providers may have spam detection or other security features that prefetch URL links from incoming emails (e.g. [Safe Links in Microsoft Defender for Office 365](https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/safe-links-about?view=o365-worldwide)).
In this scenario, the `{{ .ConfirmationURL }}` sent will be consumed instantly which leads to a "Token has expired or is invalid" error.
To guard against this:
To guard against this there are the options below:
- Use an email OTP instead by including `{{ .Token }}` in the email template.
- Create your own custom email link to redirect the user to a page where they can click on a button to confirm the action.
For example, you can include the following in your email template:
**Option 1**
```html
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}"
>Confirm your signup
</a>
```
- Use an email OTP instead by including `{{ .Token }}` in the email template
- Create your own custom email link to redirect the user to a page where they can enter with their email and token to login
The user should be brought to a page on your site where they can confirm the action by clicking a button.
The button should contain the actual confirmation link which can be obtained from parsing the `confirmation_url={{ .ConfirmationURL }}` query parameter in the URL.
```html
<a href="{{ .SiteURL }}/confirm-signup">Confirm your signup</a>
```
- Log them in by verifying the OTP token value with their email e.g. with [`supabase.auth.verifyOtp`](/docs/reference/javascript/auth-verifyotp) show below
```ts
const { data, error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
```
**Option 2**
- Create your own custom email link to redirect the user to a page where they can click on a button to confirm the action
```html
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}"
>Confirm your signup</a
>
```
- The button should contain the actual confirmation link which can be obtained from parsing the `confirmation_url={{ .ConfirmationURL }}` query parameter in the URL.
### Email tracking
@@ -108,7 +108,7 @@ For native mobile applications, you can link an identity using an ID token obtai
const idToken = 'ID_TOKEN_FROM_GOOGLE'
const accessToken = 'ACCESS_TOKEN_FROM_GOOGLE'
const { data, error } = await supabase.auth.linkIdentityWithIdToken({
const { data, error } = await supabase.auth.linkIdentity({
provider: 'google',
token: idToken,
access_token: accessToken,
@@ -481,3 +481,13 @@ export const GET: RequestHandler = async ({ url, locals }) => {
</TabPanel>
</Tabs>
### Why doesn't IdP-initiated SAML flow work with PKCE, and what's the alternative?
Traditional IdP-initiated SAML flows aren't compatible with PKCE (Proof Key for Code Exchange) because PKCE requires a `code_challenge` and `code_verifier` that are generated when your application initiates the authentication flow. In IdP-initiated flows, Supabase receives an unsolicited response without this information, causing the code exchange step to fail.
To achieve the same user experience while maintaining PKCE security, you can implement a "bookmark app" approach:
Create an endpoint in your application (for example, `https://your-app.com/auth/saml-init`) that initiates the SAML flow using `signInWithSSO`. Then create a bookmark or linked application in your IdP that points to this endpoint. When users access the bookmark app, it triggers a secure SP-initiated flow.
This approach supports custom SAML assertions and lets you embed the link anywhere in your application.
@@ -50,7 +50,7 @@ When you receive this error on the server-side, try to defer rendering to the br
The `Max-Age` or `Expires` cookie parameters only control whether the browser sends the value to the server. Since a refresh token represents the long-lived authentication session of the user on that browser, setting a short `Max-Age` or `Expires` parameter on the cookies only results in a degraded user experience.
The only way to ensure that a user has logged out or their session has ended is to get the user's details with `getUser()`.
The only way to ensure that a user has logged out or their session has ended is to get the user's details with `getUser()`. The `getClaims()` method only checks local JWT validation (signature and expiration), but it doesn't verify with the auth server whether the session is still valid or if the user has logged out server-side.
### What should I use for the `SameSite` property?
@@ -443,13 +443,13 @@ export default OneTapComponent
<TabPanel id="react-native" label="Expo React Native">
Unlike the OAuth flow which requires the use of a web browser, the native Sign in with Google flow on Android uses the [operating system's built-in functionalities](https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/package-summary) to prompt the user for consent. Note that native sign-in has been rebranded as _One Tap sign-in on Android_ by Google, which you should not confuse with _One Tap sign in for web_, as mentioned below.
Unlike the OAuth flow which requires the use of a web browser, the native Sign in with Google flow on Android uses the [Credential Manager library](https://developer.android.com/identity/sign-in/credential-manager-siwg) to prompt the user for consent.
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that is then sent to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that you then send to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
By default, Supabase Auth implements nonce validation during the authentication flow. This can be disabled in production under `Authentication > Providers > Google > Skip Nonce Check` in the Dashboard, or when developing locally by setting `auth.external.<provider>.skip_nonce_check`. Only disable this if your client libraries cannot properly handle nonce verification.
When working with Expo, you can use the [`react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) library to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
When working with Expo, you can use the [`@react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
Follow the [Expo installation docs](https://react-native-google-signin.github.io/docs/setting-up/expo) for installation and configuration instructions. See the [supabase-js reference](/docs/reference/javascript/initializing?example=react-native-options-async-storage) for instructions on initializing the supabase-js client in React Native.
@@ -463,7 +463,6 @@ import { supabase } from '../utils/supabase'
export default function () {
GoogleSignin.configure({
scopes: ['https://www.googleapis.com/auth/drive.readonly'],
webClientId: 'YOUR CLIENT ID FROM GOOGLE CONSOLE',
})
@@ -474,20 +473,16 @@ export default function () {
onPress={async () => {
try {
await GoogleSignin.hasPlayServices()
const userInfo = await GoogleSignin.signIn()
if (userInfo.data.idToken) {
const response = await GoogleSignin.signIn()
if (isSuccessResponse(response)) {
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: userInfo.data.idToken,
token: response.data.idToken,
})
console.log(error, data)
} else {
throw new Error('no ID token present!')
}
} catch (error: any) {
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
// user cancelled the login flow
} else if (error.code === statusCodes.IN_PROGRESS) {
if (error.code === statusCodes.IN_PROGRESS) {
// operation (e.g. sign in) is in progress already
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
// play services not available or outdated
@@ -11,8 +11,8 @@ How you connect to your database depends on where you're connecting from:
- For frontend applications, use the [Data API](#data-apis-and-client-libraries)
- For Postgres clients, use a connection string
- For single sessions (for example, database GUIs) or Postgres native commands (for example, using client applications like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) or specifying connections for [replication](/docs/guides/database/postgres/setup-replication-external)) use the [direct connection string](#direct-connection) if your environment supports IPv6
- For persistent clients, and support for both IPv4 and IPv6, use [Supavisor session mode](#supavisor-session-mode)
- For temporary clients (for example, serverless or edge functions) use [Supavisor transaction mode](#supavisor-transaction-mode)
- For persistent clients, and support for both IPv4 and IPv6, use [pooler session mode](#pooler-session-mode)
- For temporary clients (for example, serverless or edge functions) use [pooler transaction mode](#pooler-transaction-mode)
## Quickstarts
@@ -74,18 +74,18 @@ Direct connections use IPv6 by default. If your environment doesn't support IPv6
The connection string looks like this:
```
postgresql://postgres:[YOUR-PASSWORD]@db.apbkobhfnmcqqzqeeqss.supabase.co:5432/postgres
postgresql://postgres:[YOUR-PASSWORD]@db.abcdefghijklmnopqrst.supabase.co:5432/postgres
```
Get your project's direct connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true).
## Shared pooler
## Poolers
Every Supabase project includes a free, shared connection pooler. This is ideal for persistent servers when IPv6 is not supported.
Every Supabase project includes a connection pooler. This is ideal for persistent servers when IPv6 is not supported.
### Supavisor session mode
### Pooler session mode
The session mode connection string connects to your Postgres instance via a proxy.
The session mode connection string connects to your Postgres instance via a proxy. This is only recommended as an alternative to a Direct Connection, when connecting via an IPv4 network.
The connection string looks like this:
@@ -95,7 +95,7 @@ postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.s
Get your project's Session pooler connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true&method=session).
### Supavisor transaction mode
### Pooler transaction mode
The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is ideal for serverless or edge functions, which require many transient connections.
@@ -108,7 +108,7 @@ Transaction mode does not support [prepared statements](https://postgresql.org/d
The connection string looks like this:
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
postgres://postgres:[YOUR-PASSWORD]@db.abcdefghijklmnopqrst.supabase.co:6543/postgres
```
Get your project's Transaction pooler connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true&method=transaction).
@@ -97,7 +97,6 @@ You can use Supabase to store and process Protected Health Information (PHI). Yo
- Turning on [SSL Enforcement](/docs/guides/platform/ssl-enforcement).
- Enabling [Network Restrictions](/docs/guides/platform/network-restrictions).
- Complying with encryption requirements in the HIPAA Security Rule. Data is encrypted at rest and in transit by Supabase. You can consider encrypting the data at your application layer.
- Not using [Edge functions](/docs/guides/functions) to process PHI.
- Not storing PHI in [public Storage buckets](/docs/guides/storage/buckets/fundamentals#public-buckets).
- Not [transferring projects](/docs/guides/platform/project-transfer) to a non-HIPAA organization.
@@ -155,7 +155,7 @@ Deno.serve((req) => {
return new Response('Auth token not provided', { status: 403 })
}
const { error, data } = await supabase.auth.getUser(jwt)
const { error, data } = await supabase.auth.getClaims()
if (error) {
console.error(error)
@@ -210,8 +210,7 @@ Deno.serve((req) => {
return new Response('Auth token not provided', { status: 403 })
}
const { error, data } = await supabase.auth.getUser(jwt)
const { error, data } = await supabase.auth.getClaims()
if (error) {
console.error(error)
return new Response('Invalid token provided', { status: 403 })
@@ -28,7 +28,7 @@ Our friends at [Basejump](https://usebasejump.com/) have created a useful set of
### Running database tests in CI
Use our GitHub Action to [automate your database tests](/docs/guides/cli/github-action/testing#testing-your-database).
Use our GitHub Action to [automate your database tests](/docs/guides/deployment/ci/testing).
## Testing your Edge Functions
@@ -1,18 +1,19 @@
---
title: Available regions
subtitle: Spin up Supabase projects in our global regions
---
The following regions are available for your Supabase projects.
Each Supabase project is deployed to one primary region. Choose the location closest to your users for the best performance.
## Smart region selection
## General regions
Smart Region Selection is a feature that allows you to select from a list of broader regions. Optimizations are made to ensure that the selected region has compute capacity availability.
For most projects, we recommend choosing a general region. Supabase will deploy your project to an available AWS region within that area based on current infrastructure capacity.
<SmartRegionsList />
Currently, smart region selection is not available for read replicas and project-related operations via the management API.
Note: General regions aren’t yet supported for read replicas or management via the API.
## AWS
## Specific regions
If you prefer, you can choose an exact AWS region for your project.
<RegionsList />
@@ -6,6 +6,27 @@ description: 'Send low-latency messages using the client libs, REST, or your Dat
You can use Realtime Broadcast to send low-latency messages between users. Messages can be sent using the client libraries, REST APIs, or directly from your database.
## How Broadcast works
The way Broadcast works changes based on the channel you are using:
- From REST API will receive an HTTP request which then will be sent via WebSocket to connected clients
- From Client libraries we have an established WebSocket connection and we use that to send a message to the server which then will be sent via WebSocket to connected clients
- From Database we add a new entry to `realtime.messages` where we have logical replication set to listen for changes which then will be sent via WebSocket to connected clients
<Admonition type="note">
The public flag (the last argument in `realtime.send(payload, event, topic, is_private))` only affects who can subscribe to the topic not who can read messages from the database.
- Public (false) → Anyone can subscribe to that topic without authentication
- Private (true) → Only authenticated clients can subscribe to that topic
However, regardless of whether it's public or private, the Realtime service connects to your database as the authenticated Supabase Admin role.
</Admonition>
For Authorization we do insert a message and try to read it and then we it back as way to verify that the RLS policies set by the user are being respected by the user joining the channel but this messages won't be sent to the user. You can read more about it in the [Authorization](/docs/guides/realtime/authorization) docs
## Subscribe to messages
You can use the Supabase client libraries to receive Broadcast messages.
@@ -384,6 +405,7 @@ You can use the Supabase client libraries to send Broadcast messages.
</$Show>
</Tabs>
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Broadcast from the Database
<Admonition type="caution">
@@ -411,6 +433,14 @@ select
);
```
<Admonition type="note">
The realtime.send function in the database includes a flag that determines whether the broadcast is private or public, and client channels also have the same configuration. For broadcasts to work correctly, these settings must match a public broadcast will only reach public channels, and a private broadcast will only reach private ones.
By default, all database broadcasts are private, meaning clients must authenticate to receive them. If the database sends a public message but the client subscribes to a private channel, the message won't be delivered since private channels only accept signed, authenticated messages.
</Admonition>
It's a common use case to broadcast messages when a record is created, updated, or deleted. We provide a helper function specific to this use case, `realtime.broadcast_changes()`. For more details, check out the [Subscribing to Database Changes](/docs/guides/realtime/subscribing-to-database-changes) guide.
### Broadcast using the REST API
@@ -1,5 +1,5 @@
---
title = "Converted GitHub account to organization - Lost Supabase account access"
title = "Converted GitHub account to organization / GitHub Account was deleted - Lost Supabase account access"
github_url = "https://github.com/orgs/supabase/discussions/21101"
date_created = "2024-02-08T01:39:25+00:00"
topics = [ "auth" ]
@@ -9,4 +9,6 @@ database_id = "94c6e33b-cc05-49b3-b010-7480f8dde843"
[Converting](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-your-personal-account/converting-a-user-into-an-organization) your GitHub account to a [GitHub organization](https://docs.github.com/en/get-started/learning-about-github/types-of-github-accounts#organization-accounts) will cause you to lose access to all other accounts using GitHub sign-ins.
However, Supabase can help you transfer ownership of an existing organization on your account by inviting a new owner to your organization. This new user will need to have a different email from the previous one, and you will need to email support@supabase.com with that new email as well to confirm that you own the email address.
If possible, create a new GitHub account using the **same email** as the previous GitHub account that was turned into a Organization or that was deleted. Once you sign in with that newly created account, you should have access to the old account. You can then invite any new account to have access to the Organization and Projects as needed.
If that is not possible, Supabase can help you transfer ownership of an existing organization on your account by inviting a new owner to your organization. This new user will need to have a different email from the previous one, and you will need to email support@supabase.com with that new email as well to confirm that you own the email address.
@@ -1,8 +1,8 @@
---
title = "Dashboard errors when managing users"
title = "Errors when creating / updating / deleting users"
github_url = "https://github.com/orgs/supabase/discussions/21247"
date_created = "2024-02-14T04:26:37+00:00"
topics = [ "auth", "studio" ]
topics = [ "auth", "database", "studio" ]
keywords = [ "users" ]
database_id = "c5d8260b-c17a-4f6c-ab20-801f953f6c94"
@@ -13,49 +13,108 @@ sdk = [ "auth-signup" ]
message = "Failed to send magic link: failed to make magiclink request: Error sending magic link"
[[errors]]
message = "Failed to delete user: Database error deleting user"
[[errors]]
message = "Failed to create user: Database error creating new user"
http_status_code = 500
code = "unexpected_failure"
message = "Database error saving new user"
[[errors]]
http_status_code = 500
message = "Database error saving new user"
code = "unexpected_failure"
message = "Failed to create user"
[[errors]]
http_status_code = 500
code = "unexpected_failure"
message = "Database error creating new user"
[[errors]]
http_status_code = 500
code = "unexpected_failure"
message = "Failed to delete user"
[[errors]]
http_status_code = 500
code = "unexpected_failure"
message = "Database error deleting user"
[[errors]]
http_status_code = 500
code = "unexpected_failure"
message = "Error updating user"
[[errors]]
http_status_code = 500
code = "unexpected_failure"
message = "Database error updating user"
---
## PROBLEM
These error are normal a side effect of issues in your custom user management logic. This can cause errors that return HTTP `500` status codes with a of code `unexpected_failure` and one of the following error messages
### Receiving the following or _similar_ error messages in the Dashboard when managing users.
- Failed to create user: Database error creating new user
- Failed to update user: Error updating user
- Failed to delete user: Database error deleting user
- Database error updating user
- Database error saving new user
> ... Database error
> ... Error sending
## Debugging this error
<img
width="243"
src="https://github.com/supabase/supabase/assets/91111415/91732e7d-0e83-4bc8-8ce3-57ebd871d981"
/>
<img
width="271"
src="https://github.com/supabase/supabase/assets/91111415/30f4cd9f-3736-40ae-8606-2f060cfac0d0"
/>
<img
width="271"
src="https://github.com/supabase/supabase/assets/91111415/50961019-b461-44cb-9e4b-f1361aa3ad5d"
/>
- [Auth logs](/dashboard/project/_/logs/auth-logs): as the error relates to [Auth](/docs/guides/auth) users
- [Postgres logs](/dashboard/project/_/logs/postgres-logs): for raw error logs related to database
### Or, receiving a comparable 500 error from the Auth REST API:
## Common causes of this error:
> Database error ...
- Trigger/trigger function setup on the `auth.users` table
- A constraint on the `auth.users` table which isn't being met
- You are using Prisma and it has broken all the permissions on the `auth.users` table
<img
width="496"
alt="Screenshot 2024-02-13 at 10 59 17 PM"
src="https://github.com/supabase/supabase/assets/91111415/cd151dda-5160-415c-925c-cea332887641"
/>
## Example error messages
## SOLUTION 1 (trigger related)
Use the hints provided in the error message to fix issues in your custom user management logic.
Check if the auth schema contains any triggers in the [Dashboard's trigger section](/dashboard/project/_/database/triggers). Remove all triggers by dropping their functions with a CASCADE modifier:
**Trigger/trigger function related error messages** - [Solution for trigger related issues](#solution-trigger)
```
"error":"error update user`s last_sign_in field: ERROR: permission denied for table profiles (SQLSTATE 42501)"
```
**Constraint related error message** - [Solution for constraint related issues](#solution-constraint)
```
ERROR: 23503: update or delete on table "users" violates foreign key constraint "profiles_id_fkey" on table "profiles"
DETAIL: Key (id)=(7428a53c-75b7-4531-9ae9-1567d9c4ac0a) is still referenced from table "profiles".
```
**Missing column**
```
ERROR: column \"updated_at\" of relation \"profiles\" does not exist (SQLSTATE 42703)
```
**Broken search path / incorrect name** - [42P01 related solution](docs/guides/troubleshooting/resolving-42p01-relation-does-not-exist-error-W4_9-V)
```
failed to close prepared statement: ERROR: current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02): ERROR: relation \"public.profiles\" does not exist (SQLSTATE 42P01)
```
---
## Solution for constraint related issues [#solution-constraint]
- Check for foreign/primary key relationship between the `auth.users` table and another table
- Then ALTER the [behavior](https://stackoverflow.com/questions/5383612/setting-up-table-relations-what-do-cascade-set-null-and-restrict-do) of the relationship and recreate it with a less [restrictive constraint](https://stackoverflow.com/questions/3359329/how-to-change-the-foreign-key-referential-action-behavior).
- If this is related to deleting records, review the [Cascade Deletes doc](/docs/guides/database/postgres/cascade-deletes) for possible approaches (e.g. using `CASCADE` / `SET NULL`)
---
## Solution for trigger related issues [#solution-trigger]
Supabase Auth uses your project's database to store user data. It relies on the `auth` schema, and Supabase restricts access to the `auth` schema to prevent unintended custom changes that could break the functionality of the Auth service.
Check if the `auth` schema contains any triggers in the [Dashboard's trigger section](/dashboard/project/_/database/triggers?schema=auth).
1. Identify related functions using `security invoker` from the [Dashboard's function section](/dashboard/project/_/database/functions)
2. Remove all triggers by dropping their functions with a CASCADE modifier shown below (This command still works because the `postgres` role has the ownership of the function, and the `CASCADE` clause will drop the trigger indirectly.)
```sql
DROP FUNCTION <function name>() CASCADE;
@@ -64,8 +123,10 @@ DROP FUNCTION <function name>() CASCADE;
-- DROP TRIGGER <trigger_name> on auth.<table_name>;
```
Then recreate the functions with a [security definer](/docs/guides/database/functions#security-definer-vs-invoker) modifier before recreating the triggers.
3. Recreate the functions with a [security definer](/docs/guides/database/functions#security-definer-vs-invoker) modifier
4. Recreate the triggers
**Example function and trigger using security definer**
The [SQL Editor](/dashboard/project/_/sql/) contains a template for [User Management](/dashboard/project/_/sql/quickstarts). Within it, there is a working example of how to setup triggers with security definer that may be worth referencing:
```sql
@@ -81,7 +142,9 @@ create table profiles (
);
create function public.handle_new_user()
returns trigger as $$
returns trigger
set search_path = ''
as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
@@ -94,12 +157,8 @@ create trigger on_auth_user_created
for each row execute procedure public.handle_new_user();
```
### EXPLANATION
### Explanation
One of the most common design patterns in Supabase is to add a trigger to the `auth.users` table. The database role managing authentication (supabase_auth_admin) only has the necessary permissions it needs to perform its duties. So, when a trigger operated by the supabase_auth_admin interacts outside the auth schema, it causes a permission error.
One of the most common design patterns in Supabase is to add a trigger to the `auth.users` table. The database role managing authentication (`supabase_auth_admin`) only has the necessary permissions it needs to perform its duties. So, when a trigger operated by the `supabase_auth_admin` interacts outside the auth schema, it causes a permission error.
A security definer function retains the privileges of the database user that created it. As long as it is the `postgres` role, your auth triggers should be able to engage with outside tables.
## SOLUTION 2 (constraint related)
If you did not create a trigger, check if you created a foreign/primary key relationship between the auth.users table and another table. If you did, then ALTER the [behavior](https://stackoverflow.com/questions/5383612/setting-up-table-relations-what-do-cascade-set-null-and-restrict-do) of the relationship and recreate it with a less [restrictive constraint](https://stackoverflow.com/questions/3359329/how-to-change-the-foreign-key-referential-action-behavior).
@@ -0,0 +1,42 @@
---
title = "Edge Function deploy failed due to an internal error"
topics = [ "functions" ]
keywords = [ "fail", "deploy" ]
[[errors]]
http_status_code = 500
message="Function deploy failed due to an internal error"
[[errors]]
http_status_code = 500
message="unexpected deploy status 500"
---
When encountering the `Function deploy failed due to an internal error`, try deploy the function via the [Supabase CLI](/docs/guides/local-development) with the `--debug` flag to gather extra information to identify the possible cause.
You can also try [alternative bundle methods](/docs/reference/cli/supabase-functions-deploy):
- `--use-api`: Use Management API to bundle functions.
- `--use-docker`: Use Docker to bundle functions.
If you're not able to run the CLI or cannot identify the cause, [open a support ticket](/dashboard/support/new).
**Example error**
```
$ supabase functions deploy FUNCTION_NAME --project-ref $PROJECT_ID
...
unexpected deploy status 500: {"message":"Function deploy failed due to an internal error"}
```
**Example output with `--debug`**
- From the details we can see it is exceeding the 20MB [Edge Function size limit](/docs/guides/functions/limits)
```
$ supabase functions deploy FUNCTION_NAME --project-ref $PROJECT_ID --debug
...
Deploying Function: FUNCTION_NAME (script size: 62.61MB)
...
unexpected update function status 413: {"message":"request entity too large"}
```
@@ -2,7 +2,6 @@
title = "Edge Function shutdown reasons explained"
topics = [ "functions" ]
keywords = [ "shutdown", "termination", "event loop", "wall clock", "cpu time", "memory", "early drop" ]
database_id = ""
[[errors]]
http_status_code = 546
@@ -0,0 +1,31 @@
---
title = "Email template not updating"
topics = [ "auth", "studio" ]
keywords = [ "email", "template" ]
[[errors]]
code = "templatemailer_template_body_parse_error"
---
If your email templates are not updated, check [Auth Logs](/dashboard/project/_/logs/auth-logs) for any errors when the email is sent. When there are errors, we will use a fallback template with valid syntax so it will not show any of your customizations.
Typical errors relate to invalid syntax involving variables. Refer to [the Email Templates docs](/docs/guides/auth/auth-email-templates#terminology) for the supported variables and syntax.
**Example error message**
```
"event": "templatemailer_template_body_parse_error",
"msg": "templatemailer: template type \"recovery\": template: https://api.supabase.com/platform/auth/PROJECT_ID/templates/recovery:2: function \"default\" not defined",
```
The error shows an issue with `default` which is used in the invalid syntax below.
```
{{ .Data.display_name | default: "test" }}
```
The final correct syntax after removing invalid syntax.
```
{{ .Data.display_name }}
```
@@ -9,4 +9,4 @@ database_id = "e51bedf0-cdc4-4880-865b-e39c06dfe497"
The `Max-Age` or `Expires` cookie parameters only control whether the browser sends the value to the server. Since a refresh token represents the long-lived authentication session of the user on that browser, setting a short `Max-Age` or `Expires` parameter on the cookies only results in a degraded user experience.
The only way to ensure that a user has logged out or their session has ended is to get the user's details with `getUser()`.
The only way to ensure that a user has logged out or their session has ended is to get the user's details with `getUser()`. The `getClaims()` method only checks local JWT validation (signature and expiration), but it doesn't verify with the auth server whether the session is still valid or if the user has logged out server-side.
+1 -1
View File
@@ -23,7 +23,7 @@ All API requests must be authenticated and made over HTTPS.
## Rate limits
The rate limit for Management API is 60 requests per one minute per user, and applies cumulatively across all requests made with your personal access tokens.
The rate limit for Management API is 120 requests per one minute per user, and applies cumulatively across all requests made with your personal access tokens.
If you exceed this limit, all Management API calls for the next minute will be blocked, resulting in a HTTP 429 response.
+1 -1
View File
@@ -40,7 +40,7 @@ hideTitle: true
## Rate limits
The rate limit for Management API is 60 requests per one minute per user, and applies cumulatively across all requests made with your personal access tokens.
The rate limit for Management API is 120 requests per one minute per user, and applies cumulatively across all requests made with your personal access tokens.
If you exceed this limit, all Management API calls for the next minute will be blocked, resulting in a HTTP 429 response.
@@ -173,6 +173,14 @@ The current Postgres Ingestion backend isn't optimized for production usage. We
**We recommend using the BigQuery backend for production environments as it offers better scaling and querying/debugging experiences.**
### Rotate Encryption Keys Regularly
The Logflare server uses the a Base64 encryption key set on the `LOGFLARE_DB_ENCRYPTION_KEY` environment variable to perform encryption at rest for sensitive database columns.
To perform encryption key rotation, move the retired key to the `LOGFLARE_DB_ENCRYPTION_KEY_RETIRED` environment variable, and replace the `LOGFLARE_DB_ENCRYPTION_KEY` environement variable with the new key. Perform a server restart and check `info` logs for the migration to be detected and performed.
Once migration is complete, you can safely remove the retired key.
</RefSubLayout.Details>
<RefSubLayout.Examples>
@@ -258,37 +258,37 @@ async function updateChecksumIfNeeded(entry) {
/**
* Converts relative links to absolute URLs for GitHub discussions using MDAST
* @param {string} content - The markdown content to process
* @param {string} content - The markdown content to process (already stripped of JSX)
*/
function rewriteRelativeLinks(content) {
const baseUrl = 'https://supabase.com'
// Parse the markdown to AST
const mdast = fromMarkdown(content, {
extensions: [gfm(), mdxjs()],
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
})
// Walk the tree and modify link nodes
/**
* @param {import('mdast').Root|import('mdast').Content} node
*/
/**
* @param {import('mdast').Root|import('mdast').Content} node
*/
function visitNode(node) {
if (node.type === 'link' && node.url && node.url.startsWith('/')) {
// Convert relative URL to absolute
node.url = `${baseUrl}${node.url}`
}
// Recursively visit children
if ('children' in node) {
node.children.forEach(visitNode)
}
}
visitNode(mdast)
// Convert back to markdown
return toMarkdown(mdast, { extensions: [gfmToMarkdown(), mdxToMarkdown()] })
return toMarkdown(mdast, { extensions: [gfmToMarkdown()] })
}
/**
@@ -42109,7 +42109,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Generates email links and OTPs to be sent via a custom email provider."
"shortText": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality."
}
},
"@supabase/auth-js.GoTrueAdminApi.getUserById": {
+1 -1
View File
@@ -294,7 +294,7 @@ export function McpConfigPanel() {
<Admonition type="note" title="Authentication" className="mt-3">
<p>
{
"Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions."
"Some MCP clients will automatically prompt you to login during setup, while others may require manual authentication steps. Either authentication method will open a browser window where you can login to your Supabase account and grant organization access to the MCP client. In the future, we'll offer more fine grain control over these permissions."
}
</p>
<p>
+2
View File
@@ -74,6 +74,7 @@ Ignacio Dobronich
Illia Basalaiev
Inian P
Ivan Vasilov
Jason Farber
Jean-Paul Argudo
Jeff Smick
Jenny Kibiri
@@ -135,6 +136,7 @@ Sam Meech-Ward
Sam Rome
Sam Rose
Sean Oliver
Sean Thompson
Sergio Cioban Filho
Sreyas Udayavarman
Stanislav M
+1 -1
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 2.54.10
version: 2.54.11
title: Supabase CLI
language: sh
source: https://github.com/supabase/cli
@@ -65068,7 +65068,7 @@
"summary": [
{
"kind": "text",
"text": "Generates email links and OTPs to be sent via a custom email provider."
"text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality."
}
]
},
@@ -6309,7 +6309,7 @@
"kindString": "Call signature",
"flags": {},
"comment": {
"shortText": "Generates email links and OTPs to be sent via a custom email provider."
"shortText": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality."
},
"parameters": [
{
@@ -4413,7 +4413,7 @@
"summary": [
{
"kind": "text",
"text": "Generates email links and OTPs to be sent via a custom email provider."
"text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality."
}
]
},
@@ -4413,7 +4413,7 @@
"summary": [
{
"kind": "text",
"text": "Generates email links and OTPs to be sent via a custom email provider."
"text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality."
}
]
},
+1 -1
View File
@@ -685,7 +685,7 @@ functions:
- id: generate-link
title: 'generateLink()'
notes: |
Generates email links and OTPs to be sent via a custom email provider.
Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.
- The following types can be passed into `generateLink()`: `signup`, `magiclink`, `invite`, `recovery`, `emailChangeCurrent`, `emailChangeNew`, `phoneChange`.
- `generateLink()` only generates the email link for `email_change_email` if the "Secure email change" setting is enabled under the "Email" provider in your Supabase project.
- `generateLink()` handles the creation of the user for `signup`, `invite` and `magiclink`.
+1 -1
View File
@@ -2428,7 +2428,7 @@ functions:
- id: generate-link
title: 'generateLink()'
notes: |
Generates email links and OTPs to be sent via a custom email provider.
Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.
- The following types can be passed into `generateLink()`: `signup`, `magiclink`, `invite`, `recovery`, `emailChangeCurrent`, `emailChangeNew`, `phoneChange`.
- `generateLink()` only generates the email link for `email_change_email` if the "Secure email change" setting is enabled under the "Email" provider in your Supabase project.
- `generateLink()` handles the creation of the user for `signup`, `invite` and `magiclink`.
+1 -1
View File
@@ -2904,7 +2904,7 @@ functions:
title: 'generateLink()'
$ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink'
notes: |
Generates email links and OTPs to be sent via a custom email provider.
Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.
examples:
- id: generate-a-signup-link
name: Generate a signup link
+1 -1
View File
@@ -4186,7 +4186,7 @@ functions:
title: 'generateLink()'
$ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink'
notes: |
Generates email links and OTPs to be sent via a custom email provider.
Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.
params:
- name: type
isOptional: false
+1 -1
View File
@@ -4271,7 +4271,7 @@ functions:
title: 'generateLink()'
$ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink'
notes: |
Generates email links and OTPs to be sent via a custom email provider.
Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.
params:
- name: type
isOptional: false
@@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import dayjs from 'dayjs'
import { ExternalLink } from 'lucide-react'
import { useState } from 'react'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { useForm, type SubmitHandler } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -214,16 +214,13 @@ export const NewAccessTokenDialog = ({
selectsRange={false}
triggerButtonSize="small"
contentSide="top"
to={customExpiryDate?.date}
minDate={new Date()}
maxDate={dayjs().add(1, 'year').toDate()}
onChange={(date) => {
if (date.to) handleCustomDateChange({ date: date.to })
}}
>
{customExpiryDate
? `${dayjs(customExpiryDate.date).format('DD MMM, HH:mm')}`
: 'Select date'}
</DatePicker>
/>
)}
</div>
{field.value === NON_EXPIRING_TOKEN_VALUE && (
@@ -8,13 +8,6 @@ export const FEATURE_PREVIEWS = [
isNew: true,
isPlatformOnly: true,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_NEW_STORAGE_UI,
name: 'New Storage interface',
discussionsUrl: undefined,
isNew: true,
isPlatformOnly: false,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
name: 'New Logs interface',
@@ -110,11 +110,6 @@ export const useIsAdvisorRulesEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES]
}
export const useIsNewStorageUIEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_NEW_STORAGE_UI]
}
export const useIsSecurityNotificationsEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SECURITY_NOTIFICATIONS]
@@ -126,7 +121,6 @@ export const useFeaturePreviewModal = () => {
const gitlessBranchingEnabled = useFlag('gitlessBranching')
const advisorRulesEnabled = useFlag('advisorRules')
const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
const isNewStorageUIAvailable = useFlag('storageAnalyticsVector')
const isSecurityNotificationsAvailable = useFlag('securityNotifications')
const selectedFeatureKeyFromQuery = featurePreviewModal?.trim() ?? null
@@ -142,8 +136,6 @@ export const useFeaturePreviewModal = () => {
return advisorRulesEnabled
case 'supabase-ui-preview-unified-logs':
return isUnifiedLogsPreviewAvailable
case 'new-storage-ui':
return isNewStorageUIAvailable
case 'security-notifications':
return isSecurityNotificationsAvailable
default:
@@ -154,7 +146,6 @@ export const useFeaturePreviewModal = () => {
gitlessBranchingEnabled,
advisorRulesEnabled,
isUnifiedLogsPreviewAvailable,
isNewStorageUIAvailable,
isSecurityNotificationsAvailable,
]
)
@@ -14,9 +14,8 @@ import { CLSPreview } from './CLSPreview'
import { FEATURE_PREVIEWS } from './FeaturePreview.constants'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { InlineEditorPreview } from './InlineEditorPreview'
import { NewStorageUIPreview } from './NewStorageUIPreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
import { SecurityNotificationsPreview } from './SecurityNotificationsPreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[key: string]: ReactNode
@@ -27,7 +26,6 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: <APISidePanelPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: <CLSPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: <UnifiedLogsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_NEW_STORAGE_UI]: <NewStorageUIPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SECURITY_NOTIFICATIONS]: <SecurityNotificationsPreview />,
}
@@ -1,42 +0,0 @@
import Image from 'next/image'
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { BASE_PATH } from 'lib/constants'
import { useIsNewStorageUIEnabled } from './FeaturePreviewContext'
export const NewStorageUIPreview = () => {
const { ref } = useParams()
const isStorageV2 = useIsNewStorageUIEnabled()
return (
<div className="text-sm text-foreground-light">
<p className=" mb-4">
Experience our enhanced{' '}
<InlineLink href={`/project/${ref ?? '_'}/storage/${isStorageV2 ? 'files' : 'buckets'}`}>
Storage interface
</InlineLink>{' '}
with support for analytics and vector bucket types.
</p>
<Image
alt="new-storage-preview"
src={`${BASE_PATH}/img/previews/new-storage-preview.png`}
width={1160}
height={658}
className="rounded border mb-4"
/>
<div className="space-y-2 !mt-4">
<p className="text-foreground">Enabling this preview will:</p>
<ul className="list-disc pl-6 space-y-1">
<li>Move Storage buckets from the sidebar into the main content area</li>
<li>Change the role of the sidebar to a bucket type selector</li>
<li>Nest settings and policies under their respective bucket types</li>
</ul>
<p>
These changes are necessary to support incoming analytics and vector bucket types. File
storage will remain the default, and be shown by default when entering Storage.
</p>
</div>
</div>
)
}
@@ -6,7 +6,7 @@ import { PropsWithChildren, ReactNode } from 'react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import type { Branch } from 'data/branches/branches-query'
import { BASE_PATH } from 'lib/constants'
import Link from 'next/link'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { WorkflowLogs } from './WorkflowLogs'
@@ -25,9 +25,7 @@ export const BranchManagementSection = ({
<div className="bg-surface-100 shadow-sm flex justify-between items-center px-4 py-3 rounded-t-lg text-xs font-mono uppercase">
{typeof header === 'string' ? <span>{header}</span> : header}
</div>
<div className="bg-surface border-t shadow-sm rounded-b-lg text-sm divide-y px-4">
{children}
</div>
<div className="bg-surface border-t shadow-sm rounded-b-lg text-sm divide-y">{children}</div>
{footer !== undefined && <div className="bg-surface-100 px-6 py-1 border-t">{footer}</div>}
</div>
)
@@ -86,14 +84,6 @@ export const BranchRow = ({
const navigateUrl = rowLink ?? `/project/${branch.project_ref}`
const handleRowClick = () => {
if (external) {
window.open(`${BASE_PATH}/${navigateUrl}`, '_blank', 'noopener noreferrer')
} else {
router.push(navigateUrl)
}
}
return (
<div className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface-100">
<div className="flex items-center gap-x-3">
@@ -114,10 +104,15 @@ export const BranchRow = ({
</ButtonTooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-pointer" onClick={handleRowClick}>
<TooltipTrigger>
<Link
target={external ? '_blank' : '_self'}
rel={external ? 'noopener noreferrer' : undefined}
href={navigateUrl}
className="flex items-center"
>
{label || branch.name}
</div>
</Link>
</TooltipTrigger>
{((page === 'branches' && !branch.is_default) || page === 'merge-requests') && (
<TooltipContent side="bottom">
@@ -1,12 +1,11 @@
import { format } from 'date-fns'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import { HelpCircle } from 'lucide-react'
import { useMemo, useState } from 'react'
import DatePicker from 'react-datepicker'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import InformationBox from 'components/ui/InformationBox'
import { Calendar, cn } from 'ui'
import { Timezone } from './PITR.types'
import {
constrainDateToRange,
@@ -41,6 +40,8 @@ export function PITRForm({
const latestAvailableBackup = dayjs
.unix(latestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const earliestAvailableBackupAsDate = earliestAvailableBackup.toDate()
const latestAvailableBackupAsDate = latestAvailableBackup.toDate()
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
@@ -125,51 +126,25 @@ export function PITRForm({
>
<div className="flex justify-between px-4 md:px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
<Calendar
mode="single"
required={true}
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
onSelect={onUpdateDate}
defaultMonth={latestAvailableBackupAsDate}
startMonth={earliestAvailableBackupAsDate}
endMonth={latestAvailableBackupAsDate}
disabled={[
{ before: earliestAvailableBackupAsDate },
{ after: latestAvailableBackupAsDate },
]}
classNames={{
day: cn(
'[&:not(:has(:disabled))]:border [&:not(:has(:disabled))]:border-stronger [&:not(:last-child)]:border-r-0 [&:not(:has(:disabled))]:bg-overlay-hover',
'rounded-none'
),
selected: '!bg-brand-500',
}}
/>
{availableDates.length > 1 && (
<div className="flex items-center space-x-2">
@@ -3,7 +3,6 @@ import { useMemo, useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from 'components/ui/InlineLink'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -124,7 +123,6 @@ export const AnalyticsBucketFields = ({
const [showSecretAccessKey, setShowSecretAccessKey] = useState(false)
const { ref: projectRef } = useParams()
const isStorageV2 = useIsNewStorageUIEnabled()
const { data: project } = useSelectedProjectQuery()
const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
@@ -447,13 +445,7 @@ export const AnalyticsBucketFields = ({
<p className="!leading-normal">
Please select another key or create a new set, as this destination will not work
otherwise. S3 access keys can be managed in your{' '}
<InlineLink
href={
isStorageV2
? `/project/${projectRef}/storage/files/settings`
: `/project/${projectRef}/storage/settings`
}
>
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
storage settings
</InlineLink>
</p>
@@ -466,13 +458,7 @@ export const AnalyticsBucketFields = ({
<Admonition type="default" title="A new set of S3 access keys will be created">
<p className="!leading-normal">
S3 access keys can be managed in your{' '}
<InlineLink
href={
isStorageV2
? `/project/${projectRef}/storage/files/settings`
: `/project/${projectRef}/storage/settings`
}
>
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
storage settings
</InlineLink>
.
@@ -412,7 +412,7 @@ export const TriggerSheet = ({
<FormItemLayout layout="vertical" className="px-5">
<FormControl_Shadcn_>
<div className="flex flex-col gap-y-2">
<p className="text-smn">Function to trigger</p>
<p className="text-sm">Function to trigger</p>
{function_name.length === 0 ? (
<button
type="button"
@@ -1,9 +1,12 @@
import { PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { includes, sortBy } from 'lodash'
import { Check, Copy, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useParams } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -24,7 +27,6 @@ import {
TooltipTrigger,
} from 'ui'
import { generateTriggerCreateSQL } from './TriggerList.utils'
import { PostgresTrigger } from '@supabase/postgres-meta'
interface TriggerListProps {
schema: string
@@ -35,7 +37,7 @@ interface TriggerListProps {
deleteTrigger: (trigger: PostgresTrigger) => void
}
const TriggerList = ({
export const TriggerList = ({
schema,
filterString,
isLocked,
@@ -43,10 +45,16 @@ const TriggerList = ({
duplicateTrigger,
deleteTrigger,
}: TriggerListProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { can: canUpdateTriggers } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'triggers'
)
const { data: triggers } = useDatabaseTriggersQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -56,15 +64,10 @@ const TriggerList = ({
includes(x.name.toLowerCase(), filterString.toLowerCase()) ||
(x.function_name && includes(x.function_name.toLowerCase(), filterString.toLowerCase()))
)
const _triggers = sortBy(
filteredTriggers.filter((x) => x.schema == schema),
(trigger) => trigger.name.toLocaleLowerCase()
)
const { can: canUpdateTriggers } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'triggers'
)
if (_triggers.length === 0 && filterString.length === 0) {
return (
@@ -94,7 +97,7 @@ const TriggerList = ({
return (
<>
{_triggers.map((x: any) => (
{_triggers.map((x) => (
<TableRow key={x.id}>
<TableCell className="space-x-2">
<Tooltip>
@@ -111,15 +114,33 @@ const TriggerList = ({
</TableCell>
<TableCell className="break-all">
<p title={x.table} className="truncate">
{x.table}
</p>
{x.table_id ? (
<InlineLink
title={x.table}
href={`/project/${projectRef}/editor/${x.table_id}`}
className="truncate block max-w-40"
>
{x.table}
</InlineLink>
) : (
<p title={x.table} className="truncate">
{x.table}
</p>
)}
</TableCell>
<TableCell className="space-x-2">
<p title={x.function_name} className="truncate">
{x.function_name}
</p>
{x.function_name ? (
<InlineLink
title={x.function_name}
href={`/project/${projectRef}/database/functions?search=${x.function_name}&schema=${x.function_schema}`}
className="truncate block max-w-40"
>
{x.function_name}
</InlineLink>
) : (
<p className="truncate text-foreground-light">-</p>
)}
</TableCell>
<TableCell>
@@ -176,7 +197,7 @@ const TriggerList = ({
const sql = generateTriggerCreateSQL(x)
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({
name: `Update trigger ${X.name}`,
name: `Update trigger ${x.name}`,
initialInput: `Update this trigger which exists on the ${x.schema}.${x.table} table to...`,
suggestions: {
title:
@@ -237,5 +258,3 @@ const TriggerList = ({
</>
)
}
export default TriggerList
@@ -4,13 +4,11 @@ import { noop } from 'lodash'
import { DatabaseZap, FunctionSquare, Plus, Search, Shield } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -21,10 +19,7 @@ import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import {
AiIconAnimation,
Button,
Card,
CardContent,
cn,
Input,
Table,
TableBody,
@@ -33,8 +28,7 @@ import {
TableRow,
} from 'ui'
import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
import TriggerList from './TriggerList'
import Link from 'next/link'
import { TriggerList } from './TriggerList'
interface TriggersListProps {
createTrigger: () => void
@@ -43,7 +37,7 @@ interface TriggersListProps {
deleteTrigger: (trigger: PostgresTrigger) => void
}
const TriggersList = ({
export const TriggersList = ({
createTrigger = noop,
editTrigger = noop,
duplicateTrigger = noop,
@@ -248,5 +242,3 @@ const TriggersList = ({
</div>
)
}
export default TriggersList
@@ -26,9 +26,9 @@ export const IntegrationLoadingCard = () => {
<ShimmeringLoader className="w-full h-full bg-white border rounded-md" />
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="flex-col justify-start items-start gap-y-1 flex">
<ShimmeringLoader className="w-20 py-2.5" />
<ShimmeringLoader className="w-56 py-2.5" />
<div className="w-full flex-col justify-start items-start gap-y-1 flex">
<ShimmeringLoader className="w-3/4 py-2.5" />
<ShimmeringLoader className="w-full py-2.5" />
</div>
</div>
</div>
@@ -6,7 +6,7 @@ import { toast } from 'sonner'
import { z } from 'zod'
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { DocsButton } from 'components/ui/DocsButton'
import Link from 'next/link'
import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query'
import { DOCS_URL } from 'lib/constants'
import {
@@ -34,6 +34,7 @@ import {
SheetSection,
SheetTitle,
Switch,
cn,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
@@ -571,33 +572,36 @@ export function LogDrainDestinationSheetForm({
<div className="mt-auto">
<SheetSection
className={`border-t bg-background-alternative-200 mt-auto ${!IS_PLATFORM ? 'hidden' : ''}`}
className={cn(
`border-t bg-background-alternative-200 mt-auto py-1.5 ${!IS_PLATFORM ? 'hidden' : ''}`
)}
>
<FormItemLayout
isReactForm={false}
layout="horizontal"
label={
<div className="flex flex-col gap-y-2 text-foreground-light">
Additional drain cost
<DocsButton
abbrev={false}
className="w-min"
href={`${DOCS_URL}/guides/platform/log-drains`}
/>
</div>
}
>
<ul className="text-right text-foreground-light">
<li className="text-brand-link text-base" translate="no">
$60 per drain per month
</li>
<li translate="no">+ $0.20 per million events</li>
<li translate="no">+ $0.09 per GB egress</li>
</ul>
</FormItemLayout>
<ul className="text-right text-foreground-light divide-y divide-dashed text-sm">
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Additional drain cost</span>
<span className="text-foreground">$60 per month</span>
</li>
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Per million events</span>
<span>+$0.20</span>
</li>
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Per GB egress</span>
<span>+$0.09</span>
</li>
</ul>
</SheetSection>
<SheetFooter className="p-content !mt-0">
<SheetFooter className="p-content !mt-0 !justify-between !flex-row w-full items-center">
<span className="text-sm text-foreground-light">
<span>See full pricing breakdown</span>{' '}
<Link
href={`${DOCS_URL}/guides/platform/manage-your-usage/log-drains`}
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
>
here
</Link>
</span>
<Button form={FORM_ID} loading={isLoading} htmlType="submit" type="primary">
Save destination
</Button>
@@ -13,7 +13,7 @@ export type LogDrainType = components['schemas']['CreateBackendParamsOpenapi']['
export const LOG_DRAIN_TYPES = [
{
value: 'webhook',
name: 'HTTP Endpoint',
name: 'Custom Endpoint',
description: 'Forward logs as a POST request to a custom HTTP endpoint',
icon: <BracesIcon {...iconProps} />,
},
@@ -1,11 +1,8 @@
import { MoreHorizontal, Pencil, TrashIcon } from 'lucide-react'
import { useState } from 'react'
import React, { useState } from 'react'
import { toast } from 'sonner'
import { useFlag, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import CardButton from 'components/ui/CardButton'
import Panel from 'components/ui/Panel'
import { useDeleteLogDrainMutation } from 'data/log-drains/delete-log-drain-mutation'
import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
@@ -21,11 +18,15 @@ import {
TableHead,
TableHeader,
TableRow,
Card,
cn,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants'
import { LogDrainsEmpty } from './LogDrainsEmpty'
import { LogDrainsCard } from './LogDrainsCard'
import { VoteLink } from './VoteLink'
export function LogDrains({
onNewDrainClick,
@@ -81,19 +82,23 @@ export function LogDrains({
if (!isLoading && !hasLogDrains) {
return (
<div className="grid lg:grid-cols-2 gap-3">
{LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map((src) => (
<CardButton
key={src.value}
title={src.name}
description={src.description}
icon={src.icon}
onClick={() => {
onNewDrainClick(src.value)
}}
/>
))}
</div>
<>
<div className="grid lg:grid-cols-2 gap-4">
{LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map((src) => (
<LogDrainsCard
key={src.value}
title={src.name}
description={src.description}
icon={src.icon}
rightLabel="Additional $60"
onClick={() => {
onNewDrainClick(src.value)
}}
/>
))}
</div>
<VoteLink />
</>
)
}
@@ -103,63 +108,80 @@ export function LogDrains({
return (
<>
<Panel className="">
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[200px]">Name</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Destination</TableHead>
<TableHead className="w-96">Description</TableHead>
<TableHead className="w-48">Destination</TableHead>
<TableHead className="text-right">
<div className="sr-only">Actions</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logDrains?.map((drain) => (
<TableRow key={drain.id}>
<TableCell className="font-medium truncate max-w-72" title={drain.name}>
{drain.name}
</TableCell>
<TableCell
className="text-foreground-light truncate max-w-72"
title={drain.description}
>
{drain.description}
</TableCell>
<TableCell className="text-right font-mono">{drain.type}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1 opacity-50 hover:opacity-100 !bg-transparent"
icon={<MoreHorizontal />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[140px]" align="end">
<DropdownMenuItem
onClick={() => {
onUpdateDrainClick(drain)
}}
>
<Pencil className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLogDrain(drain)
setIsDeleteModalOpen(true)
}}
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{logDrains
?.slice()
.sort((a, b) => b.id - a.id)
.map((drain) => (
<TableRow key={drain.id}>
<TableCell className="font-medium truncate max-w-72" title={drain.name}>
{drain.name}
</TableCell>
<TableCell
className={cn(
'truncate max-w-96',
drain.description ? 'text-foreground-light' : 'text-foreground-muted'
)}
title={drain.description}
>
{drain.description || '-'}
</TableCell>
<TableCell className="text-foreground-light">
<div className="flex items-center gap-2">
{React.cloneElement(
LOG_DRAIN_TYPES.find((t) => t.value === drain.type)
?.icon as React.ReactElement,
{ width: 16, height: 16 }
)}
<span className="truncate max-w-40">
{LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.name}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1 opacity-50 hover:opacity-100 !bg-transparent flex-shrink-0"
icon={<MoreHorizontal />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[140px]" align="end">
<DropdownMenuItem
onClick={() => {
onUpdateDrainClick(drain)
}}
>
<Pencil className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLogDrain(drain)
setIsDeleteModalOpen(true)
}}
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
<ConfirmationModal
@@ -183,7 +205,7 @@ export function LogDrains({
</div>
</ConfirmationModal>
</Table>
</Panel>
</Card>
</>
)
}
@@ -0,0 +1,34 @@
import { Card } from 'ui'
interface LogDrainsCardProps {
title: string
description: string
rightLabel?: string
icon: React.ReactNode
onClick: () => void
}
export const LogDrainsCard = ({
title,
description,
rightLabel,
icon,
onClick,
}: LogDrainsCardProps) => {
return (
<button className="w-full h-full text-left" onClick={onClick}>
<Card className="p-6 cursor-pointer hover:bg-surface-200 hover:border-strong transition-colors h-48">
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between w-full">
<div>{icon}</div>
<div className="text-xs text-foreground-light">{rightLabel}</div>
</div>
<div className="flex flex-col gap-1">
<h1 className="text-base">{title}</h1>
<p className="text-sm text-foreground-light leading-relaxed">{description}</p>
</div>
</div>
</Card>
</button>
)
}
@@ -1,15 +1,14 @@
import { Button, Card, cn } from 'ui'
import Link from 'next/link'
import { AnimatedLogos } from './AnimatedLogos'
import Image from 'next/image'
import { BASE_PATH } from 'lib/constants'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { VoteLink } from './VoteLink'
export const LogDrainsEmpty = () => {
const items = [
{
step: 1,
title: 'Log drain pricing',
title: 'Pricing',
description:
'Log Drains are available as a project Add-On for all Team and Enterprise users. Each Log Drain costs $60 per month.',
label: 'See our pricing',
@@ -19,14 +18,14 @@ export const LogDrainsEmpty = () => {
step: 2,
title: 'Connect to your drain',
description:
'We offer support for multiple destinations including HTTPS, Datadog, Loki and Sentry.',
'We offer support for multiple destinations including Datadog, Loki, Sentry or a custom endpoint.',
label: 'Read our documentation',
link: 'https://supabase.com/docs/guides/telemetry/log-drains',
},
]
return (
<div className="flex grow h-full pt-16">
<div className="flex grow h-full">
<div className="flex grow items-center justify-center p-12 @container">
<div className="w-full max-w-4xl flex flex-col items-center gap-0">
<div className="text-center mb-12">
@@ -61,25 +60,7 @@ export const LogDrainsEmpty = () => {
</div>
))}
</Card>
<div className="flex items-center justify-center gap-1.5 text-sm">
<Image
className={cn('dark:invert text-muted')}
src={`${BASE_PATH}/img/icons/github-icon.svg`}
width={16}
height={16}
alt="GitHub icon"
/>
<p className="text-foreground-light">
Don't see your preferred drain?{' '}
<Link
href="https://github.com/orgs/supabase/discussions/28324?sort=top"
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
target="_blank"
>
Vote here
</Link>
</p>
</div>
<VoteLink />
</div>
</div>
</div>
@@ -0,0 +1,18 @@
import Link from 'next/link'
export const VoteLink = () => {
return (
<div className="flex items-center justify-center gap-1.5 text-sm">
<p className="text-foreground-light">
Don't see your preferred drain?{' '}
<Link
href="https://github.com/orgs/supabase/discussions/28324?sort=top"
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
target="_blank"
>
Vote here
</Link>
</p>
</div>
)
}
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { get, handleError } from '../../../../data/fetchers'
import type { ResponseError } from '../../../../types'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { useProfile } from '../../../../lib/profile'
import { cloudMarketplaceKeys } from './keys'
@@ -35,7 +35,7 @@ export const useCloudMarketplaceOnboardingInfoQuery = <TData = CloudMarketplaceO
{
enabled = true,
...options
}: UseQueryOptions<
}: UseCustomQueryOptions<
CloudMarketplaceOnboardingInfo,
CloudMarketplaceOnboardingInfoError,
TData
@@ -38,6 +38,14 @@ interface RegionSelectorProps {
// I tried using https://flagpack.xyz/docs/development/react/ but couldn't get it to render
// ^ can try again next time
// Map backend region names to user-friendly display names
const getDisplayNameForSmartRegion = (name: string): string => {
if (name === 'APAC') {
return 'Asia-Pacific'
}
return name
}
export const RegionSelector = ({
form,
instanceSize,
@@ -149,7 +157,9 @@ export const RegionSelector = ({
/>
)}
<span className="text-foreground">
{selectedRegion?.name ?? field.value}
{selectedRegion?.name
? getDisplayNameForSmartRegion(selectedRegion.name)
: field.value}
</span>
</div>
)}
@@ -159,7 +169,7 @@ export const RegionSelector = ({
{smartRegionEnabled && (
<>
<SelectGroup_Shadcn_>
<SelectLabel_Shadcn_>Smart Region Selection</SelectLabel_Shadcn_>
<SelectLabel_Shadcn_>General regions</SelectLabel_Shadcn_>
{smartRegions.map((value) => {
return (
<SelectItem_Shadcn_
@@ -174,7 +184,9 @@ export const RegionSelector = ({
className="w-5 rounded-sm"
src={`${BASE_PATH}/img/regions/${value.code}.svg`}
/>
<span className="text-foreground">{value.name}</span>
<span className="text-foreground">
{getDisplayNameForSmartRegion(value.name)}
</span>
</div>
<div>
@@ -194,7 +206,7 @@ export const RegionSelector = ({
)}
<SelectGroup_Shadcn_>
<SelectLabel_Shadcn_>All Regions</SelectLabel_Shadcn_>
<SelectLabel_Shadcn_>Specific regions</SelectLabel_Shadcn_>
{regionOptions.map((value) => {
return (
<SelectItem_Shadcn_
@@ -1,6 +1,6 @@
import { useDebounce } from '@uidotdev/usehooks'
import { Search, X } from 'lucide-react'
import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
import { ChangeEvent, ReactNode, useEffect, useState } from 'react'
import { FilterPopover } from 'components/ui/FilterPopover'
@@ -20,10 +20,12 @@ export const QueryPerformanceFilterBar = ({
const { data: project } = useSelectedProjectQuery()
const { sort, clearSort } = useQueryPerformanceSort()
const [{ search: searchQuery, roles: defaultFilterRoles }, setSearchParams] = useQueryStates({
search: parseAsString.withDefault(''),
roles: parseAsArrayOf(parseAsString).withDefault([]),
})
const [{ search: searchQuery, roles: defaultFilterRoles, minCalls }, setSearchParams] =
useQueryStates({
search: parseAsString.withDefault(''),
roles: parseAsArrayOf(parseAsString).withDefault([]),
minCalls: parseAsInteger,
})
const { data, isLoading: isLoadingRoles } = useDatabaseRolesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -34,16 +36,30 @@ export const QueryPerformanceFilterBar = ({
roles: defaultFilterRoles,
})
const [inputValue, setInputValue] = useState(searchQuery)
const [minCallsInput, setMinCallsInput] = useState(
typeof minCalls === 'number' && Number.isFinite(minCalls) && minCalls >= 0
? String(minCalls)
: ''
)
const debouncedInputValue = useDebounce(inputValue, 500)
const debouncedMinCalls = useDebounce(minCallsInput, 300)
const searchValue = inputValue.length === 0 ? inputValue : debouncedInputValue
const onSearchQueryChange = (value: string) => {
setSearchParams({ search: value || '' })
const sanitizedMinCalls =
typeof minCalls === 'number' && Number.isFinite(minCalls) && minCalls >= 0
? Math.floor(minCalls)
: undefined
setSearchParams({ search: value || '', minCalls: sanitizedMinCalls })
}
const onFilterRolesChange = (roles: string[]) => {
setFilters({ ...filters, roles })
setSearchParams({ roles })
const sanitizedMinCalls =
typeof minCalls === 'number' && Number.isFinite(minCalls) && minCalls >= 0
? Math.floor(minCalls)
: undefined
setSearchParams({ roles, minCalls: sanitizedMinCalls })
}
useEffect(() => {
@@ -51,6 +67,21 @@ export const QueryPerformanceFilterBar = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue])
useEffect(() => {
const value = debouncedMinCalls.trim()
if (value === '') {
setSearchParams({ minCalls: undefined })
return
}
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed >= 0) {
setSearchParams({ minCalls: Math.floor(parsed) })
} else {
setSearchParams({ minCalls: undefined })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedMinCalls])
return (
<div className="px-4 py-1.5 bg-surface-200 border-t -mt-px flex justify-between items-center overflow-x-auto overflow-y-hidden w-full flex-shrink-0">
<div className="flex items-center gap-x-4">
@@ -78,6 +109,30 @@ export const QueryPerformanceFilterBar = ({
]}
/>
<Input
size="tiny"
type="number"
autoComplete="off"
value={minCallsInput}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMinCallsInput(e.target.value)}
name="minCalls"
id="minCalls"
min={0}
placeholder="Min. calls (e.g. 100)"
className="w-32"
actions={[
minCallsInput && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => setMinCallsInput('')}
className="p-0 h-5 w-5"
/>
),
]}
/>
{showRolesFilter && (
<FilterPopover
name="Roles"
@@ -72,7 +72,7 @@ export const QueryPerformanceGrid = ({
}: QueryPerformanceGridProps) => {
const { sort, setSortConfig } = useQueryPerformanceSort()
const gridRef = useRef<DataGridHandle>(null)
const { sort: urlSort, order, roles, search } = useParams()
const { sort: urlSort, order, roles, search, minCalls } = useParams()
const dataGridContainerRef = useRef<HTMLDivElement>(null)
const [view, setView] = useState<'details' | 'suggestion'>('details')
@@ -341,6 +341,11 @@ export const QueryPerformanceGrid = ({
data = data.filter((row) => row.rolname && roles.includes(row.rolname))
}
const minCallsNum = Number(minCalls)
if (!Number.isNaN(minCallsNum) && minCallsNum > 0) {
data = data.filter((row) => (row.calls || 0) >= minCallsNum)
}
if (sort?.column === 'prop_total_time') {
data.sort((a, b) => {
const aValue = a.prop_total_time || 0
@@ -360,7 +365,7 @@ export const QueryPerformanceGrid = ({
}
return data
}, [aggregatedData, sort, search, roles])
}, [aggregatedData, sort, search, roles, minCalls])
const selectedQuery = selectedRow !== undefined ? reportData[selectedRow]?.query : undefined
const query = (selectedQuery ?? '').trim().toLowerCase()
@@ -372,7 +377,7 @@ export const QueryPerformanceGrid = ({
useEffect(() => {
setSelectedRow(undefined)
}, [search, roles, urlSort, order])
}, [search, roles, urlSort, order, minCalls])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -17,6 +17,7 @@ export const ColumnRenderer: Column<LogData, unknown>[] = [
{
name: 'timestamp-with-truncated-text',
key: 'main-column',
renderHeaderCell: () => null,
renderCell: (data: { row: PreviewLogData }) => {
const type = data.row.message as keyof typeof ICONS
@@ -29,6 +29,7 @@ export type QueryPerformanceQueryOpts = {
orderBy?: QueryPerformanceSort
roles?: string[]
runIndexAdvisor?: boolean
minCalls?: number
}
export const useQueryPerformanceQuery = ({
@@ -37,6 +38,7 @@ export const useQueryPerformanceQuery = ({
searchQuery = '',
roles,
runIndexAdvisor = false,
minCalls,
}: QueryPerformanceQueryOpts) => {
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
const baseSQL = queryPerfQueries.queries[preset]
@@ -46,6 +48,7 @@ export const useQueryPerformanceQuery = ({
? `auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`
: '',
searchQuery.length > 0 ? `statements.query ~* '${searchQuery}'` : '',
typeof minCalls === 'number' && minCalls > 0 ? `statements.calls >= ${minCalls}` : '',
]
.filter((x) => x.length > 0)
.join(' AND ')
@@ -57,12 +57,16 @@ const MonacoEditor = ({
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot()
const [intellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery<boolean>(
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT),
true
)
// [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce
const [value, setValue] = useState('')
@@ -124,6 +128,17 @@ const MonacoEditor = ({
},
})
editor.addAction({
id: 'toggle-ai-assistant',
label: 'Toggle AI Assistant',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
run: () => {
if (isAIAssistantHotkeyEnabled) {
toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
}
},
})
if (onPrompt) {
editor.addAction({
id: 'generate-sql',
@@ -1,8 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, Clock, HistoryIcon } from 'lucide-react'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import DatePicker from 'react-datepicker'
import { Clock, HistoryIcon } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@ui/components/shadcn/ui/badge'
import { Label } from '@ui/components/shadcn/ui/label'
@@ -13,6 +12,7 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import {
Button,
ButtonProps,
Calendar,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
@@ -50,13 +50,13 @@ export const LogsDatePicker = ({
}: PropsWithChildren<LogsDatePickerProps>) => {
const [open, setOpen] = useState(false)
const todayButtonRef = useRef<HTMLButtonElement>(null)
// Reset the state when the popover closes
useEffect(() => {
if (!open) {
setStartDate(value.from ? new Date(value.from) : null)
setEndDate(value.to ? new Date(value.to) : new Date())
const defaultEndDate = value.to ? new Date(value.to) : new Date()
setEndDate(defaultEndDate)
setCurrentMonth(new Date(defaultEndDate))
const fromDate = value.from ? new Date(value.from) : null
const toDate = value.to ? new Date(value.to) : null
@@ -96,6 +96,9 @@ export const LogsDatePicker = ({
const [startDate, setStartDate] = useState<Date | null>(value.from ? new Date(value.from) : null)
const [endDate, setEndDate] = useState<Date | null>(value.to ? new Date(value.to) : new Date())
const [currentMonth, setCurrentMonth] = useState<Date>(() =>
value.to ? new Date(value.to) : new Date()
)
const [startTime, setStartTime] = useState({
HH: startDate?.getHours().toString() || '00',
@@ -166,6 +169,7 @@ export const LogsDatePicker = ({
setStartDate(fromDate)
setEndDate(toDate)
setCurrentMonth(new Date(toDate))
// Update time states
setStartTime({
@@ -190,7 +194,7 @@ export const LogsDatePicker = ({
})
}
function handleCopy() {
const handleCopy = useCallback(() => {
if (!startDate || !endDate) return
const fromDate = new Date(startDate)
@@ -208,7 +212,7 @@ export const LogsDatePicker = ({
)
setCopied(true)
}
}, [startDate, endDate, startTime, endTime])
useEffect(() => {
if (pasted) {
@@ -227,7 +231,7 @@ export const LogsDatePicker = ({
document.removeEventListener('paste', handlePaste)
document.removeEventListener('copy', handleCopy)
}
}, [open, startDate, endDate])
}, [open, startDate, endDate, handleCopy])
const isLargeRange =
Math.abs(dayjs(startDate).diff(dayjs(endDate), 'days')) >
@@ -340,54 +344,14 @@ export const LogsDatePicker = ({
</div>
</div>
<div className="p-2 border-t">
<DatePicker
inline
selectsRange
todayButton={
<div className="sr-only">
<Button type="text" size="tiny" ref={todayButtonRef}>
Go to today
</Button>
</div>
}
onChange={(dates) => {
handleDatePickerChange(dates)
<Calendar
mode="range"
month={currentMonth}
onMonthChange={(month) => setCurrentMonth(new Date(month))}
selected={{ from: startDate ?? undefined, to: endDate ?? undefined }}
onSelect={(range) => {
handleDatePickerChange([range?.from ?? null, range?.to ?? null])
}}
dateFormat="MMMM d, yyyy h:mm aa"
dayClassName={() => 'cursor-pointer'}
startDate={startDate}
endDate={endDate}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between">
<div className="flex w-full items-center justify-between">
<Button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
icon={<ChevronLeft size={14} strokeWidth={2} />}
type="text"
size="tiny"
className="px-1.5"
></Button>
<span className="text-sm text-foreground-light">
{dayjs(date).format('MMMM YYYY')}
</span>
<Button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
icon={<ChevronRight size={14} strokeWidth={2} />}
type="text"
size="tiny"
className="px-1.5"
></Button>
</div>
</div>
)}
/>
</div>
{isLargeRange && !hideWarnings && (
@@ -412,12 +376,10 @@ export const LogsDatePicker = ({
<Button
type="default"
onClick={() => {
// [Jordi]: Workaround so that if the user is in a different month, the datepicker will move the view to the current month
if (todayButtonRef.current) {
todayButtonRef.current.click()
}
setStartDate(new Date())
setEndDate(new Date())
const today = new Date()
setCurrentMonth(today)
setStartDate(new Date(today))
setEndDate(new Date(today))
}}
>
Today
@@ -5,7 +5,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react'
import { LOCAL_STORAGE_KEYS, useIsMFAEnabled, useParams } from 'common'
import { LOCAL_STORAGE_KEYS, useFlag, useIsMFAEnabled, useParams } from 'common'
import {
generateOtherRoutes,
generateProductRoutes,
@@ -44,10 +44,8 @@ import {
} from 'ui'
import {
useIsAPIDocsSidePanelEnabled,
useIsNewStorageUIEnabled,
useUnifiedLogsPreview,
} from './App/FeaturePreview/FeaturePreviewContext'
import { useFlag } from 'common'
export const ICON_SIZE = 32
export const ICON_STROKE_WIDTH = 1.5
@@ -231,7 +229,6 @@ const ProjectLinks = () => {
const { mutate: sendEvent } = useSendEventMutation()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const isStorageV2 = useIsNewStorageUIEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const activeRoute = router.pathname.split('/')[3]
@@ -257,7 +254,6 @@ const ProjectLinks = () => {
storage: storageEnabled,
realtime: realtimeEnabled,
authOverviewPage: authOverviewPageEnabled,
isStorageV2,
})
const otherRoutes = generateOtherRoutes(ref, project, {
unifiedLogs: isUnifiedLogsEnabled,
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { snakeCase } from 'lodash'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
@@ -6,6 +7,8 @@ import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -13,12 +16,10 @@ import { useCreateDestinationPipelineMutation } from 'data/replication/create-de
import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { AnalyticsBucket } from 'data/storage/analytics-buckets-query'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { snakeCase } from 'lodash'
import {
Button,
Dialog,
@@ -35,8 +36,6 @@ import {
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import { convertKVStringArrayToJson } from '../../Integrations/Wrappers/Wrappers.utils'
import { getCatalogURI } from '../StorageSettings/StorageSettings.utils'
import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
@@ -62,7 +61,7 @@ const isEnabled = false // Kill switch if we wanna hold off supporting connectin
type ConnectTablesForm = z.infer<typeof FormSchema>
export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) => {
export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => {
const form = useForm<ConnectTablesForm>({
resolver: zodResolver(FormSchema),
defaultValues: { tables: [] },
@@ -72,7 +71,7 @@ export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) =>
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucket.id })
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId })
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
@@ -106,14 +105,15 @@ export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) =>
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
// [Joshen] Currently creates the destination for the analytics bucket here
// Which also involves creating a namespace + publication
// Publication name is automatically generated as {bucket.id}_publication
// Destination name is automatically generated as {bucket.id}_destination
// Publication name is automatically generated as {bucketId}_publication
// Destination name is automatically generated as {bucketId}_destination
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return toast.error('Source ID is required')
if (!bucketId) return toast.error('Bucket ID is required')
try {
const publicationName = getAnalyticsBucketPublicationName(bucket.id)
const publicationName = getAnalyticsBucketPublicationName(bucketId)
await createPublication({
projectRef,
sourceId,
@@ -135,7 +135,7 @@ export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) =>
ids: keysToDecrypt,
})
const warehouseName = bucket.id
const warehouseName = bucketId
const catalogToken = serviceKey?.api_key ?? ''
const s3AccessKeyId = decryptedValues[wrapperValues['vault_aws_access_key_id']]
const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']]
@@ -145,7 +145,7 @@ export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) =>
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
const namespace = `${bucket.id}_namespace`
const namespace = `${bucketId}_namespace`
await createNamespace({
catalogUri,
warehouse: warehouseName,
@@ -162,7 +162,7 @@ export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) =>
s3SecretAccessKey,
s3Region,
}
const destinationName = `${snakeCase(bucket.id)}_destination`
const destinationName = `${snakeCase(bucketId)}_destination`
const { pipeline_id: pipelineId } = await createDestinationPipeline({
projectRef,
@@ -31,7 +31,7 @@ import {
} from 'ui'
type NamespaceWithTablesProps = {
bucketName: string
bucketName?: string
namespace: string
sourceType: 'replication' | 'direct'
schema: string
@@ -324,7 +324,7 @@ export const NamespaceWithTables = ({
</TableBody>
</Table>
<ImportForeignSchemaDialog
bucketName={bucketName}
bucketName={bucketName ?? ''}
namespace={namespace}
circumstance="clash"
wrapperMeta={wrapperMeta}
@@ -1,3 +1,7 @@
import {
getCatalogURI,
getConnectionURL,
} from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import {
ScaffoldHeader,
ScaffoldSection,
@@ -10,7 +14,6 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Card } from 'ui'
import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils'
import { DESCRIPTIONS } from './constants'
import { CopyEnvButton } from './CopyEnvButton'
import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
@@ -24,7 +27,7 @@ const wrapperMeta = {
],
}
export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string }) => {
export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string }) => {
const { data: project } = useSelectedProjectQuery()
const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref })
@@ -37,7 +40,7 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string
const values: Record<string, string> = {
vault_token: serviceApiKey,
warehouse: bucketName,
warehouse: bucketName ?? '',
's3.endpoint': getConnectionURL(project?.ref ?? '', protocol, endpoint),
catalog_uri: getCatalogURI(project?.ref ?? '', protocol, endpoint),
}
@@ -1,17 +1,17 @@
import { uniq } from 'lodash'
import { SquarePlus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import {
convertKVStringArrayToJson,
formatWrapperTables,
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket'
import {
ScaffoldContainer,
ScaffoldHeader,
@@ -19,7 +19,7 @@ import {
ScaffoldSectionDescription,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import {
DatabaseExtension,
@@ -31,40 +31,37 @@ import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-cr
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Button,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { Button, Card, CardContent } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DeleteBucketModal } from '../DeleteBucketModal'
import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './constants'
import { CopyEnvButton } from './CopyEnvButton'
import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
import { NamespaceRow } from './NamespaceRow'
import { NamespaceWithTables } from './NamespaceWithTables'
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
import { useIcebergWrapperExtension } from './useIcebergWrapper'
export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) => {
const config = BUCKET_TYPES.analytics
const [modal, setModal] = useState<'delete' | null>(null)
const isStorageV2 = useIsNewStorageUIEnabled()
export const AnalyticBucketDetails = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { state: extensionState } = useIcebergWrapperExtension()
const {
bucket: _bucket,
error: bucketError,
isSuccess: isSuccessBucket,
isError: isErrorBucket,
} = useSelectedBucket()
const bucket = _bucket as undefined | AnalyticsBucket
const [modal, setModal] = useState<'delete' | null>(null)
/** The wrapper instance is the wrapper that is installed for this Analytics bucket. */
const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({
bucketId: bucket.id,
bucketId: bucket?.id,
})
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
@@ -126,20 +123,13 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
return (
<>
<PageLayout
title={bucket.id}
breadcrumbs={
isStorageV2
? [
{
label: 'Analytics',
href: `/project/${project?.ref}/storage/analytics`,
},
]
: []
}
secondaryActions={config?.docsUrl ? [<DocsButton key="docs" href={config.docsUrl} />] : []}
>
{isErrorBucket ? (
<ScaffoldContainer bottomPadding>
<ScaffoldSection isFullWidth>
<AlertError subject="Failed to fetch analytics buckets" error={bucketError} />
</ScaffoldSection>
</ScaffoldContainer>
) : (
<ScaffoldContainer bottomPadding>
{state === 'loading' && (
<ScaffoldSection isFullWidth>
@@ -148,7 +138,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
)}
{state === 'not-installed' && (
<ExtensionNotInstalled
bucketName={bucket.id}
bucketName={bucket?.id}
projectRef={project?.ref!}
wrapperMeta={wrapperMeta}
wrappersExtension={wrappersExtension!}
@@ -156,7 +146,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
)}
{state === 'needs-upgrade' && (
<ExtensionNeedsUpgrade
bucketName={bucket.id}
bucketName={bucket?.id}
projectRef={project?.ref!}
wrapperMeta={wrapperMeta}
wrappersExtension={wrappersExtension!}
@@ -165,117 +155,49 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
{state === 'added' && wrapperInstance && (
<>
{isStorageV2 ? (
<ScaffoldSection isFullWidth>
<ScaffoldHeader className="pt-0 flex flex-row justify-between items-end gap-x-8">
<div>
<ScaffoldSectionTitle>Tables</ScaffoldSectionTitle>
<ScaffoldSectionDescription>
Analytics tables stored in this bucket
</ScaffoldSectionDescription>
</div>
{namespaces.length > 0 && <ConnectTablesDialog bucket={bucket} />}
</ScaffoldHeader>
{isLoadingNamespaces || isLoading ? (
<GenericSkeletonLoader />
) : namespaces.length === 0 ? (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>Connect database tables</h3>
<p className="text-foreground-light text-sm">
Stream data from tables for archival, backups, or analytical queries.
</p>
</div>
<ConnectTablesDialog bucket={bucket} />
</aside>
) : (
<div className="flex flex-col gap-y-10">
{namespaces.map(({ namespace, schema, tables }) => (
<NamespaceWithTables
key={namespace}
bucketName={bucket.id}
namespace={namespace}
sourceType="direct"
schema={schema}
tables={tables as any}
token={token!}
wrapperInstance={wrapperInstance}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
/>
))}
</div>
)}
</ScaffoldSection>
) : (
<ScaffoldSection isFullWidth>
<ScaffoldHeader>
<ScaffoldSectionTitle>Namespaces</ScaffoldSectionTitle>
<ScaffoldSection isFullWidth>
<ScaffoldHeader className="pt-0 flex flex-row justify-between items-end gap-x-8">
<div>
<ScaffoldSectionTitle>Tables</ScaffoldSectionTitle>
<ScaffoldSectionDescription>
Connected namespaces and tables.
Analytics tables stored in this bucket
</ScaffoldSectionDescription>
</ScaffoldHeader>
</div>
{namespaces.length > 0 && <ConnectTablesDialog bucketId={bucket?.id} />}
</ScaffoldHeader>
{isLoadingNamespaces || isLoading ? (
<GenericSkeletonLoader />
) : namespaces.length === 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-foreground-muted">Namespace</TableHead>
<TableHead className="text-foreground-muted">Schema</TableHead>
<TableHead className="text-foreground-muted">Tables</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={4}>
<p className="text-sm text-foreground">
No namespaces in this bucket
</p>
<p className="text-sm text-foreground-lighter">
Create a namespace and add some data
</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Namespace</TableHead>
<TableHead>Schema</TableHead>
<TableHead>Tables</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{namespaces.map(({ namespace, schema, tables }) => (
<NamespaceRow
key={namespace}
bucketName={bucket.id}
namespace={namespace}
schema={schema}
tables={tables as any}
token={token!}
wrapperInstance={wrapperInstance}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
/>
))}
</TableBody>
</Table>
</Card>
)}
</ScaffoldSection>
)}
{isLoadingNamespaces || isLoading ? (
<GenericSkeletonLoader />
) : namespaces.length === 0 ? (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>Connect database tables</h3>
<p className="text-foreground-light text-sm">
Stream data from tables for archival, backups, or analytical queries.
</p>
</div>
<ConnectTablesDialog bucketId={bucket?.id} />
</aside>
) : (
<div className="flex flex-col gap-y-10">
{namespaces.map(({ namespace, schema, tables }) => (
<NamespaceWithTables
key={namespace}
bucketName={bucket?.id}
namespace={namespace}
sourceType="direct"
schema={schema}
tables={tables as any}
token={token!}
wrapperInstance={wrapperInstance}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
/>
))}
</div>
)}
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldHeader className="flex flex-row justify-between items-end gap-x-8">
@@ -317,7 +239,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
</ScaffoldSection>
</>
)}
{state === 'missing' && <WrapperMissing bucketName={bucket.id} />}
{state === 'missing' && <WrapperMissing bucketName={bucket?.id} />}
<ScaffoldSection isFullWidth className="flex flex-col gap-y-4">
<header>
@@ -334,9 +256,8 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
</div>
<Button
type="danger"
onClick={() => {
setModal('delete')
}}
disabled={!isSuccessBucket}
onClick={() => setModal('delete')}
>
Delete bucket
</Button>
@@ -344,12 +265,13 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
</Card>
</ScaffoldSection>
</ScaffoldContainer>
</PageLayout>
)}
<DeleteBucketModal
<DeleteAnalyticsBucketModal
visible={modal === `delete`}
bucket={bucket}
bucketId={bucket?.id}
onClose={() => setModal(null)}
onSuccess={() => router.push(`/project/${projectRef}/storage/analytics`)}
/>
</>
)
@@ -361,7 +283,7 @@ const ExtensionNotInstalled = ({
wrapperMeta,
wrappersExtension,
}: {
bucketName: string
bucketName?: string
projectRef: string
wrapperMeta: WrapperMeta
wrappersExtension: DatabaseExtension
@@ -410,7 +332,7 @@ const ExtensionNeedsUpgrade = ({
wrapperMeta,
wrappersExtension,
}: {
bucketName: string
bucketName?: string
projectRef: string
wrapperMeta: WrapperMeta
wrappersExtension: DatabaseExtension
@@ -453,11 +375,12 @@ const ExtensionNeedsUpgrade = ({
)
}
const WrapperMissing = ({ bucketName }: { bucketName: string }) => {
const WrapperMissing = ({ bucketName }: { bucketName?: string }) => {
const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
useIcebergWrapperCreateMutation()
const onSetupWrapper = async () => {
if (!bucketName) return console.error('Bucket name is required')
await createIcebergWrapper({ bucketName })
}
@@ -1,6 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
import { FDW } from 'data/fdw/fdws-query'
@@ -23,13 +22,9 @@ import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperIn
* Used for cleaning up analytics bucket after deletion
*/
export const useAnalyticsBucketAssociatedEntities = (
{ projectRef, bucketId }: { projectRef?: string; bucketId: string },
{ projectRef, bucketId }: { projectRef?: string; bucketId?: string },
options: { enabled: boolean } = { enabled: true }
) => {
// [Joshen] Opting to skip cleaning up ETL related entities within old UI
// Also to prevent an unnecessary call to /sources for existing UI
const isStorageV2 = useIsNewStorageUIEnabled()
const { can: canReadS3Credentials } = useAsyncCheckPermissions(
PermissionAction.STORAGE_ADMIN_READ,
'*'
@@ -45,12 +40,12 @@ export const useAnalyticsBucketAssociatedEntities = (
{ enabled: canReadS3Credentials && options.enabled }
)
const s3AccessKey = (s3AccessKeys?.data ?? []).find(
(x) => x.description === getAnalyticsBucketS3KeyName(bucketId)
(x) => x.description === getAnalyticsBucketS3KeyName(bucketId ?? '')
)
const { data: sourcesData } = useReplicationSourcesQuery(
{ projectRef },
{ enabled: isStorageV2 && options.enabled }
{ enabled: options.enabled }
)
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
@@ -59,7 +54,7 @@ export const useAnalyticsBucketAssociatedEntities = (
{ enabled: options.enabled }
)
const publication = publications.find(
(p) => p.name === getAnalyticsBucketPublicationName(bucketId)
(p) => p.name === getAnalyticsBucketPublicationName(bucketId ?? '')
)
return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication }
@@ -10,17 +10,18 @@ import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
export const useAnalyticsBucketWrapperInstance = (
{ bucketId }: { bucketId: string },
{ bucketId }: { bucketId?: string },
options?: { enabled?: boolean }
) => {
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
const defaultEnabled = options?.enabled ?? true
const { data, isLoading: isLoadingFDWs } = useFDWsQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
options
{ enabled: defaultEnabled && !!bucketId }
)
const icebergWrapper = useMemo(() => {
@@ -10,15 +10,16 @@ import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useIsAnalyticsBucketsEnabled } from 'data/config/project-storage-config-query'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useAnalyticsBucketCreateMutation } from 'data/storage/analytics-bucket-create-mutation'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import { DOCS_URL } from 'lib/constants'
import {
Button,
cn,
@@ -37,9 +38,9 @@ import {
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { inverseValidBucketNameRegex, validBucketNameRegex } from '../CreateBucketModal.utils'
import { BUCKET_TYPES } from '../Storage.constants'
import { useIcebergWrapperExtension } from './AnalyticsBucketDetails/useIcebergWrapper'
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
import { BUCKET_TYPES } from './Storage.constants'
const FormSchema = z
.object({
@@ -70,12 +71,11 @@ const FormSchema = z
}
})
const formId = 'create-specialized-storage-bucket-form'
const formId = 'create-analytics-storage-bucket-form'
export type CreateSpecializedBucketForm = z.infer<typeof FormSchema>
export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
interface CreateSpecializedBucketModalProps {
bucketType: 'analytics' | 'vectors'
interface CreateAnalyticsBucketModalProps {
buttonSize?: 'tiny' | 'small'
buttonType?: 'default' | 'primary'
buttonClassName?: string
@@ -88,14 +88,13 @@ interface CreateSpecializedBucketModalProps {
}
}
export const CreateSpecializedBucketModal = ({
bucketType,
export const CreateAnalyticsBucketModal = ({
buttonSize = 'tiny',
buttonType = 'default',
buttonClassName,
disabled = false,
tooltip,
}: CreateSpecializedBucketModalProps) => {
}: CreateAnalyticsBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
@@ -106,8 +105,9 @@ export const CreateSpecializedBucketModal = ({
const [visible, setVisible] = useState(false)
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
const icebergCatalogEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
const wrappersExtenstionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
const { mutate: sendEvent } = useSendEventMutation()
@@ -126,64 +126,48 @@ export const CreateSpecializedBucketModal = ({
const config = BUCKET_TYPES['analytics']
const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
const form = useForm<CreateSpecializedBucketForm>({
const form = useForm<CreateAnalyticsBucketForm>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '' },
})
const onSubmit: SubmitHandler<CreateSpecializedBucketForm> = async (values) => {
const onSubmit: SubmitHandler<CreateAnalyticsBucketForm> = async (values) => {
if (!ref) return console.error('Project ref is required')
if (!project) return console.error('Project details is required')
if (!wrappersExtension) return console.error('Unable to find wrappers extension')
if (wrappersExtensionState === 'needs-upgrade') {
// [Joshen] Double check if this is the right CTA
return toast.error(
<p className="prose max-w-full text-sm">
Wrappers extensions needs to be updated to create an Iceberg Wrapper. Update the extension
by disabling and enabling the <code className="text-xs">wrappers</code> extension first in
the{' '}
<InlineLink href={`/project/${ref}/database/extensions?filter=wrappers`}>
database extensions page
</InlineLink>{' '}
before creating an Analytics bucket.
</p>
)
}
const hasExistingBucket = buckets.some((x) => x.id === values.name)
if (hasExistingBucket) return toast.error('Bucket name already exists')
try {
if (bucketType === 'analytics') {
await createAnalyticsBucket({
projectRef: ref,
bucketName: values.name,
})
await createAnalyticsBucket({
projectRef: ref,
bucketName: values.name,
})
if (wrappersExtensionState === 'not-installed') {
await enableExtension({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: wrappersExtension.name,
schema: wrappersExtension.schema ?? 'extensions',
version: wrappersExtension.default_version,
})
await createIcebergWrapper({ bucketName: values.name })
} else if (wrappersExtensionState === 'installed') {
await createIcebergWrapper({ bucketName: values.name })
}
} else {
// TODO for vectors bucket
if (wrappersExtensionState === 'not-installed') {
await enableExtension({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: wrappersExtension.name,
schema: wrappersExtension.schema ?? 'extensions',
version: wrappersExtension.default_version,
})
await createIcebergWrapper({ bucketName: values.name })
} else if (wrappersExtensionState === 'installed') {
await createIcebergWrapper({ bucketName: values.name })
}
sendEvent({
action: 'storage_bucket_created',
properties: { bucketType },
properties: { bucketType: 'analytics' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
toast.success(`Created bucket “${values.name}”`)
form.reset()
setVisible(false)
router.push(`/project/${ref}/storage/${bucketType}/buckets/${values.name}`)
router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`)
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
@@ -227,7 +211,7 @@ export const CreateSpecializedBucketModal = ({
</ButtonTooltip>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogContent size="large" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create {config.singularName} bucket</DialogTitle>
</DialogHeader>
@@ -263,9 +247,27 @@ export const CreateSpecializedBucketModal = ({
)}
/>
{bucketType === 'analytics' && (
{wrappersExtenstionNeedsUpgrading ? (
<Admonition
type="warning"
title="Wrappers extension must be updated for Iceberg Wrapper support"
>
<p className="prose max-w-full text-sm !leading-normal">
Update the <code className="text-xs">wrappers</code> extension by disabling and
enabling it in{' '}
<InlineLink href={`/project/${ref}/database/extensions?filter=wrappers`}>
database extensions
</InlineLink>{' '}
before creating an Analytics bucket.{' '}
<InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
Learn more
</InlineLink>
.
</p>
</Admonition>
) : (
<Admonition type="default">
<p>
<p className="!leading-normal">
Supabase will install the{' '}
{wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''}
Iceberg Wrapper integration on your behalf.{' '}
@@ -284,8 +286,13 @@ export const CreateSpecializedBucketModal = ({
<Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating} disabled={isCreating}>
Create
<Button
form={formId}
htmlType="submit"
loading={isCreating}
disabled={wrappersExtenstionNeedsUpgrading || isCreating}
>
Create bucket
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,164 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
useAnalyticsBucketAssociatedEntities,
useAnalyticsBucketDeleteCleanUp,
} from './AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities'
export interface DeleteAnalyticsBucketModalProps {
visible: boolean
bucketId?: string
onClose: () => void
onSuccess?: () => void
}
const formId = `delete-analytics-bucket-form`
// [Joshen] Can refactor to use TextConfirmModal
export const DeleteAnalyticsBucketModal = ({
visible,
bucketId,
onClose,
onSuccess,
}: DeleteAnalyticsBucketModalProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const schema = z.object({
confirm: z.literal(bucketId, {
errorMap: () => ({ message: `Please enter "${bucketId}" to confirm` }),
}),
})
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } =
useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucketId })
const { mutateAsync: deleteAnalyticsBucketCleanUp, isLoading: isCleaningUpAnalyticsBucket } =
useAnalyticsBucketDeleteCleanUp()
const { mutate: deleteAnalyticsBucket, isLoading: isDeletingAnalyticsBucket } =
useAnalyticsBucketDeleteMutation({
onSuccess: async () => {
if (project?.connectionString) {
await deleteAnalyticsBucketCleanUp({
projectRef,
connectionString: project.connectionString,
bucketId: bucketId,
icebergWrapper,
icebergWrapperMeta,
s3AccessKey,
publication,
})
}
toast.success(`Successfully deleted analytics bucket ${bucketId}`)
onClose()
onSuccess?.()
},
})
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucketId) return console.error('No bucket is selected')
deleteAnalyticsBucket({ projectRef, id: bucketId })
}
const isDeleting = isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket
return (
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Confirm deletion of {bucketId}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Admonition
type="destructive"
className="rounded-none border-x-0 border-t-0 mb-0"
title="You cannot recover this bucket once deleted."
description="All bucket data will be lost."
/>
<DialogSection>
<p className="text-sm">
Your bucket <span className="font-bold text-foreground">{bucketId}</span> and all its
contents will be permanently deleted.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection>
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormField_Shadcn_
key="confirm"
name="confirm"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="confirm"
label={
<>
Type <span className="font-bold text-foreground">{bucketId}</span> to
confirm.
</>
}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="confirm"
autoComplete="off"
{...field}
placeholder="Type bucket name"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={isDeleting} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" type="danger" loading={isDeleting}>
Delete bucket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,5 @@
import { MoreVertical, Search, Trash2 } from 'lucide-react'
import { ExternalLink, MoreVertical, Search, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
@@ -21,16 +20,13 @@ import {
TableHeader,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Admonition, TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal'
import { DeleteBucketModal } from './DeleteBucketModal'
import { EmptyBucketState } from './EmptyBucketState'
// [Joshen] Remove typecasts bucket: any once infra changes for analytics bucket is in
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal'
import { DeleteAnalyticsBucketModal } from './DeleteAnalyticsBucketModal'
export const AnalyticsBuckets = () => {
const router = useRouter()
const { ref } = useParams()
const [filterString, setFilterString] = useState('')
@@ -41,19 +37,41 @@ export const AnalyticsBuckets = () => {
projectRef: ref,
})
const analyticsBuckets = buckets.filter((bucket: any) =>
const analyticsBuckets = buckets.filter((bucket) =>
filterString.length === 0 ? true : bucket.id.toLowerCase().includes(filterString.toLowerCase())
)
return (
<>
<ScaffoldSection isFullWidth>
<Admonition
type="warning"
layout="horizontal"
className="mb-12 [&>div]:!translate-y-0 [&>svg]:!translate-y-1"
title="Analytics buckets are in alpha"
actions={
<Button asChild type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/orgs/supabase/discussions/40116"
>
Leave feedback
</a>
</Button>
}
>
<p className="!leading-normal !mb-0">
Expect rapid changes, limited features, and possible breaking updates as we expand access.
</p>
<p className="!leading-normal !mb-0">Please share feedback as we refine the experience!</p>
</Admonition>
{!isLoadingBuckets &&
buckets.filter((bucket: any) => !('type' in bucket) || bucket.type === 'ANALYTICS').length ===
buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS').length ===
0 ? (
<EmptyBucketState bucketType="analytics" />
) : (
// Override the default first:pt-12 to match other storage types
<ScaffoldSection isFullWidth className="gap-y-4 first:pt-8">
<div className="flex flex-col gap-y-4">
<ScaffoldHeader className="py-0">
<ScaffoldSectionTitle>Buckets</ScaffoldSectionTitle>
</ScaffoldHeader>
@@ -66,11 +84,7 @@ export const AnalyticsBuckets = () => {
onChange={(e) => setFilterString(e.target.value)}
icon={<Search size={12} />}
/>
<CreateSpecializedBucketModal
buttonType="primary"
buttonClassName="w-fit"
bucketType="analytics"
/>
<CreateAnalyticsBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
{isLoadingBuckets ? (
@@ -96,7 +110,7 @@ export const AnalyticsBuckets = () => {
</TableCell>
</TableRow>
)}
{analyticsBuckets.map((bucket: any) => (
{analyticsBuckets.map((bucket) => (
<TableRow key={bucket.id}>
<TableCell>
<p className="text-foreground">{bucket.id}</p>
@@ -145,16 +159,16 @@ export const AnalyticsBuckets = () => {
</Table>
</Card>
)}
</ScaffoldSection>
</div>
)}
{selectedBucket && (
<DeleteBucketModal
<DeleteAnalyticsBucketModal
visible={modal === 'delete'}
bucket={selectedBucket}
bucketId={selectedBucket.id}
onClose={() => setModal(null)}
/>
)}
</>
</ScaffoldSection>
)
}
@@ -0,0 +1,36 @@
import { ScaffoldSection } from 'components/layouts/Scaffold'
import { Bucket } from 'icons'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
export const BucketsComingSoon = ({ type }: { type: 'analytics' | 'vector' }) => {
return (
<ScaffoldSection isFullWidth>
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<Bucket size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>Coming soon</h3>
<p className="text-foreground-light text-sm">
{type === 'analytics'
? 'Store large datasets for analytics and reporting'
: type === 'vector'
? 'Store, index, and query your vector embeddings at scale.'
: undefined}
</p>
</div>
{type === 'analytics' && (
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://forms.supabase.com/analytics-buckets"
>
Request access
</Link>
</Button>
)}
</aside>
</ScaffoldSection>
)
}
@@ -1,29 +1,22 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { snakeCase } from 'lodash'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticsBucketDetails/useIcebergWrapper'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { IS_PLATFORM } from 'lib/constants'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
Dialog,
DialogContent,
@@ -38,22 +31,16 @@ import {
FormField_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
import { BUCKET_TYPES } from './Storage.constants'
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
const FormSchema = z
@@ -71,7 +58,6 @@ const FormSchema = z
(value) => value !== 'public',
'"public" is a reserved name. Please choose another name'
),
type: z.enum(['STANDARD', 'ANALYTICS']).default('STANDARD'),
public: z.boolean().default(false),
has_file_size_limit: z.boolean().default(false),
formatted_size_limit: z.coerce
@@ -110,60 +96,42 @@ export const CreateBucketModal = ({
buttonClassName,
label = 'New bucket',
}: CreateBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const isStorageV2 = useIsNewStorageUIEnabled()
const [visible, setVisible] = useState(false)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const { mutate: sendEvent } = useSendEventMutation()
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
})
const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
useIcebergWrapperCreateMutation()
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const config = BUCKET_TYPES['files']
const { mutate: sendEvent } = useSendEventMutation()
const { mutateAsync: createBucket, isLoading: isCreatingBucket } = useBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
})
const form = useForm<CreateBucketForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
public: false,
type: 'STANDARD',
has_file_size_limit: false,
formatted_size_limit: undefined,
allowed_mime_types: '',
},
})
const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors
const bucketName = snakeCase(form.watch('name'))
const isPublicBucket = form.watch('public')
const isStandardBucket = form.watch('type') === 'STANDARD'
const hasFileSizeLimit = form.watch('has_file_size_limit')
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
const { state: icebergWrapperExtensionState } = useIcebergWrapperExtension()
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
const onSubmit: SubmitHandler<CreateBucketForm> = async (values) => {
if (!ref) return console.error('Project ref is required')
if (values.type === 'ANALYTICS' && !icebergCatalogEnabled) {
return toast.error(
'The Analytics catalog feature is not enabled for your project. Please contact support to enable it.'
)
}
// [Joshen] Should shift this into superRefine in the form schema
try {
const fileSizeLimit =
@@ -186,27 +154,21 @@ export const CreateBucketModal = ({
await createBucket({
projectRef: ref,
id: values.name,
type: values.type,
type: 'STANDARD',
isPublic: values.public,
file_size_limit: fileSizeLimit,
allowed_mime_types: allowedMimeTypes,
})
sendEvent({
action: 'storage_bucket_created',
properties: { bucketType: values.type },
properties: { bucketType: 'STANDARD' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') {
await createIcebergWrapper({ bucketName: values.name })
}
toast.success(`Successfully created bucket ${values.name}`)
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
if (!isStorageV2) router.push(`/project/${ref}/storage/buckets/${values.name}`)
} catch (error: any) {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
@@ -267,7 +229,7 @@ export const CreateBucketModal = ({
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create a {isStorageV2 ? config.singularName : 'storage'} bucket</DialogTitle>
<DialogTitle>Create a storage bucket</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
@@ -299,344 +261,197 @@ export const CreateBucketModal = ({
</FormItemLayout>
)}
/>
</DialogSection>
{!isStorageV2 && (
<FormField_Shadcn_
key="type"
name="type"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Bucket type">
<FormControl_Shadcn_>
<RadioGroupStacked
id="type"
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<RadioGroupStackedItem
id="STANDARD"
value="STANDARD"
label="Standard bucket"
description="Compatible with S3 buckets."
showIndicator={false}
/>
{IS_PLATFORM && (
<RadioGroupStackedItem
id="ANALYTICS"
value="ANALYTICS"
label="Analytics bucket"
showIndicator={false}
disabled={!icebergCatalogEnabled}
>
<>
<p className="text-foreground-light text-left">
Stores Iceberg files and is optimized for analytical workloads.
</p>
<DialogSectionSeparator />
{icebergCatalogEnabled ? null : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs text-left">
This feature is currently in alpha and not yet enabled for
your project. Sign up{' '}
<InlineLink href="https://forms.supabase.com/analytics-buckets">
here
</InlineLink>
.
</span>
</div>
)}
</>
</RadioGroupStackedItem>
)}
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
<DialogSection className="space-y-3">
<FormField_Shadcn_
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="public"
label="Public bucket"
description="Allow anyone to read objects without authorization"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="public"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{isPublicBucket && (
<Admonition
type="warning"
title="Public buckets are not protected"
description="Users can read objects in public buckets without any authorization. Row level security (RLS) policies are still required for other operations such as object uploads and deletes."
/>
)}
</DialogSection>
<DialogSectionSeparator />
{isStandardBucket ? (
<>
<DialogSection className="space-y-3">
<FormField_Shadcn_
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="public"
label="Public bucket"
description="Allow anyone to read objects without authorization"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="public"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{isPublicBucket && (
<Admonition
type="warning"
title="Public buckets are not protected"
description="Users can read objects in public buckets without any authorization. Row level security (RLS) policies are still required for other operations such as object uploads and deletes."
/>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div>
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="formatted_size_limit"
label="File size limit"
>
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl_Shadcn_>
</div>
<div className="col-span-4">
<Select_Shadcn_
value={selectedUnit}
onValueChange={setSelectedUnit}
>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
>
<SelectValue_Shadcn_>{selectedUnit}</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_
key={unit}
value={unit}
className="text-xs"
>
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
</div>
</FormItemLayout>
)}
/>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage_Shadcn_ className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage_Shadcn_>
)}
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<DialogSection className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_allowed_mime_types"
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
name="has_file_size_limit"
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_allowed_mime_types"
id="has_file_size_limit"
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</DialogSection>
</>
) : (
<>
{icebergWrapperExtensionState === 'installed' ? (
<DialogSection>
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
<p>
<span>Supabase will setup a </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
target="_blank"
className="underline text-foreground-light"
>
foreign data wrapper
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
</a>
<span>
{' '}
for easier access to the data. This action will also create{' '}
<a
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
target="_blank"
className="underline text-foreground-light"
>
S3 Access Keys
{bucketName && (
<>
{' '}
named <span className="text-brand"> {`${bucketName}_keys`}</span>
</>
)}
</a>
<span> and </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
target="_blank"
className="underline text-foreground-light"
>
four Vault Secrets
{bucketName && (
<>
{' '}
prefixed with{' '}
<span className="text-brand"> {`${bucketName}_vault_`}</span>
</>
)}
</a>
.
</span>
</p>
<p>
As a final step, you'll need to create an{' '}
<span className="text-foreground-light">Iceberg namespace</span> before you
connect the Iceberg data to your database.
</p>
</Label_Shadcn_>
</DialogSection>
) : (
<Alert_Shadcn_ variant="warning" className="rounded-none border-0">
<WarningIcon />
<AlertTitle_Shadcn_>
You need to install the Iceberg wrapper extension to connect your Analytic
bucket to your database.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
You need to install the <span className="text-brand">wrappers</span>{' '}
extension (with the minimum version of <span>0.5.3</span>) if you want to
connect your Analytics bucket to your database.
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</>
)}
/>
{hasFileSizeLimit && (
<div>
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="formatted_size_limit"
label="File size limit"
>
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl_Shadcn_>
</div>
<div className="col-span-4">
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_ aria-label="File size limit unit" size="small">
<SelectValue_Shadcn_>{selectedUnit}</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
</div>
</FormItemLayout>
)}
/>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage_Shadcn_ className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage_Shadcn_>
)}
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormItemLayout
name="has_allowed_mime_types"
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_allowed_mime_types"
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
/>
</FormControl_Shadcn_>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</DialogSection>
</form>
</Form_Shadcn_>
<DialogFooter>
<Button
type="default"
disabled={isCreating || isCreatingIcebergWrapper}
onClick={() => setVisible(false)}
>
<Button type="default" disabled={isCreatingBucket} onClick={() => setVisible(false)}>
Cancel
</Button>
<Button
form={formId}
htmlType="submit"
loading={isCreating || isCreatingIcebergWrapper}
disabled={isCreating || isCreatingIcebergWrapper}
loading={isCreatingBucket}
disabled={isCreatingBucket}
>
Create
</Button>
@@ -8,7 +8,6 @@ import z from 'zod'
import { useParams } from 'common'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation'
import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation'
import { AnalyticsBucket } from 'data/storage/analytics-buckets-query'
import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
@@ -29,11 +28,6 @@ import {
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import {
useAnalyticsBucketAssociatedEntities,
useAnalyticsBucketDeleteCleanUp,
} from './AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities'
import { formatPoliciesForStorage } from './Storage.utils'
export interface DeleteBucketModalProps {
@@ -46,11 +40,8 @@ const formId = `delete-storage-bucket-form`
export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModalProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const isStorageV2 = useIsNewStorageUIEnabled()
const isStandardBucketSelected = 'type' in bucket && bucket.type === 'STANDARD'
const schema = z.object({
confirm: z.literal(bucket.id, {
@@ -71,12 +62,6 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
schema: 'storage',
})
const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } =
useAnalyticsBucketAssociatedEntities(
{ projectRef, bucketId: bucket.id },
{ enabled: !isStandardBucketSelected }
)
const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation()
const { mutate: deleteBucket, isLoading: isDeletingBucket } = useBucketDeleteMutation({
@@ -107,11 +92,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
)
toast.success(`Successfully deleted bucket ${bucket.id}`)
if (isStorageV2) {
router.push(`/project/${projectRef}/storage/files`)
} else {
router.push(`/project/${projectRef}/storage/buckets`)
}
if (!!bucketId) router.push(`/project/${projectRef}/storage/files`)
onClose()
} catch (error) {
toast.success(
@@ -121,51 +102,12 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
},
})
const { mutateAsync: deleteAnalyticsBucketCleanUp, isLoading: isCleaningUpAnalyticsBucket } =
useAnalyticsBucketDeleteCleanUp()
const { mutate: deleteAnalyticsBucket, isLoading: isDeletingAnalyticsBucket } =
useAnalyticsBucketDeleteMutation({
onSuccess: async () => {
if (project?.connectionString) {
await deleteAnalyticsBucketCleanUp({
projectRef,
connectionString: project.connectionString,
bucketId: bucket.id,
icebergWrapper,
icebergWrapperMeta,
s3AccessKey,
publication,
})
}
toast.success(`Successfully deleted analytics bucket ${bucket.id}`)
if (isStorageV2) {
router.push(`/project/${projectRef}/storage/analytics`)
} else {
router.push(`/project/${projectRef}/storage/buckets`)
}
onClose()
},
})
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucket) return console.error('No bucket is selected')
// [Joshen] We'll need a third case to figure out for vector buckets
if (isStandardBucketSelected) {
deleteBucket({ projectRef, id: bucket.id })
} else {
if (isStorageV2) {
deleteAnalyticsBucket({ projectRef, id: bucket.id })
} else {
deleteBucket({ projectRef, id: bucket.id })
}
}
deleteBucket({ projectRef, id: bucket.id })
}
const isDeleting = isDeletingBucket || isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket
return (
<Dialog
open={visible}
@@ -226,10 +168,10 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={isDeleting} onClick={onClose}>
<Button type="default" disabled={isDeletingBucket} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" type="danger" loading={isDeleting}>
<Button form={formId} htmlType="submit" type="danger" loading={isDeletingBucket}>
Delete bucket
</Button>
</DialogFooter>
@@ -1,18 +1,27 @@
import { BucketAdd } from 'icons'
import { cn } from 'ui'
import { CreateAnalyticsBucketModal } from './AnalyticsBuckets/CreateAnalyticsBucketModal'
import { CreateBucketModal } from './CreateBucketModal'
import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal'
import { BUCKET_TYPES } from './Storage.constants'
import { CreateVectorBucketDialog } from './VectorBuckets/CreateVectorBucketDialog'
interface EmptyBucketStateProps {
bucketType: keyof typeof BUCKET_TYPES
className?: string
}
export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => {
export const EmptyBucketState = ({ bucketType, className }: EmptyBucketStateProps) => {
const config = BUCKET_TYPES[bucketType]
return (
<aside className="mt-12 border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<aside
className={cn(
'border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center text-balance',
className
)}
>
<BucketAdd size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>
Create {config.article} {config.singularName} bucket
@@ -20,27 +29,17 @@ export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => {
<p className="text-foreground-light text-sm">{config.valueProp}</p>
</div>
{bucketType === 'files' ? (
{bucketType === 'files' && (
<CreateBucketModal buttonSize="tiny" buttonType="primary" buttonClassName="w-fit" />
) : (
<CreateSpecializedBucketModal
bucketType={bucketType}
)}
{bucketType === 'analytics' && (
<CreateAnalyticsBucketModal
buttonSize="tiny"
buttonType="primary"
buttonClassName="w-fit"
disabled={bucketType === 'vectors'}
tooltip={
bucketType === 'vectors'
? {
content: {
side: 'bottom',
text: 'Vector buckets are not supported yet',
},
}
: undefined
}
/>
)}
{bucketType === 'vectors' && <CreateVectorBucketDialog />}
</aside>
)
}
@@ -1,223 +0,0 @@
import { Edit, FolderOpen, MoreVertical, Search, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useStoragePolicyCounts } from 'hooks/storage/useStoragePolicyCounts'
import { IS_PLATFORM } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import {
Badge,
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateBucketModal } from './CreateBucketModal'
import { DeleteBucketModal } from './DeleteBucketModal'
import { EditBucketModal } from './EditBucketModal'
import { EmptyBucketModal } from './EmptyBucketModal'
import { EmptyBucketState } from './EmptyBucketState'
export const FilesBuckets = () => {
const router = useRouter()
const { ref } = useParams()
const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
const [selectedBucket, setSelectedBucket] = useState<Bucket>()
const [filterString, setFilterString] = useState('')
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref })
const { getPolicyCount, isLoading: isLoadingPolicies } = useStoragePolicyCounts(buckets)
const formattedGlobalUploadLimit = formatBytes(data?.fileSizeLimit ?? 0)
const isLoading = isLoadingBuckets || isLoadingPolicies
const filesBuckets = buckets
.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD')
.filter((bucket) =>
filterString.length === 0
? true
: bucket.name.toLowerCase().includes(filterString.toLowerCase())
)
return (
<>
{!isLoading &&
buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD').length === 0 ? (
<EmptyBucketState bucketType="files" />
) : (
// Add !pt-8 to override the default first:pt-12
<ScaffoldSection isFullWidth className="gap-y-4">
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search size={12} />}
/>
<CreateBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
{isLoading ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Policies</TableHead>
<TableHead>File size limit</TableHead>
<TableHead>Allowed MIME types</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filesBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{filesBuckets.map((bucket) => (
<TableRow key={bucket.id}>
<TableCell>
<div className="flex items-center gap-2">
<p className="text-foreground">{bucket.name}</p>
{bucket.public && <Badge variant="warning">Public</Badge>}
</div>
</TableCell>
<TableCell>
<p className="text-foreground-light">{getPolicyCount(bucket.name)}</p>
</TableCell>
<TableCell>
<p
className={
bucket.file_size_limit
? 'text-foreground-light'
: 'text-foreground-muted'
}
>
{bucket.file_size_limit
? formatBytes(bucket.file_size_limit)
: `Unset (${formattedGlobalUploadLimit})`}
</p>
</TableCell>
<TableCell>
<p
className={
bucket.allowed_mime_types
? 'text-foreground-light'
: 'text-foreground-muted'
}
>
{bucket.allowed_mime_types ? bucket.allowed_mime_types.join(', ') : 'Any'}
</p>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link
href={`/project/${ref}/storage/files/buckets/${encodeURIComponent(bucket.id)}`}
>
View files
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={(e) => {
setModal('edit')
setSelectedBucket(bucket)
}}
>
<Edit size={12} />
<p>Edit bucket</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={(e) => {
setModal('empty')
setSelectedBucket(bucket)
}}
>
<FolderOpen size={12} />
<p>Empty bucket</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={(e) => {
setModal('delete')
setSelectedBucket(bucket)
}}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</ScaffoldSection>
)}
{selectedBucket && (
<>
<EditBucketModal
visible={modal === 'edit'}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
<EmptyBucketModal
visible={modal === 'empty'}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
<DeleteBucketModal
visible={modal === `delete`}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
</>
)}
</>
)
}
@@ -0,0 +1,197 @@
import { Edit, FolderOpen, MoreVertical, Trash2 } from 'lucide-react'
import Link from 'next/link'
import {
VirtualizedTableCell,
VirtualizedTableHead,
VirtualizedTableHeader,
VirtualizedTableRow,
} from 'components/ui/VirtualizedTable'
import { Bucket } from 'data/storage/buckets-query'
import { formatBytes } from 'lib/helpers'
import {
Badge,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
type BucketTableMode = 'standard' | 'virtualized'
type BucketTableHeaderProps = {
mode: BucketTableMode
}
export const BucketTableHeader = ({ mode }: BucketTableHeaderProps) => {
const BucketTableHeader = mode === 'standard' ? TableHeader : VirtualizedTableHeader
const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow
const BucketTableHead = mode === 'standard' ? TableHead : VirtualizedTableHead
const stickyClasses = 'sticky top-0 z-10 bg-200'
return (
<BucketTableHeader>
<BucketTableRow>
<BucketTableHead className={cn('w-[280px]', stickyClasses)}>Name</BucketTableHead>
<BucketTableHead className={stickyClasses}>Policies</BucketTableHead>
<BucketTableHead className={stickyClasses}>File size limit</BucketTableHead>
<BucketTableHead className={stickyClasses}>Allowed MIME types</BucketTableHead>
<BucketTableHead className={stickyClasses}>
<span className="sr-only">Actions</span>
</BucketTableHead>
</BucketTableRow>
</BucketTableHeader>
)
}
type BucketTableEmptyStateProps = {
mode: BucketTableMode
filterString: string
}
export const BucketTableEmptyState = ({ mode, filterString }: BucketTableEmptyStateProps) => {
const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow
const BucketTableCell = mode === 'standard' ? TableCell : VirtualizedTableCell
return (
<BucketTableRow className="[&>td]:hover:bg-inherit">
<BucketTableCell colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</BucketTableCell>
</BucketTableRow>
)
}
type BucketTableRowProps = {
mode: BucketTableMode
bucket: Bucket
projectRef: string
formattedGlobalUploadLimit: string
getPolicyCount: (bucketName: string) => number
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
}
export const BucketTableRow = ({
mode,
bucket,
projectRef,
formattedGlobalUploadLimit,
getPolicyCount,
setSelectedBucket,
setModal,
}: BucketTableRowProps) => {
const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow
const BucketTableCell = mode === 'standard' ? TableCell : VirtualizedTableCell
return (
<BucketTableRow key={bucket.id}>
<BucketTableCell>
<div className="flex items-center gap-2">
<p title={bucket.id} className="text-foreground truncate max-w-[240px]">
{bucket.id}
</p>
{bucket.public && <Badge variant="warning">Public</Badge>}
</div>
</BucketTableCell>
<BucketTableCell>
<p className="text-foreground-light">{getPolicyCount(bucket.id)}</p>
</BucketTableCell>
<BucketTableCell>
<p className={bucket.file_size_limit ? 'text-foreground-light' : 'text-foreground-muted'}>
{bucket.file_size_limit
? formatBytes(bucket.file_size_limit)
: `Unset (${formattedGlobalUploadLimit})`}
</p>
</BucketTableCell>
<BucketTableCell>
<p
className={bucket.allowed_mime_types ? 'text-foreground-light' : 'text-foreground-muted'}
>
{bucket.allowed_mime_types ? bucket.allowed_mime_types.join(', ') : 'Any'}
</p>
</BucketTableCell>
<BucketTableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link
href={`/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucket.id)}`}
>
View files
</Link>
</Button>
<BucketDropdownMenu
bucket={bucket}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
</div>
</BucketTableCell>
</BucketTableRow>
)
}
type BucketDropdownMenuProps = {
bucket: Bucket
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
}
const BucketDropdownMenu = ({ bucket, setSelectedBucket, setModal }: BucketDropdownMenuProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('edit')
setSelectedBucket(bucket)
}}
>
<Edit size={12} />
<p>Edit bucket</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('empty')
setSelectedBucket(bucket)
}}
>
<FolderOpen size={12} />
<p>Empty bucket</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('delete')
setSelectedBucket(bucket)
}}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,100 @@
import { VirtualizedTable, VirtualizedTableBody } from 'components/ui/VirtualizedTable'
import { Bucket } from 'data/storage/buckets-query'
import { Table, TableBody } from 'ui'
import { BucketTableEmptyState, BucketTableHeader, BucketTableRow } from './BucketTable'
type BucketsTableProps = {
buckets: Bucket[]
projectRef: string
filterString: string
formattedGlobalUploadLimit: string
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
getPolicyCount: (bucketName: string) => number
}
export const BucketsTable = (props: BucketsTableProps) => {
const isVirtualized = props.buckets.length > 50
return isVirtualized ? (
<BucketsTableVirtualized {...props} />
) : (
<BucketsTableUnvirtualized {...props} />
)
}
const BucketsTableUnvirtualized = ({
buckets,
projectRef,
filterString,
formattedGlobalUploadLimit,
setSelectedBucket,
setModal,
getPolicyCount,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
return (
<Table
containerProps={{ containerClassName: 'h-full overflow-auto', className: 'overflow-visible' }}
>
<BucketTableHeader mode="standard" />
<TableBody>
{showSearchEmptyState ? (
<BucketTableEmptyState mode="standard" filterString={filterString} />
) : (
buckets.map((bucket) => (
<BucketTableRow
mode="standard"
key={bucket.id}
bucket={bucket}
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
getPolicyCount={getPolicyCount}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
))
)}
</TableBody>
</Table>
)
}
const BucketsTableVirtualized = ({
buckets,
projectRef,
filterString,
formattedGlobalUploadLimit,
setSelectedBucket,
setModal,
getPolicyCount,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
return (
<VirtualizedTable data={buckets} estimateSize={() => 59} getItemKey={(bucket) => bucket.id}>
<BucketTableHeader mode="virtualized" />
<VirtualizedTableBody<Bucket>
paddingColSpan={5}
emptyContent={
showSearchEmptyState ? (
<BucketTableEmptyState mode="virtualized" filterString={filterString} />
) : undefined
}
>
{(bucket) => (
<BucketTableRow
mode="virtualized"
key={bucket.id}
bucket={bucket}
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
getPolicyCount={getPolicyCount}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
)}
</VirtualizedTableBody>
</VirtualizedTable>
)
}
@@ -0,0 +1,143 @@
import { ArrowDownNarrowWide, Search } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useStoragePolicyCounts } from 'hooks/storage/useStoragePolicyCounts'
import { IS_PLATFORM } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import {
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateBucketModal } from '../CreateBucketModal'
import { DeleteBucketModal } from '../DeleteBucketModal'
import { EditBucketModal } from '../EditBucketModal'
import { EmptyBucketModal } from '../EmptyBucketModal'
import { EmptyBucketState } from '../EmptyBucketState'
import { STORAGE_BUCKET_SORT } from '../Storage.constants'
import { BucketsTable } from './BucketsTable'
export const FilesBuckets = () => {
const { ref } = useParams()
const snap = useStorageExplorerStateSnapshot()
const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
const [selectedBucket, setSelectedBucket] = useState<Bucket>()
const [filterString, setFilterString] = useState('')
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref })
const { getPolicyCount, isLoading: isLoadingPolicies } = useStoragePolicyCounts(buckets)
const formattedGlobalUploadLimit = formatBytes(data?.fileSizeLimit ?? 0)
const isLoading = isLoadingBuckets || isLoadingPolicies
const filesBuckets = buckets
.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD')
.filter((bucket) =>
filterString.length === 0
? true
: bucket.id.toLowerCase().includes(filterString.toLowerCase())
)
const sortedFilesBuckets = useMemo(
() =>
snap.sortBucket === 'alphabetical'
? filesBuckets.sort((a, b) =>
a.id.toLowerCase().trim().localeCompare(b.id.toLowerCase().trim())
)
: filesBuckets.sort((a, b) => (new Date(b.created_at) > new Date(a.created_at) ? 1 : -1)),
[filesBuckets, snap.sortBucket]
)
return (
<>
{!isLoading &&
buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD').length === 0 ? (
<EmptyBucketState bucketType="files" className="mt-12" />
) : (
// Add !pt-8 to override the default first:pt-12
<ScaffoldSection isFullWidth className="h-full gap-y-4">
<div className="flex flex-grow justify-between gap-x-2 items-center">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search size={12} />}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<ArrowDownNarrowWide />}>
Sorted by {snap.sortBucket === 'alphabetical' ? 'name' : 'created at'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={snap.sortBucket}
onValueChange={(value) => snap.setSortBucket(value as STORAGE_BUCKET_SORT)}
>
<DropdownMenuRadioItem value="alphabetical">Sort by name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at">
Sort by created at
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
{isLoading ? (
<GenericSkeletonLoader />
) : (
<Card>
<BucketsTable
buckets={sortedFilesBuckets}
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
getPolicyCount={getPolicyCount}
/>
</Card>
)}
</ScaffoldSection>
)}
{selectedBucket && (
<>
<EditBucketModal
visible={modal === 'edit'}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
<EmptyBucketModal
visible={modal === 'empty'}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
<DeleteBucketModal
visible={modal === `delete`}
bucket={selectedBucket}
onClose={() => setModal(null)}
/>
</>
)}
</>
)
}
Loaded 100 of 641 files, more files were not shown because too many files have changed in this diff. Show more