mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge branch 'codex/feature-flag-oauth-app-integrations' into dnywh/chore/oauth-app-integrations
# Conflicts: # apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx # apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx
This commit is contained in:
commit
e30063dab2
166 files changed
+6253
-12733
No files matched your search
@@ -42,10 +42,13 @@ jobs:
|
||||
|
||||
- name: Regenerate JS client libraries tsdoc files
|
||||
working-directory: apps/docs/spec
|
||||
env:
|
||||
SOURCE: ${{ github.event.inputs.source }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
echo "Regenerating tsdoc files for JS client libraries..."
|
||||
echo "Source: ${{ github.event.inputs.source }}"
|
||||
echo "Version: ${{ github.event.inputs.version }}"
|
||||
echo "Source: ${SOURCE}"
|
||||
echo "Version: ${VERSION}"
|
||||
make
|
||||
|
||||
- name: Generate new typespec snapshot
|
||||
@@ -68,7 +71,7 @@ jobs:
|
||||
commit-message: 'docs: update js sdk docs (${{ github.event.inputs.version }})'
|
||||
title: 'docs: update js sdk docs (${{ github.event.inputs.version }})'
|
||||
body: |
|
||||
Updates JS sdk documentation following stable release.
|
||||
Updates JS sdk documentation following stable release.
|
||||
Ran `make` in apps/docs/spec to regenerate tsdoc files.
|
||||
|
||||
**Details:**
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
- cron: '0 4 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
settings:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [master]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-nimbus-sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -38,32 +38,34 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Update @supabase/*-js packages in pnpm-workspace.yaml
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# Update @supabase/supabase-js
|
||||
sed -i "s/'@supabase\/supabase-js': .*/'@supabase\/supabase-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s|'@supabase/supabase-js': .*|'@supabase/supabase-js': ${VERSION}|" pnpm-workspace.yaml
|
||||
|
||||
# Update @supabase/auth-js
|
||||
sed -i "s/'@supabase\/auth-js': .*/'@supabase\/auth-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s|'@supabase/auth-js': .*|'@supabase/auth-js': ${VERSION}|" pnpm-workspace.yaml
|
||||
|
||||
# Update @supabase/realtime-js
|
||||
sed -i "s/'@supabase\/realtime-js': .*/'@supabase\/realtime-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s|'@supabase/realtime-js': .*|'@supabase/realtime-js': ${VERSION}|" pnpm-workspace.yaml
|
||||
|
||||
# Update @supabase/postgrest-js
|
||||
sed -i "s/'@supabase\/postgrest-js': .*/'@supabase\/postgrest-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s|'@supabase/postgrest-js': .*|'@supabase/postgrest-js': ${VERSION}|" pnpm-workspace.yaml
|
||||
|
||||
echo "Updated pnpm-workspace.yaml:"
|
||||
cat pnpm-workspace.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
pull_request:
|
||||
types: [opened, labeled, unlabeled, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -79,8 +79,6 @@ from supabase import create_client
|
||||
client = create_client("YOUR_SUPABASE_URL", "YOUR_SUPABASE_KEY")
|
||||
movies = client.table("movies")
|
||||
|
||||
movies = supabase.table("movies")
|
||||
|
||||
# Select
|
||||
selected = [PublicMovies(m) for m in movies.select("*").execute().data]
|
||||
|
||||
|
||||
@@ -395,11 +395,11 @@ const OneTapComponent = () => {
|
||||
console.log('Nonce: ', nonce, hashedNonce)
|
||||
|
||||
// check if there's already an existing session before initializing the one-tap UI
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
const { data: { claims }, error } = await supabase.auth.getClaims()
|
||||
if (error) {
|
||||
console.error('Error getting session', error)
|
||||
console.error('Error getting claims', error)
|
||||
}
|
||||
if (data.session) {
|
||||
if (claims) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ Store `project_url` and `anon_key` in Supabase Vault:
|
||||
|
||||
```sql
|
||||
select vault.create_secret('https://project-ref.supabase.co', 'project_url');
|
||||
select vault.create_secret('YOUR_SUPABASE_PUBLISHABLE_KEY', 'publishable_key');
|
||||
select vault.create_secret('YOUR_SUPABASE_ANON_KEY', 'anon_key');
|
||||
```
|
||||
|
||||
Make a POST request to a Supabase Edge Function every minute:
|
||||
|
||||
@@ -14,15 +14,18 @@ If you get stuck while working through this guide, refer to the [full example on
|
||||
|
||||
</Admonition>
|
||||
|
||||
<$Partial path="project_setup.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} />
|
||||
<$Partial
|
||||
path="project_setup.mdx"
|
||||
variables={{ "framework": "exporeactnative", "tab": "mobiles" }}
|
||||
/>
|
||||
|
||||
## Building the app
|
||||
|
||||
Let's start building the React Native app from scratch.
|
||||
Start by building the React Native app from scratch.
|
||||
|
||||
### Initialize a React Native app
|
||||
|
||||
We can use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize
|
||||
Use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize
|
||||
an app called `expo-user-management`:
|
||||
|
||||
```bash
|
||||
@@ -31,14 +34,14 @@ npx create-expo-app -t expo-template-blank-typescript expo-user-management
|
||||
cd expo-user-management
|
||||
```
|
||||
|
||||
Then let's install the additional dependencies: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
Then install the additional dependencies:
|
||||
|
||||
```bash
|
||||
npx expo install @supabase/supabase-js @rneui/themed expo-sqlite
|
||||
```
|
||||
|
||||
Now let's create a helper file to initialize the Supabase client.
|
||||
We need the API URL and the key that you copied [earlier](#get-api-details).
|
||||
Now create a helper file to initialize the Supabase client using the API URL and the key that you copied [earlier](#get-api-details).
|
||||
|
||||
These variables are safe to expose in your Expo app since Supabase has
|
||||
[Row Level Security](/docs/guides/database/postgres/row-level-security) enabled on your Database.
|
||||
|
||||
@@ -51,26 +54,11 @@ These variables are safe to expose in your Expo app since Supabase has
|
||||
>
|
||||
<TabPanel id="local-storage" label="LocalStorage">
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=lib/supabase.ts
|
||||
import 'expo-sqlite/localStorage/install';
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
|
||||
const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
|
||||
auth: {
|
||||
storage: localStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/expo-user-management/lib/supabase.ts"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=lib/supabase.ts"
|
||||
/>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="secure-store" label="SecureStore">
|
||||
@@ -78,6 +66,7 @@ These variables are safe to expose in your Expo app since Supabase has
|
||||
If you wish to encrypt the user's session information, you can use `aes-js` and store the encryption key in [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore). The [`aes-js` library](https://github.com/ricmoo/aes-js) is a reputable JavaScript-only implementation of the AES encryption algorithm in CTR mode. A new 256-bit encryption key is generated using the `react-native-get-random-values` library. This key is stored inside Expo's SecureStore, while the value is encrypted and placed inside AsyncStorage.
|
||||
|
||||
Make sure that:
|
||||
|
||||
- You keep the `expo-secure-storage`, `aes-js` and `react-native-get-random-values` libraries up-to-date.
|
||||
- Choose the correct [`SecureStoreOptions`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestoreoptions) for your app's needs. E.g. [`SecureStore.WHEN_UNLOCKED`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestorewhen_unlocked) regulates when the data can be accessed.
|
||||
- Carefully consider optimizations or other modifications to the above example, as those can lead to introducing subtle security vulnerabilities.
|
||||
@@ -169,110 +158,14 @@ These variables are safe to expose in your Expo app since Supabase has
|
||||
|
||||
### Set up a login component
|
||||
|
||||
Let's set up a React Native component to manage logins and sign ups.
|
||||
Users would be able to sign in with their email and password.
|
||||
Set up a React Native component to manage logins and sign ups.
|
||||
Users should be able to sign in with their email and password.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```tsx name=components/Auth.tsx
|
||||
import React, { useState } from 'react'
|
||||
import { Alert, StyleSheet, View, AppState } from 'react-native'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { Button, Input } from '@rneui/themed'
|
||||
|
||||
// Tells Supabase Auth to continuously refresh the session automatically if
|
||||
// the app is in the foreground. When this is added, you will continue to receive
|
||||
// `onAuthStateChange` events with the `TOKEN_REFRESHED` or `SIGNED_OUT` event
|
||||
// if the user's session is terminated. This should only be registered once.
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
supabase.auth.startAutoRefresh()
|
||||
} else {
|
||||
supabase.auth.stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
export default function Auth() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function signInWithEmail() {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function signUpWithEmail() {
|
||||
setLoading(true)
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
if (!session) Alert.alert('Please check your inbox for email verification!')
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input
|
||||
label="Email"
|
||||
leftIcon={{ type: 'font-awesome', name: 'envelope' }}
|
||||
onChangeText={(text) => setEmail(text)}
|
||||
value={email}
|
||||
placeholder="email@address.com"
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input
|
||||
label="Password"
|
||||
leftIcon={{ type: 'font-awesome', name: 'lock' }}
|
||||
onChangeText={(text) => setPassword(text)}
|
||||
value={password}
|
||||
secureTextEntry={true}
|
||||
placeholder="Password"
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button title="Sign in" disabled={loading} onPress={() => signInWithEmail()} />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button title="Sign up" disabled={loading} onPress={() => signUpWithEmail()} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 40,
|
||||
padding: 12,
|
||||
},
|
||||
verticallySpaced: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/expo-user-management/components/Auth.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=components/Auth.tsx"
|
||||
/>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -284,173 +177,25 @@ While testing, you can disable email confirmation in your [project's email auth
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
After a user signs in, you can let them to edit their profile details and manage their account.
|
||||
|
||||
Let's create a new component for that called `Account.tsx`.
|
||||
Create a new component for that called `Account.tsx`.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```tsx name=components/Account.tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { StyleSheet, View, Alert } from 'react-native'
|
||||
import { Button, Input } from '@rneui/themed'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
export default function Account({ session }: { session: Session }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [avatarUrl, setAvatarUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (session) getProfile()
|
||||
}, [session])
|
||||
|
||||
async function getProfile() {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!session?.user) throw new Error('No user on the session!')
|
||||
|
||||
const { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', session?.user.id)
|
||||
.single()
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile({
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
}: {
|
||||
username: string
|
||||
website: string
|
||||
avatar_url: string
|
||||
}) {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!session?.user) throw new Error('No user on the session!')
|
||||
|
||||
const updates = {
|
||||
id: session?.user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input label="Email" value={session?.user?.email} disabled />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input label="Username" value={username || ''} onChangeText={(text) => setUsername(text)} />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input label="Website" value={website || ''} onChangeText={(text) => setWebsite(text)} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button
|
||||
title={loading ? 'Loading ...' : 'Update'}
|
||||
onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button title="Sign Out" onPress={() => supabase.auth.signOut()} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 40,
|
||||
padding: 12,
|
||||
},
|
||||
verticallySpaced: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/expo-user-management/components/Account.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=components/Account.tsx"
|
||||
/>
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `App.tsx`:
|
||||
Now that you have all the components in place, update `App.tsx`:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```tsx name=App.tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import Auth from './components/Auth'
|
||||
import Account from './components/Account'
|
||||
import { View } from 'react-native'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session)
|
||||
})
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View>
|
||||
{session && session.user ? <Account key={session.user.id} session={session} /> : <Auth />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/expo-user-management/App.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=App.tsx"
|
||||
/>
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
@@ -467,7 +212,7 @@ photos and videos.
|
||||
|
||||
### Additional dependency installation
|
||||
|
||||
You will need an image picker that works on the environment you will build the project for, we will use `expo-image-picker` in this example.
|
||||
You need an image picker that works on the environment you are building the project for, this example uses `expo-image-picker`.
|
||||
|
||||
```bash
|
||||
npx expo install expo-image-picker
|
||||
@@ -475,179 +220,20 @@ npx expo install expo-image-picker
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
We can start by creating a new component:
|
||||
Create an avatar for the user so that they can upload a profile photo.
|
||||
Start by creating a new component:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```tsx name=components/Avatar.tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { StyleSheet, View, Alert, Image, Button } from 'react-native'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
url: string | null
|
||||
onUpload: (filePath: string) => void
|
||||
}
|
||||
|
||||
export default function Avatar({ url, size = 150, onUpload }: Props) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
|
||||
const avatarSize = { height: size, width: size }
|
||||
|
||||
useEffect(() => {
|
||||
if (url) downloadImage(url)
|
||||
}, [url])
|
||||
|
||||
async function downloadImage(path: string) {
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from('avatars').download(path)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const fr = new FileReader()
|
||||
fr.readAsDataURL(data)
|
||||
fr.onload = () => {
|
||||
setAvatarUrl(fr.result as string)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log('Error downloading image: ', error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar() {
|
||||
try {
|
||||
setUploading(true)
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images
|
||||
allowsMultipleSelection: false, // Can only select one image
|
||||
allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it
|
||||
quality: 1,
|
||||
exif: false, // We don't want nor need that data.
|
||||
})
|
||||
|
||||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||||
console.log('User cancelled image picker.')
|
||||
return
|
||||
}
|
||||
|
||||
const image = result.assets[0]
|
||||
console.log('Got image', image)
|
||||
|
||||
if (!image.uri) {
|
||||
throw new Error('No image uri!') // Realistically, this should never happen, but just in case...
|
||||
}
|
||||
|
||||
const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())
|
||||
|
||||
const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'
|
||||
const path = `${Date.now()}.${fileExt}`
|
||||
const { data, error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(path, arraybuffer, {
|
||||
contentType: image.mimeType ?? 'image/jpeg',
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
onUpload(data.path)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(error.message)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: avatarUrl }}
|
||||
accessibilityLabel="Avatar"
|
||||
style={[avatarSize, styles.avatar, styles.image]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[avatarSize, styles.avatar, styles.noImage]} />
|
||||
)}
|
||||
<View>
|
||||
<Button
|
||||
title={uploading ? 'Uploading ...' : 'Upload'}
|
||||
onPress={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatar: {
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
image: {
|
||||
objectFit: 'cover',
|
||||
paddingTop: 0,
|
||||
},
|
||||
noImage: {
|
||||
backgroundColor: '#333',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'rgb(200, 200, 200)',
|
||||
borderRadius: 5,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/expo-user-management/components/Avatar.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=components/Avatar.tsx"
|
||||
/>
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page:
|
||||
And then add the widget to the Account page. The `Account.tsx` component [shown earlier](#account-page) already includes the `Avatar` component when using the full example code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```tsx name=components/Account.tsx
|
||||
// Import the new component
|
||||
import Avatar from './Avatar'
|
||||
|
||||
// ...
|
||||
return (
|
||||
<View>
|
||||
{/* Add to the body */}
|
||||
<View>
|
||||
<Avatar
|
||||
size={200}
|
||||
url={avatarUrl}
|
||||
onUpload={(url: string) => {
|
||||
setAvatarUrl(url)
|
||||
updateProfile({ username, website, avatar_url: url })
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{/* ... */}
|
||||
</View>
|
||||
)
|
||||
// ...
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Now you will need to run the prebuild command to get the application working on your chosen platform.
|
||||
Now run the prebuild command to get the application working on your chosen platform.
|
||||
|
||||
```bash
|
||||
npx expo prebuild
|
||||
|
||||
@@ -13,6 +13,18 @@ When you delete a project, we permanently remove all associated data, including
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Types of backups
|
||||
|
||||
Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [in this blog post](/blog/postgresql-physical-logical-backups).
|
||||
|
||||
<Admonition type="note" label="Physical backups are now enabled by default">
|
||||
|
||||
All projects on Postgres `15.8.1.079` and newer use the newer physical backup process.
|
||||
|
||||
Projects on older Postgres versions have to upgrade in order to be transitioned to physical backups. Once upgraded to an eligible version, your project is automatically transitioned over to physical backups.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
For security purposes, daily backups do not store passwords for custom roles, and you will not find them in downloadable files. If you restore from a daily backup and use custom roles, you will need to reset their passwords after the restoration completes.
|
||||
|
||||
@@ -84,6 +84,12 @@ To authenticate with S3 using a Session Token, use the following credentials:
|
||||
|
||||
For example, using the `aws-sdk` library:
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Typically we advise against using `getSession`, because the session is read from local storage and you can't trust its claims for auth decisions. In this case however, the code only needs the raw access token string to forward as a credential to the S3 service, which validates the token server-side. Since no client-side auth decision is made based on the session data, `getSession` is appropriate here.
|
||||
|
||||
</Admonition>
|
||||
|
||||
```javascript
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
|
||||
@@ -4,7 +4,17 @@ title: 'Reports'
|
||||
description: 'Built-in observability for your Supabase project'
|
||||
---
|
||||
|
||||
Supabase Reports provide comprehensive observability for your project through dedicated monitoring dashboards that visualize key metrics across your database, auth, storage, realtime, and API systems. Each report offers self-debugging tools to gain actionable insights for optimizing performance and troubleshooting issues.
|
||||
{/* last LW we added that same report to every report page (pre-filtered) so, API Gateway report filtered by Auth will show the same data as going to Auth Report in the API Gateway section */}
|
||||
|
||||
Supabase Reports provide comprehensive observability for your project through dedicated monitoring dashboards for servers:
|
||||
|
||||
- Database
|
||||
- Auth
|
||||
- Storage
|
||||
- Realtime
|
||||
- API systems
|
||||
|
||||
Each report offers self-debugging tools to gain actionable insights for optimizing performance and troubleshooting issues.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -14,7 +24,7 @@ Reports are only available for projects hosted on the Supabase Cloud platform an
|
||||
|
||||
## Using reports
|
||||
|
||||
Reports can be filtered by time range to focus your analysis on specific periods. Available time ranges are gated by your organization's plan, with higher-tier plans providing access to longer historical periods.
|
||||
You can filter reports by time range to focus on a specific period. Higher-tier plans provide access to longer time ranges.
|
||||
|
||||
| Time Range | Free | Pro | Team | Enterprise |
|
||||
| --------------- | ---- | --- | ---- | ---------- |
|
||||
@@ -31,27 +41,63 @@ Reports can be filtered by time range to focus your analysis on specific periods
|
||||
|
||||
<$Show if="billing:all">
|
||||
|
||||
## API gateway
|
||||
|
||||
The API Gateway report analyzes performance and traffic patterns managed by your project's API layer.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | ----------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Total Requests | Overall API request volume | Traffic patterns and growth trends, including top routes |
|
||||
| Response Errors | Error rates with 4XX and 5XX status codes | API reliability and user experience issues, including top routes |
|
||||
| Response Speed | Average API response times | Performance bottlenecks and optimization targets, including top routes |
|
||||
| Network Traffic | Request and response egress usage | Data transfer patterns and cost implications |
|
||||
|
||||
## Auth
|
||||
|
||||
The Auth reports focus on user authentication patterns and behaviors within your Supabase project.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| ------------------------ | --------------------------------------------- | ----------------------------------------------- |
|
||||
| Active Users | Count of unique users performing auth actions | User engagement and retention patterns |
|
||||
| Sign In Attempts by Type | Breakdown of authentication methods used | Password vs OAuth vs magic link preferences |
|
||||
| Sign Ups | Total new user registrations | Growth trends and onboarding funnel performance |
|
||||
| API Gateway Auth Errors | Error rates grouped by status code | Authentication friction and security issues |
|
||||
| Password Reset Requests | Volume of password recovery attempts | User experience pain points |
|
||||
|
||||
### Auth API Gateway
|
||||
|
||||
The Auth API Gateway reports focus on API requests related to authentication and user management.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Total Requests | Count of unique users performing auth actions | User engagement and retention patterns, including top routes |
|
||||
| Response Errors | Error rates with 4XX and 5XX status codes | API reliability and user experience issues, including top routes |
|
||||
| Response speed | Average response time for auth requests | Performance bottlenecks and optimization opportunities, including top routes |
|
||||
| Network Traffic | Ingress and egress usage | Data transfer costs and CDN effectiveness |
|
||||
|
||||
## Database
|
||||
|
||||
The Database report provides the most comprehensive view into your Postgres instance's health and performance characteristics. These charts help you identify performance bottlenecks, resource constraints, and optimization opportunities at a glance.
|
||||
The Database report provides a comprehensive view into your Postgres instance's health and performance characteristics. These charts help you identify performance bottlenecks and resource constraints at a glance.
|
||||
|
||||
The following charts are available for Free and Pro plans:
|
||||
|
||||
| Chart | Available Plans | Description | Key Insights |
|
||||
| ---------------------------- | --------------- | -------------------------------------------- | --------------------------------------------- |
|
||||
| Memory usage | Free, Pro | RAM usage percentage by the database | Memory pressure and resource utilization |
|
||||
| CPU usage | Free, Pro | Average CPU usage percentage | CPU-intensive query identification |
|
||||
| Disk IOPS | Free, Pro | Read/write operations per second with limits | IO bottleneck detection and workload analysis |
|
||||
| Database connections | Free, Pro | Number of pooler connections to the database | Connection pool monitoring |
|
||||
| Shared Pooler connections | All | Client connections to the shared pooler | Shared pooler usage patterns |
|
||||
| Dedicated Pooler connections | All | Client connections to PgBouncer | Dedicated pooler connection monitoring |
|
||||
|
||||
</$Show>
|
||||
| Chart | Available Plans | Description | Key Insights |
|
||||
| ---------------------------- | --------------- | -------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Memory usage | Free, Pro | RAM usage percentage by the database | Memory pressure and resource utilization |
|
||||
| CPU usage | Free, Pro | Average CPU usage percentage | CPU-intensive query identification |
|
||||
| Disk IOPS | Free, Pro | Read/write operations per second with limits | IO bottleneck detection and workload analysis |
|
||||
| Database connections | Free, Pro | Number of pooler connections to the database | Connection pool monitoring |
|
||||
| Dedicated Pooler connections | All | Client connections to PgBouncer | Dedicated pooler connection monitoring |
|
||||
| Shared Pooler connections | All | Client connections to the shared pooler | Shared pooler usage patterns |
|
||||
| Shared Pooler connections | All | Client connections to the shared pooler | Shared pooler usage patterns |
|
||||
| Disk usage | Free, Pro | Disk space consumption breakdown | Storage capacity planning |
|
||||
| Database size | Free, Pro | Total database size and growth trends | Space consumption monitoring, including list of largest tables |
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### Advanced Telemetry
|
||||
|
||||
{/* TODO: This is confusing and feels contradictory */}
|
||||
The following charts provide a more advanced and detailed view of your database performance and are available only for Team, Enterprise, and Platform plans.
|
||||
|
||||
### Memory usage
|
||||
@@ -71,7 +117,7 @@ height={650}
|
||||
| Component | Description |
|
||||
| ------------------- | ------------------------------------------------------ |
|
||||
| **Used** | RAM actively used by Postgres and the operating system |
|
||||
| **Cache + buffers** | Memory used for page cache and Postgres buffers |
|
||||
| **Cache + buffers** | Memory used for page cache and OS buffers |
|
||||
| **Free** | Available unallocated memory |
|
||||
|
||||
How it helps debug issues:
|
||||
@@ -87,8 +133,8 @@ Actions you can take:
|
||||
| Action | Description |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk#compute-size) | Increase available memory resources |
|
||||
| Optimize queries | Reduce memory consumption of expensive queries |
|
||||
| Tune Postgres configuration | Improve memory management settings |
|
||||
| [Optimize queries](/docs/content/guides/database/query-optimization) | Reduce memory consumption of expensive queries |
|
||||
| [Tune Postgres configuration](https://pgtune.leopard.in.ua) | Improve memory management settings |
|
||||
| Implement application caching | Add query result caching to reduce memory load |
|
||||
|
||||
### CPU usage
|
||||
@@ -123,12 +169,12 @@ How it helps debug issues:
|
||||
|
||||
Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Optimize CPU-intensive queries | Target queries causing high User CPU usage |
|
||||
| Address IO bottlenecks | Resolve disk/network issues when IOWait is high |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk) | Increase available CPU capacity |
|
||||
| Implement proper indexing | Use [query optimization](/docs/guides/database/postgres/indexes) techniques |
|
||||
| Action | Description |
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| [Optimize CPU-intensive queries](/docs/content/guides/database/query-optimization) | Target queries causing high User CPU usage |
|
||||
| Address IO bottlenecks | Resolve disk/network issues when IOWait is high |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk) | Increase available CPU capacity |
|
||||
| [Implement proper indexing](/docs/guides/database/postgres/indexes) | Use query optimization techniques |
|
||||
|
||||
### Disk input/output operations per second (IOPS)
|
||||
|
||||
@@ -158,8 +204,8 @@ Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Optimize indexing | Reduce high read IOPS through better query indexing |
|
||||
| Consider read replicas | Distribute read-heavy workloads across multiple instances |
|
||||
| [Optimize indexing](/docs/guides/database/postgres/indexes) | Reduce high read IOPS through better query indexing |
|
||||
| Consider [read replicas](/docs/guides/platform/read-replicas) | Distribute read-heavy workloads across multiple instances |
|
||||
| Batch write operations | Reduce write IOPS by grouping database writes |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk) | Increase IOPS limits with larger compute instances |
|
||||
|
||||
@@ -181,22 +227,25 @@ Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Optimize query patterns | Reduce large sequential reads/writes |
|
||||
| Tune caching and batching | Minimize repeated disk access and improve throughput headroom |
|
||||
| [Optimize disk-intensive queries](/docs/content/guides/database/query-optimization) | Reduce queries that perform excessive reads/writes |
|
||||
| Tune caching and batching | Minimize repeated disk access and improve throughput headroom |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk) | Increase throughput limits for sustained workloads |
|
||||
| Review database design | Optimize schema and query patterns for efficiency |
|
||||
| [Add strategic indexes](http://localhost:3001/docs/guides/database/postgres/indexes) | Reduce sequential scans with appropriate indexing |
|
||||
|
||||
### Disk size
|
||||
|
||||
<Image
|
||||
alt="Disk Size chart"
|
||||
width={2148}
|
||||
height={654}
|
||||
|
||||
src={{
|
||||
dark: '/docs/img/database/reports/disk-size-chart-dark.png',
|
||||
light: '/docs/img/database/reports/disk-size-chart-light.png',
|
||||
}}
|
||||
|
||||
width={2148}
|
||||
height={654}
|
||||
|
||||
/>
|
||||
|
||||
| Component | Description |
|
||||
@@ -222,6 +271,10 @@ Actions you can take:
|
||||
| Implement data archival | Archive historical data to reduce active storage needs |
|
||||
| [Upgrade disk size](/docs/guides/platform/database-size) | Increase storage capacity when approaching limits |
|
||||
|
||||
### Query Performance
|
||||
|
||||
Links to the [Query Performance Advisory page](/docs/guides/platform/performance#examine-query-performance) in the dashboard, which provides a detailed analysis of slow database queries
|
||||
|
||||
### Database connections
|
||||
|
||||
<Image
|
||||
@@ -261,29 +314,114 @@ Actions you can take:
|
||||
| Implement [connection pooling](/docs/guides/database/connecting-to-postgres#shared-pooler) | Optimize connection management for high direct connection usage |
|
||||
| Review application code | Ensure proper connection handling and cleanup |
|
||||
|
||||
## Auth
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
### Dedicated Pooler (PgBouncer) Client Connections
|
||||
|
||||
The Auth report focuses on user authentication patterns and behaviors within your Supabase project.
|
||||
Available on Team and Enterprise plans.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| ------------------------ | --------------------------------------------- | ----------------------------------------------- |
|
||||
| Active Users | Count of unique users performing auth actions | User engagement and retention patterns |
|
||||
| Sign In Attempts by Type | Breakdown of authentication methods used | Password vs OAuth vs magic link preferences |
|
||||
| Sign Ups | Total new user registrations | Growth trends and onboarding funnel performance |
|
||||
| Auth Errors | Error rates grouped by status code | Authentication friction and security issues |
|
||||
| Password Reset Requests | Volume of password recovery attempts | User experience pain points |
|
||||
This chart displays the number of PgBouncer connections over time.
|
||||
|
||||
## Storage
|
||||
How it helps debug issues:
|
||||
|
||||
The Storage report provides visibility into how your Supabase Storage is being utilized, including request patterns, performance characteristics, and caching effectiveness.
|
||||
| Issue | Description |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| Connection pool exhaustion | Identify when approaching maximum connection limits |
|
||||
| Connection leak detection | Spot applications not properly closing connections |
|
||||
| Service distribution monitoring | Monitor connection usage across different Supabase services |
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | ------------------------------------------ | ------------------------------------------------------ |
|
||||
| Total Requests | Overall request volume to Storage | Traffic patterns and usage trends |
|
||||
| Response Speed | Average response time for storage requests | Performance bottlenecks and optimization opportunities |
|
||||
| Network Traffic | Ingress and egress usage | Data transfer costs and CDN effectiveness |
|
||||
| Request Caching | Cache hit rates and miss patterns | CDN performance and cost optimization |
|
||||
| Top Routes | Most frequently accessed storage paths | Popular content and usage patterns |
|
||||
Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk#compute-size) | Increase maximum connection limits |
|
||||
| Implement [connection pooling](/docs/guides/database/connecting-to-postgres#shared-pooler) | Optimize connection management for high direct connection usage |
|
||||
| Review application code | Ensure proper connection handling and cleanup |
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
### Shared Pooler (Supavisor) Client Connections
|
||||
|
||||
Available on Team and Enterprise plans.
|
||||
|
||||
This chart displays the number of Supavisor connections over time.
|
||||
|
||||
How it helps debug issues:
|
||||
|
||||
| Issue | Description |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| Connection pool exhaustion | Identify when approaching maximum connection limits |
|
||||
| Connection leak detection | Spot applications not properly closing connections |
|
||||
| Service distribution monitoring | Monitor connection usage across different Supabase services |
|
||||
|
||||
Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| [Upgrade compute size](/docs/guides/platform/compute-and-disk#compute-size) | Increase maximum connection limits |
|
||||
| Implement [connection pooling](/docs/guides/database/connecting-to-postgres#shared-pooler) | Optimize connection management for high direct connection usage |
|
||||
| Review application code | Ensure proper connection handling and cleanup |
|
||||
|
||||
### Disk Usage
|
||||
|
||||
### Database size
|
||||
|
||||
<Image
|
||||
alt="Disk Size chart"
|
||||
|
||||
zoomable
|
||||
src={{
|
||||
dark: '/docs/img/database/reports/disk-size-chart-dark.png',
|
||||
light: '/docs/img/database/reports/disk-size-chart-light.png',
|
||||
}}
|
||||
|
||||
width={2148}
|
||||
height={654}
|
||||
/>
|
||||
|
||||
| Component | Description |
|
||||
| ------------ | --------------------------------------------------------- |
|
||||
| **Database** | Space used by your actual database data (tables, indexes) |
|
||||
| **WAL** | Space used by Write-Ahead Logging |
|
||||
| **System** | Reserved space for system operations |
|
||||
|
||||
How it helps debug issues:
|
||||
|
||||
| Issue | Description |
|
||||
| ----------------------------- | ------------------------------------------- |
|
||||
| Space consumption monitoring | Track disk usage trends over time |
|
||||
| Growth pattern identification | Identify rapid growth requiring attention |
|
||||
| Capacity planning | Plan upgrades before hitting storage limits |
|
||||
|
||||
Actions you can take:
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Run [VACUUM](https://www.postgresql.org/docs/current/sql-vacuum.html) operations | Reclaim dead tuple space and optimize storage |
|
||||
| Analyze large tables | Use CLI commands like `table-sizes` to identify optimization targets |
|
||||
| Implement data archival | Archive historical data to reduce active storage needs |
|
||||
| [Upgrade disk size](/docs/guides/platform/database-size) | Increase storage capacity when approaching limits |
|
||||
|
||||
## Edge Functions
|
||||
|
||||
The Edge Functions report provides insights into serverless function performance, execution patterns, and regional distribution across Supabase's global edge network.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| ------------------------------------ | ----------------------------------------- | ---------------------------------------------- |
|
||||
| Total Edge Function Invocations | Function response codes and error rates | Function reliability and error patterns |
|
||||
| Edge Function Execution Status Codes | Function response codes and error rates | Function reliability and error patterns |
|
||||
| Edge Function Execution Time | Average function duration and performance | Performance optimization opportunities |
|
||||
| Edge Function Invocations by Region | Geographic distribution of function calls | Global usage patterns and latency optimization |
|
||||
|
||||
|
||||
## PostgREST
|
||||
|
||||
The PostgREST report provides insights into RESTful API performance, request patterns, and response characteristics.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Total Requests | HTTP requests to PostgREST endpoints | API usage alongside WebSocket activity |
|
||||
| Response Errors | Error rates with 4XX and 5XX status codes | API reliability and user experience issues, including top routes |
|
||||
| Response Speed | Average response time for PostgREST requests | Performance bottlenecks and optimization opportunities, including top routes |
|
||||
| Network Traffic | Ingress and egress usage | Data transfer costs and CDN effectiveness |
|
||||
|
||||
## Realtime
|
||||
|
||||
@@ -291,7 +429,7 @@ The Realtime report tracks WebSocket connections, channel activity, and real-tim
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Realtime Connections | Active WebSocket connections over time | Concurrent user activity and connection stability |
|
||||
| Connected Clients | Active WebSocket connections over time | Concurrent user activity and connection stability |
|
||||
| Broadcast Events | Broadcast events over time | Real-time feature usage patterns |
|
||||
| Presence Events | Presence events over time | Real-time feature usage patterns |
|
||||
| Postgres Changes Events | Postgres Changes events over time | Real-time feature usage patterns |
|
||||
@@ -302,24 +440,25 @@ The Realtime report tracks WebSocket connections, channel activity, and real-tim
|
||||
| Total Requests | HTTP requests to Realtime endpoints | API usage alongside WebSocket activity |
|
||||
| Response Speed | Performance of Realtime API endpoints | Infrastructure optimization opportunities |
|
||||
|
||||
## Edge Functions
|
||||
### Realtime API Gateway
|
||||
|
||||
The Edge Functions report provides insights into serverless function performance, execution patterns, and regional distribution across Supabase's global edge network.
|
||||
The Realtime API Gateway reports focus on API requests related to Realtime functionality.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| ---------------------- | ----------------------------------------- | ---------------------------------------------- |
|
||||
| Execution Status Codes | Function response codes and error rates | Function reliability and error patterns |
|
||||
| Execution Time | Average function duration and performance | Performance optimization opportunities |
|
||||
| Invocations by Region | Geographic distribution of function calls | Global usage patterns and latency optimization |
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | ------------------------------------- | --------------------------------------------------------------- |
|
||||
| Total Requests | HTTP requests to Realtime endpoints | API usage alongside WebSocket activity, including top routes |
|
||||
| Response Errors | HTTP requests to Realtime endpoints | API usage alongside WebSocket activity, including top routes |
|
||||
| Response Speed | Performance of Realtime API endpoints | Infrastructure optimization opportunities, including top routes |
|
||||
|
||||
## API gateway
|
||||
## Storage
|
||||
|
||||
The API Gateway report analyzes traffic patterns and performance characteristics of requests flowing through your Supabase project's API layer.
|
||||
The Storage report provides visibility into how your Supabase Storage is being utilized, including request patterns, performance characteristics, and caching effectiveness.
|
||||
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------------ |
|
||||
| Total Requests | Overall API request volume | Traffic patterns and growth trends |
|
||||
| Response Errors | Error rates with 4XX and 5XX status codes | API reliability and user experience issues |
|
||||
| Response Speed | Average API response times | Performance bottlenecks and optimization targets |
|
||||
| Network Traffic | Request and response egress usage | Data transfer patterns and cost implications |
|
||||
| Top Routes | Most frequently accessed API endpoints | Usage patterns and optimization priorities |
|
||||
| Chart | Description | Key Insights |
|
||||
| --------------- | ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| Total Requests | Overall request volume to Storage | Traffic patterns and usage trends, including top routes |
|
||||
| Response Speed | Average response time for storage requests | Performance bottlenecks and optimization opportunities, including top routes |
|
||||
| Network Traffic | Ingress and egress usage | Data transfer costs and CDN effectiveness |
|
||||
| Request Caching | Cache hit rates and miss patterns | CDN performance and cost optimization, including top routes |
|
||||
|
||||
</$Show>
|
||||
@@ -9,16 +9,21 @@ database_id = "ba593989-12b5-464f-8ede-a525e1ca2ffb"
|
||||
|
||||
**What is Egress?**
|
||||
|
||||
Egress (also known as bandwidth) is any amount of network packets/bytes being streamed to a connected client from your project. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor.
|
||||
Egress (also known as bandwidth) is any amount of network packets/bytes being streamed to a connected client from your project, i.e. data that is leaving the Supabase platform. Egress in Supabase includes any data sent from the data API (PostgREST), Storage, Realtime, Auth, Edge Functions, Database and Supavisor.
|
||||
|
||||
You can read about Unified egress, included quota, and how to check the egress usage here: https://supabase.com/docs/guides/platform/manage-your-usage/egress. Additionally, the [project reports](/dashboard/project/_/observability) have a few egress related stats. You can create a custom report to look into daily egress.
|
||||
You can read about Unified egress, included quota, and how to understand usage in the [egress](/docs/guides/platform/manage-your-usage/egress).
|
||||
|
||||
- Example: Log Explorer -> Custom Reports -> Add/Remove charts -> Database API -> API Egress.
|
||||
You can review the [Billing Dashboard](/dashboard/org/_/usage#egress) for egress usage. Alternatively, the [project reports](/dashboard/project/_/observability) have a few egress related stats. You can create a custom report to look into daily egress.
|
||||
|
||||
- Example: [Custom Reports](/dashboard/project/_/observability/api-overview?newReport=true) -> Add/Remove charts -> Database API -> API Egress.
|
||||
|
||||

|
||||
|
||||
**What is contributing to Egress?**
|
||||
|
||||
While pointing out the exact cause for egress may not be straightforward, there are various steps you can take to determine the source of these issues:
|
||||
|
||||
- Review your egress usage at the project and org level in the [Billing Dashboard](/dashboard/org/_/usage#egress)
|
||||
- Picking the "Top Paths" from the [log explorer](https://app.supabase.com/project/_/logs/explorer/templates) will help you identify heavily queried paths
|
||||
- By finding the most requested queries from Query performance report: https://app.supabase.com/project/_/advisors/query-performance
|
||||
- Supavisor Egress is independent of client. There is no direct relation between a single query and Supavisor egress, it is harder to debug and identify. But you can make use of frequent queries from the link in above step that also displays average number of rows which will help to identify queries with a large number of rows returned. While this does not display Supavisor queries specifically, it will give an overview of queries with lots of rows that can help.
|
||||
@@ -29,7 +34,7 @@ While pointing out the exact cause for egress may not be straightforward, there
|
||||
|
||||
- Reduce the number of fields selected or entries when querying
|
||||
- Reduce the number of queries/calls by optimising client code or use caches to reduce the number of requests/queries being done: https://github.com/psteinroe/supabase-cache-helpers/
|
||||
- In case of update/insert queries, if you don’t need the entire row to be returned, configure your ORM/queries to not return the entire row
|
||||
- In case of update/insert queries, if you don't need the entire row to be returned, configure your ORM/queries to not return the entire row
|
||||
- In case of running manual backups through Supavisor, remove unneeded tables and/or reduce the frequency
|
||||
- For Storage, if you start using the [Smart CDN](/docs/guides/storage/cdn/smart-cdn) Storage Egress usage can be reduced. You can also use the [Supabase Image Transformations](/docs/guides/storage/image-transformations) to optimize the images and reduce the egress.
|
||||
|
||||
|
||||
@@ -1,57 +1,328 @@
|
||||
---
|
||||
title = "Edge Function 546 error response"
|
||||
title = "546 - WORKER_LIMIT Exceeded"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "546", "error", "resource", "memory", "cpu", "event loop", "edge function" ]
|
||||
database_id = "4e6ff2e4-2abb-4fba-8233-5883b3d56fb0"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 546
|
||||
message = "Edge Function error"
|
||||
message = "WORKER_LIMIT"
|
||||
---
|
||||
|
||||
The 546 error typically indicates resource exhaustion or code issues in your Edge Function.
|
||||
A 546 error indicates that an edge function used more resources (CPU or Memory) than it was allocated.
|
||||
|
||||
## Common causes
|
||||
## Context for the error
|
||||
|
||||
### Memory or CPU limits
|
||||
Edge functions run in transient servers called **isolates**. Each isolate:
|
||||
|
||||
Your function may have exceeded available resources. Check the resource usage metrics in your dashboard at Functions > [Your Function] > Metrics.
|
||||
- Handles one request at a time
|
||||
- Is bound to a single function (e.g. an isolate for `func_one` will never serve `func_two`)
|
||||
|
||||
### Event loop completion
|
||||
When a request arrives, the runtime assigns it to a free isolate or spins up a new one if all existing isolates are busy. Each isolate also has resource limitations.
|
||||
|
||||
If logs show "Event loop completed," your function has implementation issues. Check your function code for:
|
||||
| Resource | Limit |
|
||||
| ---------- | ----- |
|
||||
| CPU cycles | 2s |
|
||||
| Memory | 250MB |
|
||||
|
||||
- Syntax errors
|
||||
- Infinite loops
|
||||
- Unresolved promises
|
||||
Once an isolate uses 50% of any resource, it will finish the current request and then shut down.
|
||||
|
||||
## Debugging steps
|
||||
However, if that remaining request exhausts all CPU or memory before completion, the isolate will terminate immediately and return a 546 response.
|
||||
|
||||
### Run the function locally
|
||||
## Solving the error
|
||||
|
||||
Use the Supabase CLI to run your function locally and get detailed stack traces:
|
||||
### Step 1: Identifying the error
|
||||
|
||||
```bash
|
||||
When an edge function fails due to internal CPU or memory limits, it will return the error:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "WORKER_LIMIT",
|
||||
"message": "Function failed due to not having enough compute resources (please check logs)"
|
||||
}
|
||||
```
|
||||
|
||||
In the [function dashboard's](/dashboard/project/_/functions/) `Logs` tab, you can find the specific error message:
|
||||
|
||||
- `Memory limit exceeded`
|
||||
- `CPU Time exceeded`
|
||||
|
||||

|
||||
|
||||
Alternatively, you can filter for the specific errors from the function using the [log explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++fl.event_message%2C%0A++content.timestamp%2C%0A++fel.function_name%2C%0A++fel.status_code%0AFROM+function_logs+fl%0ALEFT+JOIN+UNNEST%28fl.metadata%29+AS+content+ON+TRUE%0ALEFT+JOIN+%28%0A++SELECT%0A++++em.execution_id%2C%0A++++req.pathname+AS+function_name%2C%0A++++res.status_code%0A++FROM+function_edge_logs%0A++LEFT+JOIN+UNNEST%28metadata%29+AS+em+ON+TRUE%0A++LEFT+JOIN+UNNEST%28em.request%29+AS+req+ON+TRUE%0A++LEFT+JOIN+UNNEST%28em.response%29+AS+res+ON+TRUE%0A%29+fel+ON+content.execution_id+%3D+fel.execution_id%0AWHERE+%0A++content.level+%3D+%27error%27%0A++++AND%0A++fel.status_code+%3D+546%0AORDER+BY+function_name%2C+timestamp%0ALIMIT+5)
|
||||
|
||||
```sql
|
||||
select
|
||||
fl.event_message,
|
||||
content.timestamp,
|
||||
fel.function_name,
|
||||
fel.status_code
|
||||
from
|
||||
function_logs as fl
|
||||
left join UNNEST(fl.metadata) as content on true
|
||||
left join (
|
||||
select
|
||||
em.execution_id,
|
||||
req.pathname as function_name,
|
||||
res.status_code
|
||||
from
|
||||
function_edge_logs
|
||||
left join UNNEST(metadata) as em on true
|
||||
left join UNNEST(em.request) as req on true
|
||||
left join UNNEST(em.response) as res on true
|
||||
) as fel
|
||||
on content.execution_id = fel.execution_id
|
||||
where content.level = 'error' and fel.status_code = 546
|
||||
order by timestamp, function_name
|
||||
limit 20;
|
||||
```
|
||||
|
||||
## Step 2: Check error frequency
|
||||
|
||||
Before optimizing, run the below query in the [Log Explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++COUNT%28id%29+AS+total_responses%2C%0A++COUNTIF%28response.status_code+%3D+546%29+AS+total_546%2C%0A++SAFE_DIVIDE%28COUNTIF%28response.status_code+%3D+546%29%2C+COUNT%28*%29%29+*+100+AS+pct_546%0AFROM+function_edge_logs%0ACROSS+JOIN+UNNEST%28function_edge_logs.metadata%29+AS+metadata%0ACROSS+JOIN+UNNEST%28metadata.response%29+AS+response%0ACROSS+JOIN+UNNEST%28metadata.request%29+AS+request%0AWHERE+pathname+%3D+%27%2Ffunctions%2Fv1%2FYOUR_FUNCTION_NAME%27+) to understand how often 546s are occurring relative to total requests:
|
||||
|
||||
```sql
|
||||
select
|
||||
COUNT(id) as total_responses,
|
||||
COUNTIF(response.status_code = 546) as total_546,
|
||||
SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(*)) * 100 as pct_546
|
||||
from
|
||||
function_edge_logs
|
||||
cross join UNNEST(function_edge_logs.metadata) as metadata
|
||||
cross join UNNEST(metadata.response) as response
|
||||
cross join UNNEST(metadata.request) as request
|
||||
where method != 'OPTIONS' and pathname = '/functions/v1/YOUR_FUNCTION_NAME';
|
||||
-- <-- add your function name to inspect specific endpoints
|
||||
```
|
||||
|
||||
Depending on the results, you may be able to determine if the event is an edge case or affecting a function's overall behavior.
|
||||
|
||||
### Interpreting the results
|
||||
|
||||
| 546-rate | What it likely means |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| < 5% | May be an anomaly or edge case with how your function is structure or responds to payloads. May be acceptable for your use case |
|
||||
| 5-50% | Affecting a meaningful portion of traffic |
|
||||
| > 50% | Nearly all requests are over-resourced; the function needs significant work |
|
||||
|
||||
## Step 2: Narrowing down the cause
|
||||
|
||||
### Experimenting locally
|
||||
|
||||
{/* Note to future maintainers: it would be really nice to have copy/paste commands for setting up a local environment right in the guide */}
|
||||
|
||||
The same constraints placed on edge function's hosted by Supabase are also imposed by the test environment spun-up by the CLI. You can follow the function's [local development guide](/docs/guides/functions/quickstart) to set up a test environment and then serve your function locally:
|
||||
|
||||
```sh
|
||||
supabase functions serve your-function --debug
|
||||
```
|
||||
|
||||
The local console provides full stack traces with line numbers, making it easier to identify the source of errors.
|
||||
Then try experimenting with different stress tests to see if you can induce 546s. Some tests worth trying may involve:
|
||||
|
||||
### Check function logs
|
||||
- sending a large payload
|
||||
- testing varying paths or query parameters
|
||||
- sending multiple requests at once
|
||||
|
||||
Navigate to Functions > [Your Function] > Logs in the dashboard. Look for:
|
||||
If you find a reliable way to induce the error, you may want to [log](/docs/guides/functions/logging) between operations to gain more visibility or [configure chrome dev-tools](/docs/guides/functions/debugging-tools) to pinpoint the underlying logic that is failing.
|
||||
|
||||
- Error messages
|
||||
- Resource usage warnings
|
||||
- Shutdown events with specific reasons
|
||||
### Exploring for failure patterns in the logs
|
||||
|
||||
### Review example implementations
|
||||
There are a few other queries that may be useful for identifying patterns around 546 errors.
|
||||
|
||||
Compare your function against working examples in the [Edge Functions examples repository](https://github.com/supabase/supabase/tree/master/examples/edge-functions).
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
chevronAlign="right"
|
||||
justified
|
||||
size="medium"
|
||||
className="text-foreground-light mt-8 mb-6"
|
||||
>
|
||||
<div className="border-b mt-3 pb-3">
|
||||
<AccordionItem
|
||||
header="Checking if a specific version is an offender"
|
||||
id="item-1"
|
||||
>
|
||||
Every time you update a function, its version number increments. It may be that you made an update and it's only a specific version that is problematic.
|
||||
|
||||
You can check error frequency by version with the below query:
|
||||
|
||||
```sql
|
||||
select
|
||||
COUNT(id) as total_responses,
|
||||
version,
|
||||
COUNTIF(response.status_code = 546) as total_546,
|
||||
SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(*)) * 100 as pct_546
|
||||
from
|
||||
function_edge_logs
|
||||
cross join UNNEST(function_edge_logs.metadata) as metadata
|
||||
cross join UNNEST(metadata.response) as response
|
||||
cross join UNNEST(metadata.request) as request
|
||||
where method != 'OPTIONS' and pathname = '/functions/v1/FUNCTION_NAME' -- <--OPTIONAL FILTER: add specific function name to target query
|
||||
group by version
|
||||
having pct_546 > 5 -- <--Failure percentage threshold. The query only shows versions with a 5% or above 546 error rate
|
||||
order by pct_546;
|
||||
```
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</div>
|
||||
<div className="border-b mt-3 pb-3">
|
||||
<AccordionItem
|
||||
header="Check error frequency by time"
|
||||
id="item-2"
|
||||
>
|
||||
|
||||
You can check to see how frequent 546 errors are per hour with the below query:
|
||||
|
||||
```sql
|
||||
|
||||
SELECT
|
||||
FORMAT_TIMESTAMP("%Y-%m-%d %H:00", TIMESTAMP(timestamp), "UTC") AS hour,
|
||||
COUNT(id) AS total_responses,
|
||||
|
||||
COUNTIF(response.status_code = 546) AS total_546,
|
||||
|
||||
SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(id)) \* 100
|
||||
AS pct_546
|
||||
|
||||
FROM function_edge_logs
|
||||
CROSS JOIN UNNEST(function_edge_logs.metadata) AS metadata
|
||||
CROSS JOIN UNNEST(metadata.response) AS response
|
||||
CROSS JOIN UNNEST(metadata.request) AS request
|
||||
WHERE pathname = '/functions/v1/FUNCTION_NAME'--<--OPTIONAL FILTER: add specific function name to target query
|
||||
group by hour
|
||||
ORDER by hour DESC
|
||||
LIMIT 24;
|
||||
|
||||
````
|
||||
|
||||
The output may look like:
|
||||

|
||||
|
||||
If the failures are concentrated to a specific time, you can check if you made any updates around that period or if users were engaging in atypical behavior, such as sending larger payloads.
|
||||
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="border-b mt-3 pb-3">
|
||||
<AccordionItem
|
||||
header="Check requests per isolate"
|
||||
id="item-3"
|
||||
>
|
||||
|
||||
If your isolates are serving more than 2 requests before retiring, it suggests variability in how much processing each request needs. In that case, you may want to cross compare your successful requests with your failed ones. Maybe there's a query parameter or specific content-length header that makes failures more likely.
|
||||
|
||||
```sql
|
||||
|
||||
SELECT
|
||||
COUNT(fel.id) AS requests_served,
|
||||
metadata.execution_id AS isolate_id
|
||||
FROM function_logs
|
||||
LEFT JOIN UNNEST(function_logs.metadata) AS metadata ON TRUE
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
em.execution_id,
|
||||
id,
|
||||
pathname,
|
||||
method
|
||||
FROM function_edge_logs
|
||||
LEFT JOIN UNNEST(function_edge_logs.metadata) AS em ON TRUE
|
||||
LEFT JOIN UNNEST(em.request) AS req ON TRUE
|
||||
) fel ON metadata.execution_id = fel.execution_id
|
||||
WHERE
|
||||
metadata.reason IN ('Memory', 'CPUTime')
|
||||
AND
|
||||
method <> 'OPTIONS' --ignore OPTION requests
|
||||
AND
|
||||
pathname = '/functions/v1/FUNCTION_NAME' --<-- add your function name to inspect specific endpoints
|
||||
GROUP BY metadata.execution_id
|
||||
````
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
## Step 3: Correcting the error
|
||||
|
||||
The only way to manage the error is to reduce resource consumption per request. There are a few strategies one can go about.
|
||||
|
||||
### 1. Refactor logic:
|
||||
|
||||
If you believe a portion of your function is overly aggressive, try testing locally whether refactoring reduces resource overuse.
|
||||
|
||||
Common culprits:
|
||||
|
||||
**CPU intensive recursions**: intensive loops or recursion can quickly exhaust CPU
|
||||
|
||||
```js
|
||||
// This will exhaust CPU allocation when called repeatedly
|
||||
function fib(n: number): number {
|
||||
if (n <= 1) return n;
|
||||
return fib(n - 1) + fib(n - 2); // high levels of recursion
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fib(40);
|
||||
}
|
||||
```
|
||||
|
||||
**Unbounded memory allocation**: filling large arrays in a tight loop prevents the garbage collector from freeing memory
|
||||
|
||||
```js
|
||||
// Each iteration allocates ~100s of KB. During the loops, all memory is consumed before GC can intervene
|
||||
let ref = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ref.push(new Array(10e4).fill('data'))
|
||||
}
|
||||
```
|
||||
|
||||
You can compare your function against working examples in the [Edge Function docs](/docs/guides/functions#examples) for insight on how to rework your code.
|
||||
|
||||
### 2. Swap in a lighter package:
|
||||
|
||||
If you're using a dependency that does more than you need, look for a lighter or more performant alternative.
|
||||
|
||||
### 3. Offload operations to the database:
|
||||
|
||||
If you are performing logic to process data from Supabase Postgres, you may be able to handle the processing within the database directly by using [database functions](/docs/guides/database/functions?queryGroups=language&language=js) or refactored queries.
|
||||
|
||||
### 4. Offload operations to an external API:
|
||||
|
||||
Instead of managing all operations within the function itself, there may be an external API that can execute CPU or memory intensive jobs on its behalf. One [example](/docs/guides/functions/examples/screenshots) would be using an external API for orchestrating a headless browser and then just using the edge function to manage the output of the activity instead of everything all in place.
|
||||
|
||||
### 5. Split operations into individual functions:
|
||||
|
||||
Break a large function into smaller ones, each responsible for a single sub-task. Stitch the results together at the app level or via an orchestrating function.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
If you have functions that call other functions, always implement an escape condition. Supabase will terminate functions that recursively self-call past a certain depth, but your code should enforce its own limit.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### 6. Move to a less restrictive platform:
|
||||
|
||||
Edge functions have a hard resource limit. If your work requires more resources than we permit, you can look into other solutions, such as AWS Lambda, that are less restrictive, or [self-host edge functions](/docs/reference/self-hosting-functions/introduction) and reconfigure the settings.
|
||||
|
||||
## Example cases
|
||||
|
||||
### Image processing
|
||||
|
||||
Performing edits against images or other large files can be both CPU and Memory intensive. Some approaches for reducing load is using more performant processing libraries, processing outside by using an API or the requester's server, or restricting the file size to reduce strain.
|
||||
|
||||
### AI embedding generation and inference
|
||||
|
||||
AI models process data into embeddings (large arrays), that they can more understand. Edge Functions are capable of managing [some small models directly](/blog/ai-inference-now-available-in-supabase-edge-functions); however, some require more processing power than what the edge function can support directly. In these cases, the solution is to manage the embeddings via an external source, such as OpenAI, Anthropic, etc. and to just use the edge function for light processing and coordination.
|
||||
|
||||
### Web scraping
|
||||
|
||||
Web scraping often requires a headless browser operator, such as [puppeteer](https://pptr.dev/) or [playwright](https://playwright.dev/) for rendering web pages. In this case, it is better to use an external API to manage the headless browser for you and then parse the results it returns with the edge function. There's an example in the function docs: [Taking Screenshots with Puppeteer](/docs/guides/functions/examples/screenshots)
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [Edge Function shutdown reasons explained](./edge-function-shutdown-reasons-explained)
|
||||
- [Edge Function wall clock time limit reached](./edge-function-wall-clock-time-limit-reached-Nk38bW)
|
||||
- [Monitoring resource usage](./edge-function-monitoring-resource-usage)
|
||||
- [Debugging Edge Functions](/docs/guides/functions/logging)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title = "Failed to retrieve tables"
|
||||
topics = [ "database", "studio" ]
|
||||
keywords = ["failed to retrieve tables", "connection timeout", "522", "525"]
|
||||
|
||||
[[errors]]
|
||||
|
||||
message = "Failed to retrieve tables"
|
||||
---
|
||||
|
||||
First, you can verify if this is isolated to your project or if it's a platform issue by viewing our [status page](https://status.supabase.com/).
|
||||
|
||||
If there are no issues, then it's likely a problem with your project.
|
||||
|
||||
This error occurs when the Studio (dashboard) is unable to retrieve the tables from the database. Generally, this is because the database is in a crashed state, which usually happens due to an out of memory error.
|
||||
Trying to run a SQL query will often result in a timeout error like this:
|
||||
|
||||
```
|
||||
connection terminated due to query timeout
|
||||
```
|
||||
|
||||
Trying to connect to the project via the API will often result in a 522 or 525 response code.
|
||||
|
||||
## Why this happens
|
||||
|
||||
Out of memory errors usually happen because of a sudden spike in database activity, or a sustained high level of activity, either due to a high volume of queries or very complex queries (or a combination of both).
|
||||
|
||||
Note that Nano, Micro, Small and Medium compute instances have 30 minutes of burst capacity on a daily basis so may be able to handle sustained high activity for a short period of time,
|
||||
but not sudden spikes.
|
||||
|
||||
## Next steps and preventative measures
|
||||
|
||||
First:
|
||||
|
||||
- Immediately stop any ongoing queries to the database.
|
||||
- Restart the instance from the project settings in the dashboard once you are confident there are no large ongoing queries (if there are, it may crash again). You can do that on [this page](/dashboard/project/_/settings/general). If it crashes again, re-review the logs to understand what may be causing the issue.
|
||||
|
||||
Once you are confident there will not be a crash loop, you can review the following steps:
|
||||
|
||||
- If this was intentional activity (often it's a case of uploading, or reading, a lot of test data), and it needs to be done again, batch requests into smaller chunks and reduce concurrency, and implementation pagination for select queries. Check out our [query optimisation docs](/docs/guides/database/query-optimization) for more information around general ideas here.
|
||||
- If it was unintentional, double check for any recursive calls in your application code, edge functions or database functions.
|
||||
- Consider your table and your query structure - if your tables are very "wide" (lots of columns) or have complicated data types within them, it may be worth revisiting your architecture.
|
||||
- Continue to monitor your project's [query performance tab](/dashboard/project/_/observability/query-performance) and [enable index advisor](/docs/guides/database/extensions/index_advisor) if you haven't already - especially if there are a lot of select queries.
|
||||
- If after monitoring your changes you still do not notice improvements, consider upgrading compute if you think this level of activity is going to be regular. It will give you more memory overhead to process tasks like this. You can view all compute offerings [here](/dashboard/project/_/settings/compute-and-disk).
|
||||
|
||||
If you want to effectively monitor your project's performance minute by minute, you can use the [Metrics API](/docs/guides/telemetry/metrics).
|
||||
@@ -115,6 +115,7 @@ Jim Chanco Jr
|
||||
Joakim Ahrlin
|
||||
Joaquim Moreno
|
||||
Joel Low
|
||||
John Fitzgerald
|
||||
Joel Martin
|
||||
Johan Bergström
|
||||
John Pena
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -326,7 +326,6 @@
|
||||
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx": 1,
|
||||
"components/interfaces/Functions/EdgeFunctionsListItem.tsx": 2,
|
||||
"components/interfaces/Functions/FunctionsNav.tsx": 1,
|
||||
"components/interfaces/Home/Home.tsx": 6,
|
||||
"components/interfaces/Home/ProjectList/ProjectList.tsx": 1,
|
||||
"components/interfaces/Home/ProjectUsage.tsx": 1,
|
||||
"components/interfaces/Home/ServiceStatus.tsx": 1,
|
||||
|
||||
@@ -65,15 +65,20 @@ export const HeaderNew = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-10 items-center justify-between bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2 overflow-x-auto ">
|
||||
<div className="flex flex-wrap min-h-10 items-center bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2">
|
||||
{customHeader ? (
|
||||
customHeader
|
||||
) : snap.selectedRows.size > 0 ? (
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
) : (
|
||||
<DefaultHeader tableQueriesEnabled={tableQueriesEnabled} isRefetching={isRefetching} />
|
||||
<div className="flex-1 min-w-[300px] flex items-center gap-2">
|
||||
<FilterPopoverNew isRefetching={isRefetching} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{!customHeader && snap.selectedRows.size === 0 && (
|
||||
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
|
||||
)}
|
||||
<GridHeaderActions table={snap.originalTable} isRefetching={isRefetching} />
|
||||
{showInsertButton && <InsertButton />}
|
||||
</div>
|
||||
@@ -82,20 +87,6 @@ export const HeaderNew = ({
|
||||
)
|
||||
}
|
||||
|
||||
const DefaultHeader = ({
|
||||
tableQueriesEnabled = true,
|
||||
isRefetching,
|
||||
}: Pick<HeaderProps, 'tableQueriesEnabled' | 'isRefetching'>) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<FilterPopoverNew isRefetching={isRefetching} />
|
||||
</div>
|
||||
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const InsertButton = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
@@ -207,7 +207,7 @@ export const FilterPopoverNew = ({ isRefetching = false }: FilterPopoverProps) =
|
||||
actions={actions}
|
||||
isLoading={isGenerating}
|
||||
variant="pill"
|
||||
className="bg-transparent border-0"
|
||||
className="bg-transparent border-0 overflow-visible"
|
||||
icon={icon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
|
||||
export const ConnectedAppsBannerPreview = () => {
|
||||
const { slug = '_', ref = '_' } = useParams()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground-light mb-4">
|
||||
Show a project-level awareness banner when your organization has connected OAuth apps.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
|
||||
<li>
|
||||
Project scope:{' '}
|
||||
<InlineLink href={`/project/${ref}/editor`}>Visible across project pages</InlineLink>
|
||||
</li>
|
||||
<li>
|
||||
Manage connections:{' '}
|
||||
<InlineLink href={`/org/${slug}/apps`}>Organization OAuth apps</InlineLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -119,6 +119,15 @@ export const useIsPlatformWebhooksEnabled = () => {
|
||||
return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]
|
||||
}
|
||||
|
||||
export const useIsProjectOauthIntegrationsBannerEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
const projectOauthIntegrationsBannerEnabled = useFlag('projectOauthIntegrationsBanner')
|
||||
return (
|
||||
projectOauthIntegrationsBannerEnabled &&
|
||||
flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER]
|
||||
)
|
||||
}
|
||||
|
||||
export const useIsTableFilterBarEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AdvisorRulesPreview } from './AdvisorRulesPreview'
|
||||
import { APISidePanelPreview } from './APISidePanelPreview'
|
||||
import { Branching2Preview } from './Branching2Preview'
|
||||
import { CLSPreview } from './CLSPreview'
|
||||
import { ConnectedAppsBannerPreview } from './ConnectedAppsBannerPreview'
|
||||
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
|
||||
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
|
||||
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
|
||||
@@ -44,6 +45,8 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: <QueueOperationsPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: <TableFilterBarPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER]:
|
||||
<ConnectedAppsBannerPreview />,
|
||||
}
|
||||
|
||||
export const FeaturePreviewModal = () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar')
|
||||
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
|
||||
const platformWebhooksEnabled = useFlag('platformWebhooks')
|
||||
const projectOauthIntegrationsBannerEnabled = useFlag('projectOauthIntegrationsBanner')
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -66,6 +67,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
isDefaultOptIn: false,
|
||||
enabled: platformWebhooksEnabled,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER,
|
||||
name: 'Connected apps banner',
|
||||
discussionsUrl: undefined,
|
||||
isNew: true,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: false,
|
||||
enabled: projectOauthIntegrationsBannerEnabled,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL,
|
||||
name: 'Project API documentation',
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { SpamValidation } from './SpamValidation'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
template: FormSchema
|
||||
@@ -202,21 +203,6 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
}
|
||||
}, [authConfig, properties, messageSlug, form])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef && id && !!authConfig) {
|
||||
const [subjectKey] = Object.keys(properties)
|
||||
@@ -376,6 +362,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -32,6 +32,7 @@ import {
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
|
||||
|
||||
@@ -271,7 +272,7 @@ export const PolicyDetailsV2 = ({
|
||||
form.setValue('command', value)
|
||||
onUpdateCommand(value)
|
||||
}}
|
||||
className={`grid grid-cols-10 gap-3 ${isEditing ? 'opacity-50' : ''}`}
|
||||
className={cn('flex flex-wrap gap-3', isEditing && 'opacity-50')}
|
||||
>
|
||||
{[
|
||||
'select',
|
||||
@@ -283,7 +284,7 @@ export const PolicyDetailsV2 = ({
|
||||
value={x}
|
||||
disabled={isEditing}
|
||||
label={x.toLocaleUpperCase()}
|
||||
className={`col-span-2 w-auto ${isEditing ? 'cursor-not-allowed' : ''}`}
|
||||
className={cn('w-auto', isEditing && 'cursor-not-allowed')}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup_Shadcn_>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
|
||||
export type ColumnEditionContextType = {
|
||||
onEditColumn: (tableId: number, columnId: string) => void
|
||||
}
|
||||
|
||||
export const ColumnEditionContext = createContext<ColumnEditionContextType | null>(null)
|
||||
|
||||
export const ColumnEditionContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: ColumnEditionContextType
|
||||
}) => <ColumnEditionContext.Provider value={value}>{children}</ColumnEditionContext.Provider>
|
||||
|
||||
export const useColumnEditionContext = () => {
|
||||
const context = useContext(ColumnEditionContext)
|
||||
if (!context)
|
||||
throw new Error('useColumnEditionContext must be used inside a <ColumnEditionContextProvider>')
|
||||
return context
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PostgresSchema } from '@supabase/postgres-meta'
|
||||
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { toPng, toSvg } from 'html-to-image'
|
||||
import { Check, Copy, Download, Loader2, Plus } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
|
||||
|
||||
import 'reactflow/dist/style.css'
|
||||
@@ -22,6 +22,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
|
||||
import { tablesToSQL } from 'lib/helpers'
|
||||
import { toast } from 'sonner'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import {
|
||||
Button,
|
||||
copyToClipboard,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor'
|
||||
import { ColumnEditionContextProvider, ColumnEditionContextType } from './ColumnEditionContext'
|
||||
import { SchemaGraphLegend } from './SchemaGraphLegend'
|
||||
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
|
||||
import { TableNode } from './SchemaTableNode'
|
||||
@@ -43,6 +46,8 @@ export const SchemaGraph = () => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
const [selectedTable, setSelectedTable] = useState<PostgresTable | null>(null)
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
@@ -191,17 +196,38 @@ export const SchemaGraph = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstLoad = useRef(true)
|
||||
useEffect(() => {
|
||||
if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
|
||||
const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema
|
||||
getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => {
|
||||
reactFlowInstance.setNodes(nodes)
|
||||
reactFlowInstance.setEdges(edges)
|
||||
setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
|
||||
// Prevent resetting a view after first load to avoid layout changes after editing a column
|
||||
if (isFirstLoad.current) {
|
||||
isFirstLoad.current = false
|
||||
setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isSuccessTables, isSuccessSchemas, tables, resolvedTheme])
|
||||
|
||||
const columnEditionContext = useMemo<ColumnEditionContextType>(
|
||||
() => ({
|
||||
onEditColumn: (tableId, columnId) => {
|
||||
const table = tables.find((table) => table.id === tableId)
|
||||
if (!table || table.columns == null) return
|
||||
|
||||
const column = table.columns.find((column) => column.id === columnId)
|
||||
if (!column) return
|
||||
|
||||
setSelectedTable(table)
|
||||
snap.onEditColumn(column)
|
||||
},
|
||||
}),
|
||||
[tables, snap]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-4 border-b border-muted h-[var(--header-height)]">
|
||||
@@ -323,41 +349,44 @@ export const SchemaGraph = () => {
|
||||
</Admonition>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
defaultNodes={[]}
|
||||
defaultEdges={[]}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
deletable: false,
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.8}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeDragStop={() => saveNodePositions()}
|
||||
>
|
||||
<Background
|
||||
gap={16}
|
||||
className="[&>*]:stroke-foreground-muted opacity-[25%]"
|
||||
variant={BackgroundVariant.Dots}
|
||||
color={'inherit'}
|
||||
/>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={miniMapNodeColor}
|
||||
maskColor={miniMapMaskColor}
|
||||
className="border rounded-md shadow-sm"
|
||||
/>
|
||||
<SchemaGraphLegend />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<ColumnEditionContextProvider value={columnEditionContext}>
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
defaultNodes={[]}
|
||||
defaultEdges={[]}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
deletable: false,
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.8}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeDragStop={() => saveNodePositions()}
|
||||
>
|
||||
<Background
|
||||
gap={16}
|
||||
className="[&>*]:stroke-foreground-muted opacity-[25%]"
|
||||
variant={BackgroundVariant.Dots}
|
||||
color={'inherit'}
|
||||
/>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={miniMapNodeColor}
|
||||
maskColor={miniMapMaskColor}
|
||||
className="border rounded-md shadow-sm"
|
||||
/>
|
||||
<SchemaGraphLegend />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</ColumnEditionContextProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SidePanelEditor selectedTable={selectedTable ?? undefined} includeColumns />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils'
|
||||
import { DiamondIcon, ExternalLink, Fingerprint, Hash, InfoIcon, Key, Table2 } from 'lucide-react'
|
||||
import {
|
||||
DiamondIcon,
|
||||
Edit,
|
||||
ExternalLink,
|
||||
Fingerprint,
|
||||
Hash,
|
||||
InfoIcon,
|
||||
Key,
|
||||
Table2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Handle, NodeProps } from 'reactflow'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useColumnEditionContext } from './ColumnEditionContext'
|
||||
|
||||
// ReactFlow is scaling everything by the factor of 2
|
||||
export const TABLE_NODE_WIDTH = 320
|
||||
export const TABLE_NODE_ROW_HEIGHT = 40
|
||||
@@ -35,6 +46,7 @@ export const TableNode = ({
|
||||
// Important styles is a nasty hack to use Handles (required for edges calculations), but do not show them in the UI.
|
||||
// ref: https://github.com/wbkd/react-flow/discussions/2698
|
||||
const hiddenNodeConnector = '!h-px !w-px !min-w-0 !min-h-0 !cursor-grab !border-0 !opacity-0'
|
||||
const columnEditionContext = useColumnEditionContext()
|
||||
|
||||
const itemHeight = 'h-[22px]'
|
||||
|
||||
@@ -101,6 +113,8 @@ export const TableNode = ({
|
||||
'border-t',
|
||||
'border-t-[0.5px]',
|
||||
'hover:bg-scale-500 transition cursor-default',
|
||||
'group',
|
||||
'pr-1',
|
||||
itemHeight
|
||||
)}
|
||||
key={column.id}
|
||||
@@ -144,7 +158,7 @@ export const TableNode = ({
|
||||
<span className="text-ellipsis overflow-hidden whitespace-nowrap max-w-[85px]">
|
||||
{column.name}
|
||||
</span>
|
||||
<span className="px-2 inline-flex justify-end font-mono text-lighter text-[0.4rem]">
|
||||
<span className="pl-2 pr-1 inline-flex justify-end font-mono text-lighter text-[0.4rem] group-hover:hidden">
|
||||
{column.format}
|
||||
</span>
|
||||
</div>
|
||||
@@ -164,6 +178,23 @@ export const TableNode = ({
|
||||
className={cn(hiddenNodeConnector, '!right-0')}
|
||||
/>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
className="hidden group-hover:inline-block absolute right-0 px-0 mr-1 w-[16px] h-[16px] rounded"
|
||||
onClick={() => {
|
||||
columnEditionContext.onEditColumn(data.id, column.id)
|
||||
}}
|
||||
>
|
||||
<Edit size={10} className="text-foreground-light" />
|
||||
<span className="sr-only">
|
||||
Edit {data.name} {column.name} column
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit column</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Edit2, MoreVertical, Trash } from 'lucide-react'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import type { ProjectSecret } from 'data/secrets/secrets-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Edit2, MoreVertical, Trash } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { AdvisorWidget } from 'components/interfaces/Home/AdvisorWidget'
|
||||
import { ClientLibrary } from 'components/interfaces/Home/ClientLibrary'
|
||||
import { ExampleProject } from 'components/interfaces/Home/ExampleProject'
|
||||
import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants'
|
||||
import { NewProjectPanel } from 'components/interfaces/Home/NewProjectPanel/NewProjectPanel'
|
||||
import { ProjectUsageSection } from 'components/interfaces/Home/ProjectUsageSection'
|
||||
import { ServiceStatus } from 'components/interfaces/Home/ServiceStatus'
|
||||
import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner'
|
||||
import { useBranchesQuery } from 'data/branches/branches-query'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
Badge,
|
||||
cn,
|
||||
Tabs_Shadcn_,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
export const Home = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { data: parentProject } = useProjectDetailQuery({ ref: project?.parent_project_ref })
|
||||
const isOrioleDb = useIsOrioleDb()
|
||||
const snap = useAppStateSnapshot()
|
||||
const { ref, enableBranching } = useParams()
|
||||
|
||||
const { projectHomepageExampleProjects, projectHomepageClientLibraries: clientLibraries } =
|
||||
useCustomContent(['project_homepage:example_projects', 'project_homepage:client_libraries'])
|
||||
|
||||
const {
|
||||
projectHomepageShowInstanceSize: showInstanceSize,
|
||||
projectHomepageShowExamples: showExamples,
|
||||
} = useIsFeatureEnabled(['project_homepage:show_instance_size', 'project_homepage:show_examples'])
|
||||
|
||||
const hasShownEnableBranchingModalRef = useRef(false)
|
||||
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
|
||||
const isNewProject = dayjs(project?.inserted_at).isAfter(dayjs().subtract(2, 'day'))
|
||||
|
||||
useEffect(() => {
|
||||
if (enableBranching && !hasShownEnableBranchingModalRef.current) {
|
||||
hasShownEnableBranchingModalRef.current = true
|
||||
snap.setShowCreateBranchModal(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enableBranching])
|
||||
|
||||
const { data: tablesData, isPending: isLoadingTables } = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: 'public',
|
||||
})
|
||||
const { data: functionsData, isPending: isLoadingFunctions } = useEdgeFunctionsQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
const { data: replicasData, isPending: isLoadingReplicas } = useReadReplicasQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
const { data: branches } = useBranchesQuery({
|
||||
projectRef: project?.parent_project_ref ?? project?.ref,
|
||||
})
|
||||
|
||||
const mainBranch = branches?.find((branch) => branch.is_default)
|
||||
const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref)
|
||||
const isMainBranch = currentBranch?.name === mainBranch?.name
|
||||
let projectName = 'Welcome to your project'
|
||||
|
||||
if (currentBranch && !isMainBranch) {
|
||||
projectName = currentBranch?.name
|
||||
} else if (project?.name) {
|
||||
projectName = project?.name
|
||||
}
|
||||
|
||||
const tablesCount = Math.max(0, tablesData?.length ?? 0)
|
||||
const functionsCount = Math.max(0, functionsData?.length ?? 0)
|
||||
// [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally
|
||||
const replicasCount = Math.max(0, (replicasData?.length ?? 1) - 1)
|
||||
|
||||
if (isPaused) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ProjectPausedState />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-4">
|
||||
<div className={cn('py-16 ', !isPaused && 'border-b border-muted ')}>
|
||||
<div className="mx-auto max-w-7xl flex flex-col gap-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6 justify-between w-full">
|
||||
<div className="flex flex-col md:flex-row md:items-end gap-3 w-full">
|
||||
<div>
|
||||
{!isMainBranch && (
|
||||
<Link
|
||||
href={`/project/${parentProject?.ref}`}
|
||||
className="text-sm text-foreground-light"
|
||||
>
|
||||
{parentProject?.name}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-3xl">{projectName}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2 mb-1">
|
||||
{isOrioleDb && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="warning">OrioleDB</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-80 text-center">
|
||||
This project is using Postgres with OrioleDB which is currently in preview and
|
||||
not suitable for production workloads. View our{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/database/orioledb`}>
|
||||
documentation
|
||||
</InlineLink>{' '}
|
||||
for all limitations.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showInstanceSize && (
|
||||
<ComputeBadgeWrapper
|
||||
projectRef={project?.ref}
|
||||
slug={organization?.slug}
|
||||
cloudProvider={project?.cloud_provider}
|
||||
computeSize={project?.infra_compute_size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && (
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Link
|
||||
href={`/project/${ref}/editor`}
|
||||
className="transition text-foreground-light hover:text-foreground text-sm"
|
||||
>
|
||||
Tables
|
||||
</Link>
|
||||
|
||||
{isLoadingTables ? (
|
||||
<ShimmeringLoader className="w-full h-[32px] w-6 p-0" />
|
||||
) : (
|
||||
<p className="text-2xl tabular-nums">{tablesCount}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Link
|
||||
href={`/project/${ref}/functions`}
|
||||
className="transition text-foreground-light hover:text-foreground text-sm"
|
||||
>
|
||||
Functions
|
||||
</Link>
|
||||
{isLoadingFunctions ? (
|
||||
<ShimmeringLoader className="w-full h-[32px] w-6 p-0" />
|
||||
) : (
|
||||
<p className="text-2xl tabular-nums">{functionsCount}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Link
|
||||
href={`/project/${ref}/settings/infrastructure`}
|
||||
className="transition text-foreground-light hover:text-foreground text-sm"
|
||||
>
|
||||
Replicas
|
||||
</Link>
|
||||
{isLoadingReplicas ? (
|
||||
<ShimmeringLoader className="w-full h-[32px] w-6 p-0" />
|
||||
) : (
|
||||
<p className="text-2xl tabular-nums">{replicasCount}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && (
|
||||
<div className="ml-6 border-l flex items-center w-[145px] justify-end">
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProjectUpgradeFailedBanner />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="py-16 border-b border-muted">
|
||||
<div className="mx-auto max-w-7xl space-y-16 @container">
|
||||
{IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && (
|
||||
<>{isNewProject ? <NewProjectPanel /> : <ProjectUsageSection />}</>
|
||||
)}
|
||||
{!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && <AdvisorWidget />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-100/5 py-16">
|
||||
<div className="mx-auto max-w-7xl space-y-16 @container">
|
||||
{project?.status !== PROJECT_STATUS.INACTIVE && (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg">Client libraries</h2>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-8 md:gap-12 mb-12 md:grid-cols-3">
|
||||
{clientLibraries!.map((library) => (
|
||||
// [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later.
|
||||
<ClientLibrary key={(library as any).language} {...(library as any)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showExamples && (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<h4 className="text-lg">Example projects</h4>
|
||||
{!!projectHomepageExampleProjects ? (
|
||||
<div className="grid gap-2 md:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. */}
|
||||
{(projectHomepageExampleProjects as any)
|
||||
.sort((a: any, b: any) => a.title.localeCompare(b.title))
|
||||
.map((project: any) => (
|
||||
<ExampleProject key={project.url} {...project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Tabs_Shadcn_ defaultValue="app" className="w-full">
|
||||
<TabsList_Shadcn_ className="flex gap-4 mb-8">
|
||||
<TabsTrigger_Shadcn_ value="app">App Frameworks</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="mobile">
|
||||
Mobile Frameworks
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
<TabsContent_Shadcn_ value="app">
|
||||
<div className="grid gap-2 md:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{EXAMPLE_PROJECTS.filter((project) => project.type === 'app')
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((project) => (
|
||||
<ExampleProject key={project.url} {...project} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
<TabsContent_Shadcn_ value="mobile">
|
||||
<div className="grid gap-2 md:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile')
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((project) => (
|
||||
<ExampleProject key={project.url} {...project} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import {
|
||||
calculateErrorRate,
|
||||
calculateSuccessRate,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
sumErrors,
|
||||
sumTotal,
|
||||
sumWarnings,
|
||||
} from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
} from '../ProjectHome/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import { useServiceHealthMetrics } from './useServiceHealthMetrics'
|
||||
|
||||
export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest'
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link'
|
||||
import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, cn, Loading } from 'ui'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils'
|
||||
|
||||
const colorClassMap: Record<string, string> = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge, Card, CardContent, Loading, Tooltip, TooltipContent, TooltipTrig
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
|
||||
import { ButtonTooltip } from '../../ui/ButtonTooltip'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils'
|
||||
|
||||
type ServiceConfig = {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from 'data/fetchers'
|
||||
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import { generateRegexpWhere } from '../Reports/Reports.constants'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
|
||||
type PostgrestMetricsVariables = {
|
||||
projectRef: string
|
||||
|
||||
@@ -4,15 +4,15 @@ import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted
|
||||
import useTimeseriesUnixToIso from 'hooks/analytics/useTimeseriesUnixToIso'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import { LogsTableName } from '../Settings/Logs/Logs.constants'
|
||||
import { genChartQuery } from '../Settings/Logs/Logs.utils'
|
||||
import {
|
||||
type RawChartData,
|
||||
calculateAggregatedMetrics,
|
||||
calculateDateRange,
|
||||
calculateHealthMetrics,
|
||||
transformToBarChartData,
|
||||
type RawChartData,
|
||||
} from './useServiceHealthMetrics.utils'
|
||||
|
||||
export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest'
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import {
|
||||
calculateAggregatedMetrics,
|
||||
calculateDateRange,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics'
|
||||
import {
|
||||
computeSuccessAndNonSuccessRates,
|
||||
sumErrors,
|
||||
sumTotal,
|
||||
sumWarnings,
|
||||
} from '../HomeNew/ProjectUsage.metrics'
|
||||
} from '../ProjectHome/ProjectUsage.metrics'
|
||||
|
||||
/**
|
||||
* Calculates the date range for fetching service health metrics
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
@@ -1,4 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import type { LogsBarChartDatum } from './ProjectUsage.metrics'
|
||||
|
||||
/**
|
||||
+21
-58
@@ -1,21 +1,16 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||
import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import type { CSSProperties, DragEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown'
|
||||
import { SnippetDropdown } from 'components/interfaces/ProjectHome/SnippetDropdown'
|
||||
import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock'
|
||||
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
||||
@@ -29,17 +24,20 @@ import {
|
||||
UpsertContentPayload,
|
||||
useContentUpsertMutation,
|
||||
} from 'data/content/content-upsert-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import type { CSSProperties, DragEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import type { Dashboards } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Row } from 'ui-patterns'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
|
||||
export function CustomReportSection() {
|
||||
const startDate = dayjs().subtract(7, 'day').toISOString()
|
||||
@@ -48,8 +46,7 @@ export function CustomReportSection() {
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
const { invalidateInfraMonitoringQuery } = useInvalidateAnalyticsQuery()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
@@ -203,19 +200,7 @@ export function CustomReportSection() {
|
||||
},
|
||||
})
|
||||
|
||||
if (ref && organization?.slug) {
|
||||
sendEvent({
|
||||
action: 'home_custom_report_block_added',
|
||||
properties: {
|
||||
block_id: snippet.id,
|
||||
position: 0,
|
||||
},
|
||||
groups: {
|
||||
project: ref,
|
||||
organization: organization.slug,
|
||||
},
|
||||
})
|
||||
}
|
||||
track('home_custom_report_block_added', { block_id: snippet.id, position: 0 })
|
||||
return
|
||||
}
|
||||
const current = [...editableReport.layout]
|
||||
@@ -225,19 +210,10 @@ export function CustomReportSection() {
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
|
||||
if (ref && organization?.slug) {
|
||||
sendEvent({
|
||||
action: 'home_custom_report_block_added',
|
||||
properties: {
|
||||
block_id: snippet.id,
|
||||
position: current.length - 1,
|
||||
},
|
||||
groups: {
|
||||
project: ref,
|
||||
organization: organization.slug,
|
||||
},
|
||||
})
|
||||
}
|
||||
track('home_custom_report_block_added', {
|
||||
block_id: snippet.id,
|
||||
position: current.length - 1,
|
||||
})
|
||||
},
|
||||
[
|
||||
editableReport,
|
||||
@@ -245,8 +221,7 @@ export function CustomReportSection() {
|
||||
ref,
|
||||
profile,
|
||||
upsertContent,
|
||||
organization,
|
||||
sendEvent,
|
||||
track,
|
||||
findNextPlacement,
|
||||
createSnippetChartBlock,
|
||||
persistReport,
|
||||
@@ -265,17 +240,8 @@ export function CustomReportSection() {
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
|
||||
if (ref && organization?.slug && removedChart) {
|
||||
sendEvent({
|
||||
action: 'home_custom_report_block_removed',
|
||||
properties: {
|
||||
block_id: String(removedChart.id),
|
||||
},
|
||||
groups: {
|
||||
project: ref,
|
||||
organization: organization.slug,
|
||||
},
|
||||
})
|
||||
if (removedChart) {
|
||||
track('home_custom_report_block_removed', { block_id: String(removedChart.id) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,12 +291,9 @@ export function CustomReportSection() {
|
||||
// Handle success optimistically
|
||||
toast.success(`Successfully created new query: ${label}`, { id: toastId })
|
||||
addSnippetToReport({ id: payload.id, name: label })
|
||||
sendEvent({
|
||||
action: 'custom_report_assistant_sql_block_added',
|
||||
groups: { project: ref, organization: organization?.slug ?? 'Unknown' },
|
||||
})
|
||||
track('custom_report_assistant_sql_block_added')
|
||||
},
|
||||
[ref, profile, project, upsertContent, addSnippetToReport, sendEvent, organization]
|
||||
[ref, profile, project, upsertContent, addSnippetToReport, track]
|
||||
)
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+13
-24
@@ -1,32 +1,30 @@
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { IS_PLATFORM, useFlag, useParams } from 'common'
|
||||
import { ProjectUsageSection as ProjectUsageSectionV1 } from 'components/interfaces/Home/ProjectUsageSection'
|
||||
import { SortableSection } from 'components/interfaces/HomeNew/SortableSection'
|
||||
import { TopSection } from 'components/interfaces/HomeNew/TopSection'
|
||||
import { SortableSection } from 'components/interfaces/ProjectHome/SortableSection'
|
||||
import { TopSection } from 'components/interfaces/ProjectHome/TopSection'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { AdvisorSection } from './AdvisorSection'
|
||||
import { CustomReportSection } from './CustomReportSection'
|
||||
import { type GettingStartedState } from './GettingStarted/GettingStarted.types'
|
||||
import { GettingStartedSection } from './GettingStarted/GettingStartedSection'
|
||||
import { ProjectUsageSection as ProjectUsageSectionV2 } from './ProjectUsageSection'
|
||||
|
||||
export const HomeV2 = () => {
|
||||
export const ProjectHome = () => {
|
||||
const { enableBranching } = useParams()
|
||||
const snap = useAppStateSnapshot()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
|
||||
const showHomepageUsageV2 = useFlag('newHomepageUsageV2')
|
||||
|
||||
@@ -58,20 +56,11 @@ export const HomeV2 = () => {
|
||||
const newIndex = items.indexOf(String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return items
|
||||
|
||||
if (project?.ref && organization?.slug) {
|
||||
sendEvent({
|
||||
action: 'home_section_rows_moved',
|
||||
properties: {
|
||||
section_moved: String(active.id),
|
||||
old_position: oldIndex,
|
||||
new_position: newIndex,
|
||||
},
|
||||
groups: {
|
||||
project: project.ref,
|
||||
organization: organization.slug,
|
||||
},
|
||||
})
|
||||
}
|
||||
track('home_section_rows_moved', {
|
||||
section_moved: String(active.id),
|
||||
old_position: oldIndex,
|
||||
new_position: newIndex,
|
||||
})
|
||||
|
||||
return arrayMove(items, oldIndex, newIndex)
|
||||
})
|
||||
File renamed without changes.
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeSuccessAndNonSuccessRates,
|
||||
sumErrors,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { ActivityStats } from 'components/interfaces/HomeNew/ActivityStats'
|
||||
import { ProjectConnectionHoverCard } from 'components/interfaces/HomeNew/ProjectConnectionHoverCard'
|
||||
import { ActivityStats } from 'components/interfaces/ProjectHome/ActivityStats'
|
||||
import { ProjectConnectionHoverCard } from 'components/interfaces/ProjectHome/ProjectConnectionHoverCard'
|
||||
import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
@@ -37,6 +37,7 @@ import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig'
|
||||
import { GridResize } from './GridResize'
|
||||
import { MetricOptions } from './MetricOptions'
|
||||
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
|
||||
const DEFAULT_CHART_COLUMN_COUNT = 1
|
||||
const DEFAULT_CHART_ROW_COUNT = 1
|
||||
@@ -57,9 +58,6 @@ const Reports = () => {
|
||||
const [hasEdits, setHasEdits] = useState<boolean>(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
|
||||
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
const {
|
||||
data: userContents,
|
||||
isPending: isLoading,
|
||||
@@ -364,31 +362,6 @@ const Reports = () => {
|
||||
checkEditState()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasEdits) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasEdits && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
} else {
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
}, [hasEdits, confirmNavigate, router])
|
||||
|
||||
if (isLoading || isLoadingPermissions) {
|
||||
return <LogoLoader />
|
||||
}
|
||||
@@ -528,27 +501,7 @@ const Reports = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
visible={!!navigateUrl}
|
||||
variant="warning"
|
||||
title="You have unsaved changes in your report"
|
||||
confirmLabel="Confirm"
|
||||
onConfirm={() => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
}}
|
||||
onCancel={() => setNavigateUrl(undefined)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Unsaved changes will be lost, are you sure you want to navigate away?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasEdits} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
|
||||
import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { exposedFunctionCountsQueryOptions } from '@/data/privileges/exposed-function-counts-query'
|
||||
import { exposedFunctionsInfiniteQueryOptions } from '@/data/privileges/exposed-functions-infinite-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { pluralize } from '@/lib/helpers'
|
||||
|
||||
interface ExposedFunctionSelectorProps {
|
||||
disabled?: boolean
|
||||
selectedSchemas: string[]
|
||||
pendingAddFunctionNames: string[]
|
||||
pendingRemoveFunctionNames: string[]
|
||||
onTogglePendingAdd: (functionName: string) => void
|
||||
onTogglePendingRemove: (functionName: string) => void
|
||||
}
|
||||
|
||||
export const ExposedFunctionSelector = ({
|
||||
disabled = false,
|
||||
selectedSchemas,
|
||||
pendingAddFunctionNames,
|
||||
pendingRemoveFunctionNames,
|
||||
onTogglePendingAdd,
|
||||
onTogglePendingRemove,
|
||||
}: ExposedFunctionSelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sentinelRef, entry] = useIntersectionObserver({
|
||||
root: scrollRootRef.current,
|
||||
threshold: 0,
|
||||
rootMargin: '0px',
|
||||
})
|
||||
|
||||
const { data: countsData, isPending: isCountsPending } = useQuery({
|
||||
...exposedFunctionCountsQueryOptions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
selectedSchemas,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const pendingCount = pendingAddFunctionNames.length + pendingRemoveFunctionNames.length
|
||||
|
||||
const totalCount = countsData?.total_count ?? 0
|
||||
const grantsCount = countsData?.grants_count ?? 0
|
||||
|
||||
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
...exposedFunctionsInfiniteQueryOptions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
search: search.length === 0 ? undefined : debouncedSearch || undefined,
|
||||
}),
|
||||
placeholderData: search.length > 0 ? keepPreviousData : undefined,
|
||||
})
|
||||
|
||||
const functions = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.functions) ?? [],
|
||||
[data?.pages]
|
||||
)
|
||||
|
||||
const pendingAddSet = useMemo(() => new Set(pendingAddFunctionNames), [pendingAddFunctionNames])
|
||||
const pendingRemoveSet = useMemo(
|
||||
() => new Set(pendingRemoveFunctionNames),
|
||||
[pendingRemoveFunctionNames]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className="w-full [&>span]:w-full !pr-1 space-x-1"
|
||||
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">
|
||||
{isCountsPending
|
||||
? 'Loading functions...'
|
||||
: totalCount === 0
|
||||
? 'No functions available'
|
||||
: `${grantsCount} of ${totalCount} functions exposed${
|
||||
pendingCount > 0
|
||||
? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
|
||||
: ''
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_ shouldFilter={false}>
|
||||
<CommandInput_Shadcn_
|
||||
className="text-xs"
|
||||
placeholder="Find function..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
<div className="px-2 py-1 w-4/5">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="flex items-center py-3 justify-center">
|
||||
<p className="text-xs text-foreground-lighter">Failed to retrieve functions</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{functions.length === 0 && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
{search.length > 0 ? 'No functions found' : 'No functions available'}
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea
|
||||
ref={scrollRootRef}
|
||||
className={functions.length > 7 ? 'h-[210px]' : ''}
|
||||
>
|
||||
{functions.map((fn) => {
|
||||
const key = `${fn.schema}.${fn.name}`
|
||||
const isSchemaExposed = selectedSchemas.includes(fn.schema)
|
||||
const hasPendingAdd = pendingAddSet.has(key)
|
||||
const hasPendingRemove = pendingRemoveSet.has(key)
|
||||
|
||||
const isCustom = fn.status === 'custom'
|
||||
const isGranted = fn.status === 'granted'
|
||||
|
||||
const isCustomNeutral = isCustom && !hasPendingAdd && !hasPendingRemove
|
||||
const isExposed =
|
||||
isSchemaExposed &&
|
||||
(isCustom ? hasPendingAdd : isGranted ? !hasPendingRemove : hasPendingAdd)
|
||||
|
||||
const customGrantsTooltip = getCustomGrantsTooltip({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
})
|
||||
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={key}
|
||||
value={key}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 !cursor-not-allowed'
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (!isSchemaExposed) return
|
||||
|
||||
if (isCustom) {
|
||||
if (hasPendingAdd) {
|
||||
onTogglePendingAdd(key)
|
||||
onTogglePendingRemove(key)
|
||||
} else if (hasPendingRemove) {
|
||||
onTogglePendingRemove(key)
|
||||
onTogglePendingAdd(key)
|
||||
} else {
|
||||
onTogglePendingAdd(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isGranted) {
|
||||
onTogglePendingRemove(key)
|
||||
} else {
|
||||
onTogglePendingAdd(key)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex items-center gap-x-2">
|
||||
<div className="w-4 shrink-0 flex items-center justify-center">
|
||||
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
||||
{!isSchemaExposed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Schema not exposed"
|
||||
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-[320px] text-xs">
|
||||
The schema "{fn.schema}" must be exposed before enabling this
|
||||
function.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
|
||||
isCustomNeutral && isSchemaExposed && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{isCustom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-center hover:text-foreground-light',
|
||||
isCustomNeutral && isSchemaExposed
|
||||
? 'text-warning'
|
||||
: 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={14} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[320px] text-xs pointer-events-none"
|
||||
>
|
||||
{customGrantsTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
<div ref={sentinelRef} className="h-1 -mt-1" />
|
||||
{hasNextPage && (
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
const getCustomGrantsTooltip = ({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
}: {
|
||||
hasPendingAdd: boolean
|
||||
hasPendingRemove: boolean
|
||||
}) => {
|
||||
if (hasPendingAdd) {
|
||||
return 'This function has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
|
||||
}
|
||||
|
||||
if (hasPendingRemove) {
|
||||
return 'This function has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
|
||||
}
|
||||
|
||||
return 'This function has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { pluralize } from '@/lib/helpers'
|
||||
|
||||
interface ExposedTableSelectorProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
selectedSchemas: string[]
|
||||
pendingAddTableIds: number[]
|
||||
@@ -36,7 +35,6 @@ interface ExposedTableSelectorProps {
|
||||
}
|
||||
|
||||
export const ExposedTableSelector = ({
|
||||
className,
|
||||
disabled = false,
|
||||
selectedSchemas,
|
||||
pendingAddTableIds,
|
||||
@@ -92,197 +90,193 @@ export const ExposedTableSelector = ({
|
||||
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className="w-full [&>span]:w-full !pr-1 space-x-1"
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">
|
||||
{isCountsPending
|
||||
? 'Loading tables...'
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className="w-full [&>span]:w-full !pr-1 space-x-1"
|
||||
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">
|
||||
{isCountsPending
|
||||
? 'Loading tables...'
|
||||
: totalCount === 0
|
||||
? 'No tables available'
|
||||
: `${grantsCount} of ${totalCount} tables exposed${
|
||||
pendingCount > 0
|
||||
? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
|
||||
: ''
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_ shouldFilter={false}>
|
||||
<CommandInput_Shadcn_
|
||||
className="text-xs"
|
||||
placeholder="Find table..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
<div className="px-2 py-1 w-4/5">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="flex items-center py-3 justify-center">
|
||||
<p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_ shouldFilter={false}>
|
||||
<CommandInput_Shadcn_
|
||||
className="text-xs"
|
||||
placeholder="Find table..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{search.length > 0 && tables.length === 0 && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No tables found
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea
|
||||
ref={scrollRootRef}
|
||||
className={tables.length > 7 ? 'h-[210px]' : ''}
|
||||
>
|
||||
{tables.map((table) => {
|
||||
const isSchemaExposed = selectedSchemas.includes(table.schema)
|
||||
const hasPendingAdd = pendingAddSet.has(table.id)
|
||||
const hasPendingRemove = pendingRemoveSet.has(table.id)
|
||||
<div className="px-2 py-1 w-4/5">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="flex items-center py-3 justify-center">
|
||||
<p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tables.length === 0 && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
{search.length > 0 ? 'No tables found' : 'No tables available'}
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea ref={scrollRootRef} className={tables.length > 7 ? 'h-[210px]' : ''}>
|
||||
{tables.map((table) => {
|
||||
const isSchemaExposed = selectedSchemas.includes(table.schema)
|
||||
const hasPendingAdd = pendingAddSet.has(table.id)
|
||||
const hasPendingRemove = pendingRemoveSet.has(table.id)
|
||||
|
||||
const isCustomTable = table.status === 'custom'
|
||||
const isGranted = table.status === 'granted'
|
||||
const isCustomTable = table.status === 'custom'
|
||||
const isGranted = table.status === 'granted'
|
||||
|
||||
const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
|
||||
const isExposed =
|
||||
isSchemaExposed &&
|
||||
(isCustomTable
|
||||
? hasPendingAdd
|
||||
: isGranted
|
||||
? !hasPendingRemove
|
||||
: hasPendingAdd)
|
||||
const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
|
||||
const isExposed =
|
||||
isSchemaExposed &&
|
||||
(isCustomTable
|
||||
? hasPendingAdd
|
||||
: isGranted
|
||||
? !hasPendingRemove
|
||||
: hasPendingAdd)
|
||||
|
||||
const customGrantsTooltip = getCustomGrantsTooltip({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
})
|
||||
const customGrantsTooltip = getCustomGrantsTooltip({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
})
|
||||
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={table.id}
|
||||
value={`${table.schema}.${table.name}-${table.id}`}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 !cursor-not-allowed'
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (!isSchemaExposed) return
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={table.id}
|
||||
value={`${table.schema}.${table.name}-${table.id}`}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 !cursor-not-allowed'
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (!isSchemaExposed) return
|
||||
|
||||
if (isCustomTable) {
|
||||
if (hasPendingAdd) {
|
||||
onTogglePendingAdd(table.id)
|
||||
onTogglePendingRemove(table.id)
|
||||
} else if (hasPendingRemove) {
|
||||
onTogglePendingRemove(table.id)
|
||||
onTogglePendingAdd(table.id)
|
||||
} else {
|
||||
onTogglePendingAdd(table.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isGranted) {
|
||||
if (isCustomTable) {
|
||||
if (hasPendingAdd) {
|
||||
onTogglePendingAdd(table.id)
|
||||
onTogglePendingRemove(table.id)
|
||||
} else if (hasPendingRemove) {
|
||||
onTogglePendingRemove(table.id)
|
||||
onTogglePendingAdd(table.id)
|
||||
} else {
|
||||
onTogglePendingAdd(table.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex items-center gap-x-2">
|
||||
<div className="w-4 shrink-0 flex items-center justify-center">
|
||||
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
|
||||
isCustomNeutral && isSchemaExposed && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{`${table.schema}.${table.name}`}
|
||||
</span>
|
||||
return
|
||||
}
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{isCustomTable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-center hover:text-foreground-light',
|
||||
isCustomNeutral && isSchemaExposed
|
||||
? 'text-warning'
|
||||
: 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={14} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[320px] text-xs pointer-events-none"
|
||||
if (isGranted) {
|
||||
onTogglePendingRemove(table.id)
|
||||
} else {
|
||||
onTogglePendingAdd(table.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex items-center gap-x-2">
|
||||
<div className="w-4 shrink-0 flex items-center justify-center">
|
||||
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
||||
{!isSchemaExposed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Schema not exposed"
|
||||
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
|
||||
>
|
||||
{customGrantsTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isSchemaExposed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Schema not exposed"
|
||||
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[320px] text-xs">
|
||||
{`The schema "${table.schema}" must be exposed before enabling this table.`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-[320px] text-xs">
|
||||
The schema "{table.schema}" must be exposed before enabling this
|
||||
table.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
<div ref={sentinelRef} className="h-1 -mt-1" />
|
||||
{hasNextPage && (
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
|
||||
isCustomNeutral && isSchemaExposed && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{`${table.schema}.${table.name}`}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{isCustomTable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-center hover:text-foreground-light',
|
||||
isCustomNeutral && isSchemaExposed
|
||||
? 'text-warning'
|
||||
: 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={14} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[320px] text-xs pointer-events-none"
|
||||
>
|
||||
{customGrantsTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
<div ref={sentinelRef} className="h-1 -mt-1" />
|
||||
{hasNextPage && (
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { z } from 'zod'
|
||||
|
||||
import { ExposedSchemaSelector } from './ExposedSchemaSelector'
|
||||
import { HardenAPIModal } from './HardenAPIModal'
|
||||
import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector'
|
||||
import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector'
|
||||
import { FormActions } from '@/components/ui/Forms/FormActions'
|
||||
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
|
||||
@@ -42,7 +43,7 @@ import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-p
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { privilegeKeys } from '@/data/privileges/keys'
|
||||
import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation'
|
||||
import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import useLatest from '@/hooks/misc/useLatest'
|
||||
@@ -66,6 +67,8 @@ const formSchema = z.object({
|
||||
// Fields for expose toggles
|
||||
tableIdsToAdd: z.array(z.number()),
|
||||
tableIdsToRemove: z.array(z.number()),
|
||||
functionNamesToAdd: z.array(z.string()),
|
||||
functionNamesToRemove: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const PostgrestConfig = () => {
|
||||
@@ -119,7 +122,7 @@ export const PostgrestConfig = () => {
|
||||
|
||||
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation()
|
||||
|
||||
const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation()
|
||||
const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation()
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
@@ -143,6 +146,8 @@ export const PostgrestConfig = () => {
|
||||
dbPool: config?.db_pool,
|
||||
tableIdsToAdd: [] as number[],
|
||||
tableIdsToRemove: [] as number[],
|
||||
functionNamesToAdd: [] as string[],
|
||||
functionNamesToRemove: [] as string[],
|
||||
}
|
||||
}, [config, configDbSchemas])
|
||||
|
||||
@@ -165,11 +170,13 @@ export const PostgrestConfig = () => {
|
||||
let dbSchema = values.dbSchema.join(',')
|
||||
|
||||
if (isApiGrantTogglesEnabled) {
|
||||
await updateExposedTables({
|
||||
await updateExposedEntities({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
tableIdsToAdd: values.tableIdsToAdd,
|
||||
tableIdsToRemove: values.tableIdsToRemove,
|
||||
functionNamesToAdd: values.functionNamesToAdd,
|
||||
functionNamesToRemove: values.functionNamesToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,6 +198,12 @@ export const PostgrestConfig = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.exposedFunctionCounts(projectRef, watchedDbSchema),
|
||||
}),
|
||||
])
|
||||
|
||||
toast.success('Successfully saved settings')
|
||||
@@ -204,6 +217,8 @@ export const PostgrestConfig = () => {
|
||||
dbPool: values.dbPool,
|
||||
tableIdsToAdd: [],
|
||||
tableIdsToRemove: [],
|
||||
functionNamesToAdd: [],
|
||||
functionNamesToRemove: [],
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error')
|
||||
@@ -227,6 +242,14 @@ export const PostgrestConfig = () => {
|
||||
control: form.control,
|
||||
name: 'tableIdsToRemove',
|
||||
})
|
||||
const watchedFunctionNamesToAdd = useWatch_Shadcn_({
|
||||
control: form.control,
|
||||
name: 'functionNamesToAdd',
|
||||
})
|
||||
const watchedFunctionNamesToRemove = useWatch_Shadcn_({
|
||||
control: form.control,
|
||||
name: 'functionNamesToRemove',
|
||||
})
|
||||
return (
|
||||
<PageSection id="postgrest-config" className="first:pt-0">
|
||||
<PageSectionContent>
|
||||
@@ -312,6 +335,47 @@ export const PostgrestConfig = () => {
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex-row-reverse"
|
||||
label="Exposed functions"
|
||||
description="Toggle Data API access for individual functions."
|
||||
>
|
||||
<ExposedFunctionSelector
|
||||
selectedSchemas={watchedDbSchema}
|
||||
pendingAddFunctionNames={watchedFunctionNamesToAdd}
|
||||
pendingRemoveFunctionNames={watchedFunctionNamesToRemove}
|
||||
onTogglePendingAdd={(functionName) => {
|
||||
const current = form.getValues('functionNamesToAdd')
|
||||
if (current.includes(functionName)) {
|
||||
form.setValue(
|
||||
'functionNamesToAdd',
|
||||
current.filter((x) => x !== functionName),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
} else {
|
||||
form.setValue('functionNamesToAdd', [...current, functionName], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onTogglePendingRemove={(functionName) => {
|
||||
const current = form.getValues('functionNamesToRemove')
|
||||
if (current.includes(functionName)) {
|
||||
form.setValue(
|
||||
'functionNamesToRemove',
|
||||
current.filter((x) => x !== functionName),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
} else {
|
||||
form.setValue('functionNamesToRemove', [...current, functionName], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
{watchedDbSchema.length === 0 && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
|
||||
+22
-1
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import CopyButton from 'components/ui/CopyButton'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation'
|
||||
@@ -70,6 +71,7 @@ export const CustomDomainsConfigureHostname = () => {
|
||||
}
|
||||
|
||||
const domain = form.watch('domain')
|
||||
const trimmedDomain = domain.trim()
|
||||
const isSubmitting = isCheckingRecord || isCreating
|
||||
|
||||
return (
|
||||
@@ -112,12 +114,31 @@ export const CustomDomainsConfigureHostname = () => {
|
||||
{domain ? <code className="text-code-inline">{domain}</code> : 'your custom domain'}{' '}
|
||||
resolving to{' '}
|
||||
{endpoint ? (
|
||||
<code className="text-code-inline">{endpoint}</code>
|
||||
<span className="inline-flex items-center gap-x-1">
|
||||
<code className="text-code-inline">{endpoint}</code>
|
||||
<CopyButton
|
||||
iconOnly
|
||||
type="text"
|
||||
className="h-5 w-5 min-w-0 p-0 [&_svg]:h-3 [&_svg]:w-3"
|
||||
text={endpoint}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
"your project's API URL"
|
||||
)}{' '}
|
||||
with as low a TTL as possible. If you're using Cloudflare as your DNS provider,
|
||||
disable the proxy option.
|
||||
<br />
|
||||
{trimmedDomain.includes('.') ? (
|
||||
<>
|
||||
Some DNS providers expect only the subdomain label{' '}
|
||||
<code className="text-code-inline">{trimmedDomain.split('.')[0]}</code>, while
|
||||
others accept the full hostname{' '}
|
||||
<code className="text-code-inline whitespace-nowrap">{trimmedDomain}</code>.
|
||||
</>
|
||||
) : (
|
||||
'Some DNS providers expect only the subdomain label, while others accept the full hostname.'
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useSignUpMutation } from 'data/misc/signup-mutation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { buildPathWithParams } from 'lib/gotrue'
|
||||
import { CheckCircle, Eye, EyeOff } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useRef, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { useSignUpMutation } from 'data/misc/signup-mutation'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { buildPathWithParams } from 'lib/gotrue'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
cn,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import z from 'zod'
|
||||
|
||||
import PasswordConditionsHelper from './PasswordConditionsHelper'
|
||||
|
||||
const schema = z.object({
|
||||
@@ -183,8 +183,8 @@ export const SignUpForm = () => {
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
title={passwordHidden ? `Hide password` : `Show password`}
|
||||
aria-label={passwordHidden ? `Hide password` : `Show password`}
|
||||
title={passwordHidden ? `Show password` : `Hide password`}
|
||||
aria-label={passwordHidden ? `Show password` : `Hide password`}
|
||||
className="absolute right-1 top-1 px-1.5"
|
||||
icon={passwordHidden ? <Eye /> : <EyeOff />}
|
||||
disabled={isSubmitting}
|
||||
|
||||
+2
@@ -243,6 +243,7 @@ export const ColumnEditor = ({
|
||||
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
descriptionText="Recommended to use lowercase and use an underscore to separate words e.g. column_name"
|
||||
@@ -252,6 +253,7 @@ export const ColumnEditor = ({
|
||||
onChange={(event: any) => onUpdateField({ name: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Description"
|
||||
labelOptional="Optional"
|
||||
type="text"
|
||||
|
||||
@@ -495,6 +495,9 @@ export const SidePanelEditor = ({
|
||||
queryKey: databaseKeys.tableDefinition(project?.ref, selectedTable?.id),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, selectedTable?.schema, includeColumns),
|
||||
}),
|
||||
])
|
||||
|
||||
// We need to invalidate tableRowsAndCount after tableEditor
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ProjectOAuthIntegrationsBanner } from './ProjectOAuthIntegrationsBanner
|
||||
|
||||
const mockUseSelectedOrganizationQuery = vi.fn()
|
||||
const mockUseAuthorizedAppsQuery = vi.fn()
|
||||
const mockUseIsProjectOauthIntegrationsBannerEnabled = vi.fn()
|
||||
|
||||
vi.mock('hooks/misc/useSelectedOrganization', () => ({
|
||||
useSelectedOrganizationQuery: () => mockUseSelectedOrganizationQuery(),
|
||||
@@ -17,6 +18,11 @@ vi.mock('data/oauth/authorized-apps-query', () => ({
|
||||
useAuthorizedAppsQuery: (...args: unknown[]) => mockUseAuthorizedAppsQuery(...args),
|
||||
}))
|
||||
|
||||
vi.mock('components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({
|
||||
useIsProjectOauthIntegrationsBannerEnabled: () =>
|
||||
mockUseIsProjectOauthIntegrationsBannerEnabled(),
|
||||
}))
|
||||
|
||||
const createAuthorizedApp = (overrides: Partial<AuthorizedApp>): AuthorizedApp => ({
|
||||
id: 'authorized-app-1',
|
||||
app_id: 'oauth-app-1',
|
||||
@@ -36,6 +42,7 @@ describe('ProjectOAuthIntegrationsBanner', () => {
|
||||
mockUseSelectedOrganizationQuery.mockReturnValue({
|
||||
data: { slug: 'acme' },
|
||||
})
|
||||
mockUseIsProjectOauthIntegrationsBannerEnabled.mockReturnValue(true)
|
||||
|
||||
mockUseAuthorizedAppsQuery.mockReturnValue({
|
||||
data: [createAuthorizedApp({ name: 'Lovable' })],
|
||||
@@ -71,6 +78,18 @@ describe('ProjectOAuthIntegrationsBanner', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render when feature preview is disabled', () => {
|
||||
mockUseIsProjectOauthIntegrationsBannerEnabled.mockReturnValue(false)
|
||||
|
||||
render(<ProjectOAuthIntegrationsBanner />)
|
||||
|
||||
expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument()
|
||||
expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith(
|
||||
{ slug: 'acme' },
|
||||
expect.objectContaining({ enabled: false })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'no authorized apps',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIsProjectOauthIntegrationsBannerEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Plug, Settings2 } from 'lucide-react'
|
||||
@@ -15,16 +16,25 @@ import {
|
||||
export const ProjectOAuthIntegrationsBanner = () => {
|
||||
const router = useRouter()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const isProjectOauthIntegrationsBannerEnabled = useIsProjectOauthIntegrationsBannerEnabled()
|
||||
|
||||
const organizationSlug = selectedOrganization?.slug
|
||||
const showProjectBanner = isProjectRoute({ pathname: router.pathname, asPath: router.asPath })
|
||||
const canShowProjectBanner =
|
||||
isProjectOauthIntegrationsBannerEnabled && showProjectBanner && !!organizationSlug
|
||||
|
||||
const { data: authorizedApps = [], isError } = useAuthorizedAppsQuery(
|
||||
{ slug: organizationSlug },
|
||||
{ enabled: showProjectBanner && !!organizationSlug }
|
||||
{ enabled: canShowProjectBanner }
|
||||
)
|
||||
|
||||
if (!showProjectBanner || !organizationSlug || isError) return null
|
||||
if (
|
||||
!isProjectOauthIntegrationsBannerEnabled ||
|
||||
!showProjectBanner ||
|
||||
!organizationSlug ||
|
||||
isError
|
||||
)
|
||||
return null
|
||||
|
||||
if (authorizedApps.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { UnknownInterface } from 'components/ui/UnknownInterface'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import AuthLayout from './AuthLayout'
|
||||
|
||||
export const AuthEmailsLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
@@ -23,7 +23,7 @@ export const AuthEmailsLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthLayout title="Email">
|
||||
{showEmails ? (
|
||||
<PageLayout
|
||||
title="Emails"
|
||||
|
||||
@@ -14,16 +14,15 @@ const AuthProductMenu = () => {
|
||||
|
||||
useAuthConfigPrefetch({ projectRef })
|
||||
const page = router.pathname.split('/')[4]
|
||||
|
||||
const menu = useGenerateAuthMenu()
|
||||
|
||||
return <ProductMenu page={page} menu={menu} />
|
||||
}
|
||||
|
||||
const AuthLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const AuthLayout = ({ title, children }: PropsWithChildren<{ title: string }>) => {
|
||||
return (
|
||||
<ProjectLayout
|
||||
title="Authentication"
|
||||
title={title}
|
||||
product="Authentication"
|
||||
productMenu={<AuthProductMenu />}
|
||||
isBlocking={false}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { UnknownInterface } from 'components/ui/UnknownInterface'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import AuthLayout from './AuthLayout'
|
||||
|
||||
export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
@@ -29,7 +29,7 @@ export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthLayout title="Sign In / Providers">
|
||||
{authenticationSignInProviders ? (
|
||||
<PageLayout
|
||||
title="Sign In / Providers"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ProjectLayout } from '../ProjectLayout'
|
||||
import { useGenerateDatabaseMenu } from './DatabaseMenu.utils'
|
||||
|
||||
export interface DatabaseLayoutProps {
|
||||
title?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const DatabaseProductMenu = () => {
|
||||
@@ -18,9 +18,14 @@ const DatabaseProductMenu = () => {
|
||||
return <ProductMenu page={page} menu={menu} />
|
||||
}
|
||||
|
||||
const DatabaseLayout = ({ children }: PropsWithChildren<DatabaseLayoutProps>) => {
|
||||
const DatabaseLayout = ({ children, title }: PropsWithChildren<DatabaseLayoutProps>) => {
|
||||
return (
|
||||
<ProjectLayout product="Database" productMenu={<DatabaseProductMenu />} isBlocking={false}>
|
||||
<ProjectLayout
|
||||
title={title}
|
||||
product="Database"
|
||||
productMenu={<DatabaseProductMenu />}
|
||||
isBlocking={false}
|
||||
>
|
||||
{children}
|
||||
</ProjectLayout>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import DatabaseLayout from './DatabaseLayout'
|
||||
|
||||
type DatabaseTriggersLayoutProps = PropsWithChildren
|
||||
|
||||
const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => {
|
||||
export const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => {
|
||||
const { ref } = useParams()
|
||||
const { can: canReadTriggers, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_READ,
|
||||
@@ -28,7 +28,7 @@ const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<DatabaseLayout title="Database">
|
||||
<DatabaseLayout title="Triggers">
|
||||
{isPermissionsLoaded && !canReadTriggers ? (
|
||||
<NoPermission isFullPage resourceText="view database triggers" />
|
||||
) : (
|
||||
@@ -44,5 +44,3 @@ const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => {
|
||||
</DatabaseLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatabaseTriggersLayout
|
||||
@@ -1,9 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Download, FileArchive, Send } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useEffect, useState, type PropsWithChildren } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
@@ -18,7 +13,11 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { Download, FileArchive, Send } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useEffect, useState, type PropsWithChildren } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
|
||||
BreadcrumbLink_Shadcn_ as BreadcrumbLink,
|
||||
@@ -42,11 +41,12 @@ import {
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
|
||||
import { ProjectLayout } from '../ProjectLayout'
|
||||
import EdgeFunctionsLayout from './EdgeFunctionsLayout'
|
||||
|
||||
interface EdgeFunctionDetailsLayoutProps {
|
||||
title?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const EdgeFunctionDetailsLayout = ({
|
||||
@@ -91,6 +91,10 @@ const EdgeFunctionDetailsLayout = ({
|
||||
)
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
const browserTitle = {
|
||||
entity: functionSlug ? name || functionSlug : undefined,
|
||||
section: title,
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
@@ -215,14 +219,18 @@ const EdgeFunctionDetailsLayout = ({
|
||||
|
||||
if (!isLoading && !canReadFunctions) {
|
||||
return (
|
||||
<ProjectLayout title={title || 'Edge Functions'} product="Edge Functions">
|
||||
<ProjectLayout
|
||||
title={title || 'Edge Functions'}
|
||||
product="Edge Functions"
|
||||
browserTitle={browserTitle}
|
||||
>
|
||||
<NoPermission isFullPage resourceText="access your project's edge functions" />
|
||||
</ProjectLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<EdgeFunctionsLayout>
|
||||
<EdgeFunctionsLayout title={title} browserTitle={browserTitle}>
|
||||
<div className="w-full min-h-full flex flex-col items-stretch">
|
||||
<PageHeader size="full" className="sticky top-0 z-10 bg-background">
|
||||
{breadcrumbItems.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ProductMenu } from 'components/ui/ProductMenu'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentProps, PropsWithChildren } from 'react'
|
||||
|
||||
import { ProjectLayout } from '../ProjectLayout'
|
||||
|
||||
const EdgeFunctionsProductMenu = () => {
|
||||
@@ -35,11 +35,21 @@ const EdgeFunctionsProductMenu = () => {
|
||||
return <ProductMenu page={page} menu={menuItems} />
|
||||
}
|
||||
|
||||
const EdgeFunctionsLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
interface EdgeFunctionsLayoutProps {
|
||||
title: string
|
||||
browserTitle?: ComponentProps<typeof ProjectLayout>['browserTitle']
|
||||
}
|
||||
|
||||
const EdgeFunctionsLayout = ({
|
||||
children,
|
||||
title,
|
||||
browserTitle,
|
||||
}: PropsWithChildren<EdgeFunctionsLayoutProps>) => {
|
||||
return (
|
||||
<ProjectLayout
|
||||
title="Edge Functions"
|
||||
title={title}
|
||||
product="Edge Functions"
|
||||
browserTitle={browserTitle}
|
||||
productMenu={<EdgeFunctionsProductMenu />}
|
||||
isBlocking={false}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,6 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { withAuth } from '@/hooks/misc/withAuth'
|
||||
import { usePHFlag } from '@/hooks/ui/useFlag'
|
||||
import { PROJECT_STATUS } from '@/lib/constants'
|
||||
import { buildStudioPageTitle } from '@/lib/page-title'
|
||||
import { useAppStateSnapshot } from '@/state/app-state'
|
||||
@@ -69,6 +68,7 @@ const routesToIgnorePostgrestConnection = [
|
||||
]
|
||||
|
||||
export interface ProjectLayoutProps {
|
||||
/** @deprecated Use browserTitle.section instead. */
|
||||
title?: string
|
||||
isLoading?: boolean
|
||||
isBlocking?: boolean
|
||||
@@ -279,8 +279,6 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp
|
||||
const { ref } = useParams()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const { data: selectedProject } = useSelectedProjectQuery()
|
||||
const isHomeNew = usePHFlag('homeNew') === 'new-home'
|
||||
|
||||
const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
|
||||
const isHomePage = router.pathname === '/project/[ref]'
|
||||
|
||||
@@ -300,13 +298,10 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp
|
||||
const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
|
||||
const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
|
||||
|
||||
// handle redirect to home for building state
|
||||
const shouldRedirectToHomeForBuilding =
|
||||
isProjectBuilding && requiresDbConnection && isHomeNew && !isHomePage
|
||||
const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
|
||||
|
||||
// We won't be showing the building state with the new home page
|
||||
const shouldShowBuildingState =
|
||||
isProjectBuilding && requiresDbConnection && !(isHomeNew && isHomePage)
|
||||
// Don't show building state on the home page — it handles building state inline
|
||||
const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectToHomeForBuilding && ref) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const EditorBaseLayout = ({
|
||||
product,
|
||||
productMenuClassName,
|
||||
productMenu,
|
||||
browserTitle,
|
||||
}: ExplorerLayoutProps) => {
|
||||
const { ref } = useParams()
|
||||
const pathname = usePathname()
|
||||
@@ -33,11 +34,31 @@ export const EditorBaseLayout = ({
|
||||
const hideTabs =
|
||||
pathname === `/project/${ref}/editor` || pathname === `/project/${ref}/sql` || hasNoOpenTabs
|
||||
|
||||
const activeEditorTab = tabs.activeTab ? tabs.tabsMap[tabs.activeTab] : undefined
|
||||
const activeEditorTabEntity =
|
||||
activeEditorTab === undefined
|
||||
? undefined
|
||||
: editor === 'sql'
|
||||
? activeEditorTab.type === 'sql'
|
||||
? activeEditorTab.metadata?.name || activeEditorTab.label
|
||||
: undefined
|
||||
: editor === 'table'
|
||||
? activeEditorTab.type !== 'sql'
|
||||
? activeEditorTab.metadata?.name || activeEditorTab.label
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
const mergedBrowserTitle = {
|
||||
...browserTitle,
|
||||
entity: browserTitle?.entity ?? activeEditorTabEntity,
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectLayoutWithAuth
|
||||
resizableSidebar
|
||||
title={title}
|
||||
product={product}
|
||||
browserTitle={mergedBrowserTitle}
|
||||
productMenuClassName={productMenuClassName}
|
||||
productMenu={productMenu}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from 'ui'
|
||||
|
||||
interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps {
|
||||
export interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
confirmLabel?: ReactNode
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
import {
|
||||
DiscardChangesConfirmationDialog,
|
||||
type DiscardChangesConfirmationDialogProps,
|
||||
} from './DiscardChangesConfirmationDialog'
|
||||
|
||||
export const PreventNavigationOnUnsavedChanges = ({
|
||||
hasChanges,
|
||||
...props
|
||||
}: { hasChanges: boolean } & Omit<
|
||||
DiscardChangesConfirmationDialogProps,
|
||||
'visible' | 'onClose' | 'onCancel'
|
||||
>) => {
|
||||
const router = useRouter()
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasChanges && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
return
|
||||
}
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confirmNavigate, hasChanges])
|
||||
|
||||
const handleCancel = () => {
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscardChangesConfirmationDialog
|
||||
visible={!!navigateUrl}
|
||||
onCancel={handleCancel}
|
||||
onClose={handleClose}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { AiPromptCopiedEvent } from 'common/telemetry-constants'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Chatgpt, Claude } from 'icons'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { Check, ChevronDown, Copy } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Image from 'next/image'
|
||||
import { ComponentProps, ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
@@ -23,21 +22,8 @@ import {
|
||||
type TelemetrySource = AiPromptCopiedEvent['properties']['source']
|
||||
|
||||
const EXTERNAL_AI_TOOLS = [
|
||||
{
|
||||
label: 'Open in ChatGPT',
|
||||
url: 'https://chatgpt.com/',
|
||||
promptParam: 'q',
|
||||
icon: {
|
||||
light: '/img/mcp-clients/openai-icon.svg',
|
||||
dark: '/img/mcp-clients/openai-icon-dark.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open in Claude.ai',
|
||||
url: 'https://claude.ai/new',
|
||||
promptParam: 'q',
|
||||
icon: { light: '/img/mcp-clients/claude-icon.svg', dark: '/img/mcp-clients/claude-icon.svg' },
|
||||
},
|
||||
{ label: 'Ask ChatGPT', url: 'https://chatgpt.com/', promptParam: 'q', icon: Chatgpt },
|
||||
{ label: 'Ask Claude', url: 'https://claude.ai/new', promptParam: 'q', icon: Claude },
|
||||
]
|
||||
|
||||
export interface AiAssistantDropdownItem {
|
||||
@@ -82,7 +68,6 @@ export function AiAssistantDropdown({
|
||||
additionalDropdownItems,
|
||||
}: AiAssistantDropdownProps) {
|
||||
const track = useTrack()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [showCopied, setShowCopied] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
@@ -156,12 +141,7 @@ export function AiAssistantDropdown({
|
||||
className="gap-2"
|
||||
onClick={() => handleOpenExternalAI(tool)}
|
||||
>
|
||||
<Image
|
||||
src={`${BASE_PATH}${resolvedTheme?.includes('dark') ? tool.icon.dark : tool.icon.light}`}
|
||||
alt={tool.label}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<tool.icon size={14} />
|
||||
{tool.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { privilegeKeys } from './keys'
|
||||
import { getExposedFunctionCountsSql } from './privileges.sql'
|
||||
|
||||
export type ExposedFunctionCountsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
selectedSchemas: string[]
|
||||
}
|
||||
|
||||
export type ExposedFunctionCountsResponse = {
|
||||
total_count: number
|
||||
grants_count: number
|
||||
}
|
||||
|
||||
export async function getExposedFunctionCounts(
|
||||
{ projectRef, connectionString, selectedSchemas }: ExposedFunctionCountsVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExposedFunctionCountsResponse> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!selectedSchemas) throw new Error('selectedSchemas is required')
|
||||
|
||||
const sql = getExposedFunctionCountsSql({ selectedSchemas })
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['exposed-function-counts', selectedSchemas],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0] as ExposedFunctionCountsResponse
|
||||
}
|
||||
|
||||
export type ExposedFunctionCountsData = Awaited<ReturnType<typeof getExposedFunctionCounts>>
|
||||
export type ExposedFunctionCountsError = ResponseError
|
||||
|
||||
export const exposedFunctionCountsQueryOptions = (
|
||||
{ projectRef, connectionString, selectedSchemas }: ExposedFunctionCountsVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
return queryOptions({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
|
||||
queryKey: privilegeKeys.exposedFunctionCounts(projectRef, selectedSchemas),
|
||||
queryFn: ({ signal }) =>
|
||||
getExposedFunctionCounts(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
selectedSchemas,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { infiniteQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { privilegeKeys } from './keys'
|
||||
import { getExposedFunctionsSql } from './privileges.sql'
|
||||
|
||||
export const EXPOSED_FUNCTIONS_PAGE_LIMIT = 50
|
||||
|
||||
export type ExposedFunctionsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type ExposedFunction = {
|
||||
schema: string
|
||||
name: string
|
||||
status: 'granted' | 'revoked' | 'custom'
|
||||
}
|
||||
|
||||
export type ExposedFunctionsResponse = {
|
||||
total_count: number
|
||||
functions: ExposedFunction[]
|
||||
}
|
||||
|
||||
export async function getExposedFunctions(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
search,
|
||||
page = 0,
|
||||
limit = EXPOSED_FUNCTIONS_PAGE_LIMIT,
|
||||
}: ExposedFunctionsVariables & { page?: number; limit?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<ExposedFunctionsResponse> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const offset = page * limit
|
||||
|
||||
const sql = getExposedFunctionsSql({ search, offset, limit })
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['exposed-functions', page],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0] as ExposedFunctionsResponse
|
||||
}
|
||||
|
||||
export type ExposedFunctionsData = Awaited<ReturnType<typeof getExposedFunctions>>
|
||||
export type ExposedFunctionsError = ResponseError
|
||||
|
||||
export const exposedFunctionsInfiniteQueryOptions = (
|
||||
{ projectRef, connectionString, search }: ExposedFunctionsVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
return infiniteQueryOptions({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
|
||||
queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef, search),
|
||||
queryFn: ({ signal, pageParam }) =>
|
||||
getExposedFunctions(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
search,
|
||||
page: pageParam,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
initialPageParam: 0,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
const page = pages.length
|
||||
const currentTotalCount = page * EXPOSED_FUNCTIONS_PAGE_LIMIT
|
||||
const totalCount = lastPage.total_count ?? 0
|
||||
|
||||
if (currentTotalCount >= totalCount) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return page
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -13,4 +13,14 @@ export const privilegeKeys = {
|
||||
] as const,
|
||||
exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
|
||||
['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const,
|
||||
exposedFunctionsInfinite: (projectRef: string | undefined, search?: string) =>
|
||||
[
|
||||
'projects',
|
||||
projectRef,
|
||||
'privileges',
|
||||
'exposed-functions-infinite',
|
||||
...(search ? ([{ search }] as const) : []),
|
||||
] as const,
|
||||
exposedFunctionCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
|
||||
['projects', projectRef, 'privileges', 'exposed-function-counts', ...selectedSchemas] as const,
|
||||
}
|
||||
@@ -121,42 +121,164 @@ export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas:
|
||||
|
||||
return /* SQL */ `
|
||||
with ${getTableGrantsCTEs()}
|
||||
select
|
||||
select
|
||||
count(*)::int as total_count,
|
||||
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
|
||||
from table_grants
|
||||
from table_grants
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedSchemasSql() {
|
||||
/**
|
||||
* Builds the shared `function_privileges` and `function_grants` CTEs used by
|
||||
* both the exposed-functions list query and the counts-only query.
|
||||
*
|
||||
* Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
|
||||
* Callers that append additional CTEs should add a comma after interpolation.
|
||||
*/
|
||||
function getFunctionGrantsCTEs({ search }: { search?: string } = {}) {
|
||||
return /* SQL */ `
|
||||
select coalesce(
|
||||
(
|
||||
select jsonb_agg(distinct schema_name order by schema_name)
|
||||
from (
|
||||
select n.nspname as schema_name
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true
|
||||
where c.relkind in ('r', 'p', 'v', 'm', 'f')
|
||||
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
||||
group by c.oid, n.nspname
|
||||
having
|
||||
bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'anon'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
function_privileges as (
|
||||
select
|
||||
n.nspname as schema_name,
|
||||
p.proname as name,
|
||||
|
||||
-- Aggregate EXECUTE across all overloads + all 3 roles
|
||||
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'EXECUTE') as anon_execute,
|
||||
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'EXECUTE') as auth_execute,
|
||||
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'EXECUTE') as srv_execute
|
||||
|
||||
from pg_proc p
|
||||
join pg_namespace n
|
||||
on n.oid = p.pronamespace
|
||||
left join lateral aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) as acl
|
||||
on true
|
||||
left join pg_roles pr
|
||||
on pr.oid = acl.grantee
|
||||
where p.prokind in ('f', 'w')
|
||||
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
||||
${search ? `and (n.nspname || '.' || p.proname) ilike '%${search}%'` : ''}
|
||||
group by n.nspname, p.proname
|
||||
),
|
||||
function_grants as (
|
||||
select
|
||||
schema_name,
|
||||
name,
|
||||
case
|
||||
when anon_execute and auth_execute and srv_execute then 'granted'
|
||||
when not (anon_execute or auth_execute or srv_execute) then 'revoked'
|
||||
else 'custom'
|
||||
end as status
|
||||
from function_privileges
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedFunctionsSql({
|
||||
search,
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
search?: string
|
||||
offset: number
|
||||
limit: number
|
||||
}) {
|
||||
return /* SQL */ `
|
||||
with ${getFunctionGrantsCTEs({ search })}
|
||||
select
|
||||
(select count(*)::int from function_grants) as total_count,
|
||||
coalesce(
|
||||
(
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'schema', fg.schema_name,
|
||||
'name', fg.name,
|
||||
'status', fg.status
|
||||
)
|
||||
and bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
)
|
||||
and bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
)
|
||||
) t
|
||||
),
|
||||
'[]'::jsonb
|
||||
) as schemas;
|
||||
)
|
||||
from (
|
||||
select *
|
||||
from function_grants
|
||||
order by schema_name, name
|
||||
offset ${offset}
|
||||
limit ${limit}
|
||||
) fg
|
||||
),
|
||||
'[]'::jsonb
|
||||
) as functions;
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedFunctionCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
|
||||
const schemasList =
|
||||
selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
|
||||
|
||||
return /* SQL */ `
|
||||
with ${getFunctionGrantsCTEs()}
|
||||
select
|
||||
count(*)::int as total_count,
|
||||
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
|
||||
from function_grants
|
||||
`
|
||||
}
|
||||
|
||||
export const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
|
||||
if (oids.length === 0) return ''
|
||||
|
||||
const privilegeClause =
|
||||
action === 'grant'
|
||||
? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
|
||||
: 'revoke all on table %I.%I from anon, authenticated, service_role'
|
||||
|
||||
return /* SQL */ `
|
||||
do $$
|
||||
declare
|
||||
nspname name;
|
||||
relname name;
|
||||
begin
|
||||
for nspname, relname in
|
||||
select n.nspname, c.relname
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid in (${oids.join(', ')})
|
||||
loop
|
||||
execute format('${privilegeClause}', nspname, relname);
|
||||
end loop;
|
||||
end $$;
|
||||
`
|
||||
}
|
||||
|
||||
export const buildFunctionPrivilegesSql = (schemaNames: string[], action: 'grant' | 'revoke') => {
|
||||
if (schemaNames.length === 0) return ''
|
||||
|
||||
const tuples = schemaNames
|
||||
.map((sn) => {
|
||||
const dotIdx = sn.indexOf('.')
|
||||
const schema = sn.slice(0, dotIdx)
|
||||
const name = sn.slice(dotIdx + 1)
|
||||
return `('${schema}','${name}')`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
const privilegeClause =
|
||||
action === 'grant'
|
||||
? 'grant execute on function %I.%I(%s) to anon, authenticated, service_role'
|
||||
: 'revoke all on function %I.%I(%s) from anon, authenticated, service_role'
|
||||
|
||||
return /* SQL */ `
|
||||
do $$
|
||||
declare
|
||||
nspname name;
|
||||
proname name;
|
||||
arg_types text;
|
||||
begin
|
||||
for nspname, proname, arg_types in
|
||||
select n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where (n.nspname, p.proname) in (${tuples})
|
||||
loop
|
||||
execute format('${privilegeClause}', nspname, proname, arg_types);
|
||||
end loop;
|
||||
end $$;
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { UseCustomMutationOptions } from 'types'
|
||||
|
||||
import type { ConnectionVars } from '../common.types'
|
||||
import { buildFunctionPrivilegesSql, buildTablePrivilegesSql } from './privileges.sql'
|
||||
|
||||
export type UpdateExposedEntitiesVariables = ConnectionVars & {
|
||||
tableIdsToAdd: number[]
|
||||
tableIdsToRemove: number[]
|
||||
functionNamesToAdd: string[]
|
||||
functionNamesToRemove: string[]
|
||||
}
|
||||
|
||||
export async function updateExposedEntities({
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableIdsToAdd,
|
||||
tableIdsToRemove,
|
||||
functionNamesToAdd,
|
||||
functionNamesToRemove,
|
||||
}: UpdateExposedEntitiesVariables): Promise<void> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const sqlParts: string[] = []
|
||||
|
||||
if (tableIdsToAdd.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant'))
|
||||
}
|
||||
|
||||
if (tableIdsToRemove.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke'))
|
||||
}
|
||||
|
||||
if (functionNamesToAdd.length > 0) {
|
||||
sqlParts.push(buildFunctionPrivilegesSql(functionNamesToAdd, 'grant'))
|
||||
}
|
||||
|
||||
if (functionNamesToRemove.length > 0) {
|
||||
sqlParts.push(buildFunctionPrivilegesSql(functionNamesToRemove, 'revoke'))
|
||||
}
|
||||
|
||||
if (sqlParts.length === 0) return
|
||||
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlParts.join('\n'),
|
||||
queryKey: ['update-exposed-entities'],
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateExposedEntitiesData = Awaited<ReturnType<typeof updateExposedEntities>>
|
||||
|
||||
export const useUpdateExposedEntitiesMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<UpdateExposedEntitiesData, Error, UpdateExposedEntitiesVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<UpdateExposedEntitiesData, Error, UpdateExposedEntitiesVariables>({
|
||||
mutationFn: (vars: UpdateExposedEntitiesVariables) => updateExposedEntities(vars),
|
||||
onError(error: Error) {
|
||||
toast.error(`Failed to update entity access: ${error.message}`)
|
||||
},
|
||||
...(onError ? { onError } : {}),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { UseCustomMutationOptions } from 'types'
|
||||
|
||||
import type { ConnectionVars } from '../common.types'
|
||||
import { getExposedSchemasSql } from './privileges.sql'
|
||||
|
||||
export type UpdateExposedTablesVariables = ConnectionVars & {
|
||||
tableIdsToAdd: number[]
|
||||
tableIdsToRemove: number[]
|
||||
}
|
||||
|
||||
const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
|
||||
if (oids.length === 0) return ''
|
||||
|
||||
const privilegeClause =
|
||||
action === 'grant'
|
||||
? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
|
||||
: 'revoke all on table %I.%I from anon, authenticated, service_role'
|
||||
|
||||
return /* SQL */ `
|
||||
do $$
|
||||
declare
|
||||
relname name;
|
||||
nspname name;
|
||||
begin
|
||||
for nspname, relname in
|
||||
select n.nspname, c.relname
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid in (${oids.join(', ')})
|
||||
loop
|
||||
execute format('${privilegeClause}', relname, nspname);
|
||||
end loop;
|
||||
end $$;
|
||||
`
|
||||
}
|
||||
|
||||
export async function updateExposedTables({
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableIdsToAdd,
|
||||
tableIdsToRemove,
|
||||
}: UpdateExposedTablesVariables): Promise<string[]> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const sqlParts: string[] = []
|
||||
|
||||
if (tableIdsToAdd.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant'))
|
||||
}
|
||||
|
||||
if (tableIdsToRemove.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke'))
|
||||
}
|
||||
|
||||
sqlParts.push(getExposedSchemasSql())
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlParts.join('\n'),
|
||||
queryKey: ['update-exposed-tables'],
|
||||
})
|
||||
|
||||
return (result[0] as { schemas: string[] }).schemas
|
||||
}
|
||||
|
||||
type UpdateExposedTablesData = Awaited<ReturnType<typeof updateExposedTables>>
|
||||
|
||||
export const useUpdateExposedTablesMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>({
|
||||
mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars),
|
||||
onError(error: Error) {
|
||||
toast.error(`Failed to update table access: ${error.message}`)
|
||||
},
|
||||
...(onError ? { onError } : {}),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export async function getTables(
|
||||
return sortBy(data, (t) => t[sortByProperty]) as PostgresTable[]
|
||||
}
|
||||
|
||||
return data as Omit<PostgresTable, 'columns'>[]
|
||||
return data as PostgresTable[]
|
||||
}
|
||||
|
||||
export type TablesData = Awaited<ReturnType<typeof getTables>>
|
||||
|
||||
@@ -88,6 +88,11 @@ const nextConfig = {
|
||||
destination: '/sign-in',
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/project/:ref/building',
|
||||
destination: '/project/:ref',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
@@ -120,6 +125,11 @@ const nextConfig = {
|
||||
destination: '/project/default',
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/project/:ref/building',
|
||||
destination: '/project/:ref',
|
||||
permanent: false,
|
||||
},
|
||||
]),
|
||||
{
|
||||
source: '/project/:ref/auth',
|
||||
|
||||
@@ -85,7 +85,6 @@ const Wizard: NextPageWithLayout = () => {
|
||||
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
|
||||
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
|
||||
const isHomeNew = usePHFlag('homeNew') === 'new-home'
|
||||
|
||||
const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
|
||||
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
|
||||
@@ -253,7 +252,7 @@ const Wizard: NextPageWithLayout = () => {
|
||||
organization: res.organization_slug,
|
||||
}
|
||||
)
|
||||
router.push(isHomeNew ? `/project/${res.ref}` : `/project/${res.ref}/building`)
|
||||
router.push(`/project/${res.ref}`)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { AuditLogsForm } from 'components/interfaces/Auth/AuditLogsForm'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -62,7 +61,7 @@ const AuditLogsPage: NextPageWithLayout = () => {
|
||||
|
||||
AuditLogsPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="Audit Logs">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { HooksListing } from 'components/interfaces/Auth/Hooks/HooksListing'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -60,7 +59,7 @@ const Hooks: NextPageWithLayout = () => {
|
||||
|
||||
Hooks.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="Auth Hooks">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const Authentication: NextPageWithLayout = () => {
|
||||
return <>{/* <h1>Use this as a template for authentication pages</h1> */}</>
|
||||
}
|
||||
|
||||
Authentication.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default Authentication
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { MfaAuthSettingsForm } from 'components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
@@ -66,7 +65,7 @@ const MfaPage: NextPageWithLayout = () => {
|
||||
|
||||
MfaPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="Multi-Factor">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const OAuthApps: NextPageWithLayout = () => (
|
||||
|
||||
OAuthApps.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="OAuth Apps">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const ProvidersPage: NextPageWithLayout = () => {
|
||||
|
||||
ProvidersPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="OAuth Server">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useContext, useEffect } from 'react'
|
||||
|
||||
import { FeatureFlagContext, useFlag, useParams } from 'common'
|
||||
import { OverviewLearnMore } from 'components/interfaces/Auth/Overview/OverviewLearnMore'
|
||||
import { OverviewMetrics } from 'components/interfaces/Auth/Overview/OverviewMetrics'
|
||||
@@ -9,6 +6,8 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useAuthOverviewQuery } from 'data/auth/auth-overview-query'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useContext, useEffect } from 'react'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
@@ -69,7 +68,7 @@ const AuthOverview: NextPageWithLayout = () => {
|
||||
|
||||
AuthOverview.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout title="Overview">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user