mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 12:55:06 +03:00
Merge branch 'master' into fix/analytics-self-hosted
This commit is contained in:
641 files changed
+7055
-4438
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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 }}
|
||||
```
|
||||
+1
-1
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
+2
-16
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -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,
|
||||
|
||||
File renamed without changes.
+12
-12
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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}
|
||||
+6
-3
@@ -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),
|
||||
}
|
||||
File renamed without changes.
+79
-156
@@ -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 })
|
||||
}
|
||||
|
||||
+4
-9
@@ -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 }
|
||||
+3
-2
@@ -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(() => {
|
||||
File renamed without changes.
+62
-55
@@ -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>
|
||||
+164
@@ -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>
|
||||
)
|
||||
}
|
||||
+38
-24
@@ -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
Reference in new issue
Block a user