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:
Danny White committed 2026-03-11 14:20:12 +11:00
commit e30063dab2
166 files changed
+6253 -12733

No files matched your search

+6 -3
View File
@@ -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:**
+3
View File
@@ -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
+8 -6
View File
@@ -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:
+3
View File
@@ -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'
+202 -63
View File
@@ -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.
![image](/docs/img/troubleshooting/reports-page-egress.png)
**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`
![image](/docs/img/troubleshooting/limit_logs.png)
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:
![image](/docs/img/troubleshooting/546_errors_by_hour.png)
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).
+1
View File
@@ -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

-1
View File
@@ -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_>
)
}
@@ -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,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,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
@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import type { LogsBarChartDatum } from './ProjectUsage.metrics'
/**
@@ -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>) => {
@@ -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)
})
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
computeSuccessAndNonSuccessRates,
sumErrors,
@@ -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"
@@ -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}
@@ -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
},
})
}
+10
View File
@@ -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,
}
+152 -30
View File
@@ -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,
})
}
+1 -1
View File
@@ -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>>
+10
View File
@@ -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',
+1 -2
View File
@@ -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 -2
View File
@@ -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