diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f7ea12c26a5..3feaae032a1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ /docker/ @supabase/dev-workflows -/apps/studio/next.config.js @supabase/security +/apps/studio/csp.js @supabase/security /apps/studio/components/interfaces/Billing/Payment @supabase/security /apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security /apps/studio/components/interfaces/Organization/Documents/ @supabase/security diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts index f7ecee53de6..2b7020fabc1 100644 --- a/apps/docs/app/api/graphql/route.ts +++ b/apps/docs/app/api/graphql/route.ts @@ -37,6 +37,31 @@ export const preferredRegion = [ const MAX_DEPTH = 5 +function isAllowedCorsOrigin(origin: string): boolean { + const exactMatches = IS_DEV + ? ['http://localhost:8082', 'https://supabase.com'] + : ['https://supabase.com'] + if (exactMatches.includes(origin)) { + return true + } + + return /^https:\/\/[\w-]+\w-supabase.vercel.app$/.test(origin) +} + +function getCorsHeaders(request: Request): Record { + const origin = request.headers.get('Origin') + + if (origin && isAllowedCorsOrigin(origin)) { + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept', + } + } + + return {} +} + const validationRules = [ ...specifiedRules, createQueryDepthLimiter(MAX_DEPTH), @@ -78,13 +103,18 @@ async function handleGraphQLRequest(request: Request): Promise { const { query, variables, operationName } = parsedBody.data const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request)) if (validationErrors.length > 0) { - return NextResponse.json({ - errors: validationErrors.map((error) => ({ - message: error.message, - locations: error.locations, - path: error.path, - })), - }) + return NextResponse.json( + { + errors: validationErrors.map((error) => ({ + message: error.message, + locations: error.locations, + path: error.path, + })), + }, + { + headers: getCorsHeaders(request), + } + ) } const result = await graphql({ @@ -94,7 +124,9 @@ async function handleGraphQLRequest(request: Request): Promise { variableValues: variables, operationName, }) - return NextResponse.json(result) + return NextResponse.json(result, { + headers: getCorsHeaders(request), + }) } function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray { @@ -112,6 +144,14 @@ function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyA return validate(rootGraphQLSchema, documentAST, rules) } +export async function OPTIONS(request: Request): Promise { + const corsHeaders = getCorsHeaders(request) + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }) +} + export async function POST(request: Request): Promise { try { const result = await handleGraphQLRequest(request) @@ -130,18 +170,28 @@ export async function POST(request: Request): Promise { // https://github.com/getsentry/sentry-javascript/issues/9626 await Sentry.flush(2000) - return NextResponse.json({ - errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }], - }) + return NextResponse.json( + { + errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }], + }, + { + headers: getCorsHeaders(request), + } + ) } else { Sentry.captureException(error) // Do not let Vercel close the process until Sentry has flushed // https://github.com/getsentry/sentry-javascript/issues/9626 await Sentry.flush(2000) - return NextResponse.json({ - errors: [{ message: 'Internal Server Error' }], - }) + return NextResponse.json( + { + errors: [{ message: 'Internal Server Error' }], + }, + { + headers: getCorsHeaders(request), + } + ) } } } diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 5f4042a381a..420f18093ce 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -11,6 +11,7 @@ import { genFaviconData } from 'common/MetaFavicons/app-router' import { GlobalProviders } from '~/features/app.providers' import { TopNavSkeleton } from '~/layouts/MainSkeleton' import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants' +import { TelemetryTagManager } from 'common' const metadata: Metadata = { applicationName: 'Supabase Docs', @@ -47,6 +48,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { return ( + {children} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 709b3c495db..389a9694273 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -663,6 +663,7 @@ export const auth = { { name: 'Firebase Auth', url: '/guides/auth/third-party/firebase-auth' }, { name: 'Auth0', url: '/guides/auth/third-party/auth0' }, { name: 'AWS Cognito (Amplify)', url: '/guides/auth/third-party/aws-cognito' }, + { name: 'WorkOS', url: '/guides/auth/third-party/workos' }, ], }, { @@ -714,6 +715,7 @@ export const auth = { { name: 'Rate Limits', url: '/guides/auth/rate-limits' }, { name: 'Bot Detection (CAPTCHA)', url: '/guides/auth/auth-captcha' }, { name: 'JWTs', url: '/guides/auth/jwts' }, + { name: 'JWT Fields Reference', url: '/guides/auth/jwt-fields' }, { name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' }, { name: 'Column Level Security', diff --git a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx index ff9420f1ca3..362d92da9c6 100644 --- a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx +++ b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx @@ -1 +1 @@ -There is no fixed fee for a Preview branch. You only pay for the usage it incurs. With Compute costs of per hour, a branch running on Micro Compute size starts at per day. +There is no fixed fee for a Preview branch. You only pay for the usage it incurs. A branch running on the default Micro Compute size starts at per hour. diff --git a/apps/docs/content/_partials/quickstart_db_setup.mdx b/apps/docs/content/_partials/quickstart_db_setup.mdx index cd282520da8..161ea8b7f5e 100644 --- a/apps/docs/content/_partials/quickstart_db_setup.mdx +++ b/apps/docs/content/_partials/quickstart_db_setup.mdx @@ -2,6 +2,28 @@ Go to [database.new](https://database.new) and create a new Supabase project. +Alternatively, you can create a project using the Management API: + +```bash +# First, get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" + +# List your organizations to get the organization ID +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + https://api.supabase.com/v1/organizations + +# Create a new project (replace with your organization ID) +curl -X POST https://api.supabase.com/v1/projects \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "organization_id": "", + "name": "My Project", + "region": "us-east-1", + "password": "" + }' +``` + When your project is up and running, go to the [Table Editor](https://supabase.com/dashboard/project/_/editor), create a new table and insert some data. Alternatively, you can run the following snippet in your project's [SQL Editor](https://supabase.com/dashboard/project/_/sql/new). This will create a `instruments` table with some sample data. diff --git a/apps/docs/content/guides/auth.mdx b/apps/docs/content/guides/auth.mdx index 8fefd30e7a3..213d1b91a1c 100644 --- a/apps/docs/content/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -17,7 +17,7 @@ Authentication and authorization are the core responsibilities of any Auth syste - **Authentication** means checking that a user is who they say they are. - **Authorization** means checking what resources a user is allowed to access. -Supabase Auth uses [JSON Web Tokens (JWTs)](/docs/guides/auth/jwts) for authentication. Auth integrates with Supabase's database features, making it easy to use [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for authorization. +Supabase Auth uses [JSON Web Tokens (JWTs)](/docs/guides/auth/jwts) for authentication. For a complete reference of all JWT fields, see the [JWT Fields Reference](/docs/guides/auth/jwt-fields). Auth integrates with Supabase's database features, making it easy to use [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for authorization. ## The Supabase ecosystem diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index af34bfd7f4c..20167f74ac3 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -29,6 +29,36 @@ The templating system provides the following variables for use: On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/local-development/customizing-email-templates). +You can also manage email templates using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current email templates +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + | jq 'to_entries | map(select(.key | startswith("mailer_templates"))) | from_entries' + +# Update email templates +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "mailer_subjects_confirmation": "Confirm your signup", + "mailer_templates_confirmation_content": "

Confirm your signup

Follow this link to confirm your user:

Confirm your email

", + "mailer_subjects_magic_link": "Your Magic Link", + "mailer_templates_magic_link_content": "

Magic Link

Follow this link to login:

Log In

", + "mailer_subjects_recovery": "Rest Your Password", + "mailer_templates_recovery_content": "

Reset Password

Follow this link to reset the password for your user:

Reset Password

", + "mailer_subjects_invite": "You have been invited", + "mailer_templates_invite_content": "

You have been invited

You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:

Accept the invite

", + "mailer_subjects_email_change": "Confirm email change", + "mailer_templates_email_change_content": "

Confirm email change

Follow this link to confirm the update of your email:

Change email

", + }' +``` + ## Mobile deep linking For mobile applications, you might need to link or redirect to a specific page within your app. See the [Mobile Deep Linking guide](/docs/guides/auth/native-mobile-deep-linking) to set this up. diff --git a/apps/docs/content/guides/auth/auth-smtp.mdx b/apps/docs/content/guides/auth/auth-smtp.mdx index 1d1558d3c18..5f7f05b0ed5 100644 --- a/apps/docs/content/guides/auth/auth-smtp.mdx +++ b/apps/docs/content/guides/auth/auth-smtp.mdx @@ -50,6 +50,30 @@ A non-exhaustive list of services that work with Supabase Auth is: Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/settings/auth) to enable and configure custom SMTP. +You can also configure custom SMTP using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure custom SMTP +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_email_enabled": true, + "mailer_secure_email_change_enabled": true, + "mailer_autoconfirm": false, + "smtp_admin_email": "no-reply@example.com", + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_user": "your-smtp-user", + "smtp_pass": "your-smtp-password", + "smtp_sender_name": "Your App Name" + }' +``` + Once you save these settings, your project's Auth server will send messages to all addresses. To protect the reputation of your newly set up service a low rate-limit of 30 messages per hour is imposed. To adjust this to an acceptable value for your use case head to the [Rate Limits configuration page](/dashboard/project/_/auth/rate-limits). ## Dealing with abuse: How to maintain the sending reputation of your SMTP server? diff --git a/apps/docs/content/guides/auth/jwt-fields.mdx b/apps/docs/content/guides/auth/jwt-fields.mdx new file mode 100644 index 00000000000..2a154286456 --- /dev/null +++ b/apps/docs/content/guides/auth/jwt-fields.mdx @@ -0,0 +1,288 @@ +--- +id: 'jwt-fields' +title: 'JWT Fields Reference' +subtitle: 'Complete reference for JWT fields in Supabase' +--- + +This page provides a comprehensive reference for all JWT fields used in Supabase authentication tokens. This information is essential for server-side JWT validation and serialization, especially when implementing authentication in languages like Rust where field names like `ref` are reserved keywords. + +## JWT structure overview + +Supabase JWTs follow the standard JWT structure with three parts: + +- **Header**: Contains algorithm and key information +- **Payload**: Contains the claims (user data and metadata) +- **Signature**: Cryptographic signature for verification + +The payload contains various claims that provide user identity, authentication level, and authorization information. + +## Required claims + +These claims are always present in Supabase JWTs and cannot be removed: + +| Field | Type | Description | Example | +| -------------- | -------------------- | ----------------------------------------------------------- | --------------------------------------------- | +| `iss` | `string` | **Issuer** - The entity that issued the JWT | `"https://project-ref.supabase.co/auth/v1"` | +| `aud` | `string \| string[]` | **Audience** - The intended recipient of the JWT | `"authenticated"` or `"anon"` | +| `exp` | `number` | **Expiration Time** - Unix timestamp when the token expires | `1640995200` | +| `iat` | `number` | **Issued At** - Unix timestamp when the token was issued | `1640991600` | +| `sub` | `string` | **Subject** - The user ID (UUID) | `"123e4567-e89b-12d3-a456-426614174000"` | +| `role` | `string` | **Role** - User's role in the system | `"authenticated"`, `"anon"`, `"service_role"` | +| `aal` | `string` | **Authenticator Assurance Level** - Authentication strength | `"aal1"`, `"aal2"` | +| `session_id` | `string` | **Session ID** - Unique session identifier | `"session-uuid"` | +| `email` | `string` | **Email** - User's email address | `"user@example.com"` | +| `phone` | `string` | **Phone** - User's phone number | `"+1234567890"` | +| `is_anonymous` | `boolean` | **Anonymous Flag** - Whether the user is anonymous | `false` | + +## Optional claims + +These claims may be present depending on the authentication context: + +| Field | Type | Description | Example | +| --------------- | -------- | -------------------------------------------------------------------------- | --------------------------------------------------- | +| `jti` | `string` | **JWT ID** - Unique identifier for the JWT | `"jwt-uuid"` | +| `nbf` | `number` | **Not Before** - Unix timestamp before which the token is invalid | `1640991600` | +| `app_metadata` | `object` | **App Metadata** - Application-specific user data | `{"provider": "email"}` | +| `user_metadata` | `object` | **User Metadata** - User-specific data | `{"name": "John Doe"}` | +| `amr` | `array` | **Authentication Methods Reference** - List of authentication methods used | `[{"method": "password", "timestamp": 1640991600}]` | + +## Special fields + +| Field | Type | Description | Example | Context | +| ----- | -------- | --------------------------------------------------- | ------------------------ | ----------------------------- | +| `ref` | `string` | **Project Reference** - Supabase project identifier | `"abcdefghijklmnopqrst"` | Anon/Service role tokens only | + +## Field value constraints + +### Authenticator assurance level (`aal`) + +| Value | Description | +| -------- | ---------------------------------------------------- | +| `"aal1"` | Single-factor authentication (password, OAuth, etc.) | +| `"aal2"` | Multi-factor authentication (password + TOTP, etc.) | + +### Role values (`role`) + +| Value | Description | Use Case | +| ----------------- | ------------------ | ----------------------------------- | +| `"anon"` | Anonymous user | Public access with RLS policies | +| `"authenticated"` | Authenticated user | Standard user access | +| `"service_role"` | Service role | Admin privileges (server-side only) | + +### Audience values (`aud`) + +| Value | Description | +| ----------------- | ----------------------------- | +| `"authenticated"` | For authenticated user tokens | +| `"anon"` | For anonymous user tokens | + +### Authentication methods (`amr.method`) + +| Value | Description | +| ----------------- | ----------------------------- | +| `"oauth"` | OAuth provider authentication | +| `"password"` | Email/password authentication | +| `"otp"` | One-time password | +| `"totp"` | Time-based one-time password | +| `"recovery"` | Account recovery | +| `"invite"` | Invitation-based signup | +| `"sso/saml"` | SAML single sign-on | +| `"magiclink"` | Magic link authentication | +| `"email/signup"` | Email signup | +| `"email_change"` | Email change | +| `"token_refresh"` | Token refresh | +| `"anonymous"` | Anonymous authentication | + +## JWT examples + +### Authenticated user token + +```json +{ + "aal": "aal1", + "amr": [ + { + "method": "password", + "timestamp": 1640991600 + } + ], + "app_metadata": { + "provider": "email", + "providers": ["email"] + }, + "aud": "authenticated", + "email": "user@example.com", + "exp": 1640995200, + "iat": 1640991600, + "iss": "https://abcdefghijklmnopqrst.supabase.co/auth/v1", + "phone": "", + "role": "authenticated", + "session_id": "123e4567-e89b-12d3-a456-426614174000", + "sub": "123e4567-e89b-12d3-a456-426614174000", + "user_metadata": { + "name": "John Doe" + }, + "is_anonymous": false +} +``` + +### Anonymous user token + +```json +{ + "iss": "supabase", + "ref": "abcdefghijklmnopqrst", + "role": "anon", + "iat": 1640991600, + "exp": 1640995200 +} +``` + +### Service role token + +```json +{ + "iss": "supabase", + "ref": "abcdefghijklmnopqrst", + "role": "service_role", + "iat": 1640991600, + "exp": 1640995200 +} +``` + +## Language-Specific considerations + +### Rust + +In Rust, the `ref` field is a reserved keyword. When deserializing JWTs, you'll need to handle this: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +struct JwtClaims { + iss: String, + #[serde(rename = "ref")] // Handle reserved keyword + project_ref: Option, + role: String, + iat: i64, + exp: i64, + // ... other fields +} +``` + +### TypeScript/JavaScript + +```typescript +interface JwtClaims { + iss: string + aud: string | string[] + exp: number + iat: number + sub: string + role: string + aal: 'aal1' | 'aal2' + session_id: string + email: string + phone: string + is_anonymous: boolean + jti?: string + nbf?: number + app_metadata?: Record + user_metadata?: Record + amr?: Array<{ + method: string + timestamp: number + }> + ref?: string // Only in anon/service role tokens +} +``` + +### Python + +```python +from typing import Optional, Union, List, Dict, Any +from dataclasses import dataclass + +@dataclass +class AmrEntry: + method: str + timestamp: int + +@dataclass +class JwtClaims: + iss: str + aud: Union[str, List[str]] + exp: int + iat: int + sub: str + role: str + aal: str + session_id: str + email: str + phone: str + is_anonymous: bool + jti: Optional[str] = None + nbf: Optional[int] = None + app_metadata: Optional[Dict[str, Any]] = None + user_metadata: Optional[Dict[str, Any]] = None + amr: Optional[List[AmrEntry]] = None + ref: Optional[str] = None # Only in anon/service role tokens +``` + +### Go + +```go +type AmrEntry struct { + Method string `json:"method"` + Timestamp int64 `json:"timestamp"` +} + +type JwtClaims struct { + Iss string `json:"iss"` + Aud interface{} `json:"aud"` // string or []string + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Sub string `json:"sub"` + Role string `json:"role"` + Aal string `json:"aal"` + SessionID string `json:"session_id"` + Email string `json:"email"` + Phone string `json:"phone"` + IsAnonymous bool `json:"is_anonymous"` + Jti *string `json:"jti,omitempty"` + Nbf *int64 `json:"nbf,omitempty"` + AppMetadata map[string]interface{} `json:"app_metadata,omitempty"` + UserMetadata map[string]interface{} `json:"user_metadata,omitempty"` + Amr []AmrEntry `json:"amr,omitempty"` + Ref *string `json:"ref,omitempty"` // Only in anon/service role tokens +} +``` + +## Validation guidelines + +When implementing JWT validation on your server: + +1. **Check Required Fields**: Ensure all required claims are present +2. **Validate Types**: Verify field types match expected types +3. **Check Expiration**: Validate `exp` timestamp is in the future +4. **Verify Issuer**: Ensure `iss` matches your Supabase project +5. **Check Audience**: Validate `aud` matches expected audience +6. **Handle Reserved Keywords**: Use field renaming for languages like Rust + +## Security considerations + +- **Always validate the JWT signature** before trusting any claims +- **Never expose service role tokens** to client-side code +- **Validate all claims** before trusting the JWT +- **Check token expiration** on every request +- **Use HTTPS** for all JWT transmission +- **Rotate JWT secrets** regularly +- **Implement proper error handling** for invalid tokens + +## Related documentation + +- [JWT Overview](/docs/guides/auth/jwts) +- [Custom Access Token Hooks](/docs/guides/auth/auth-hooks/custom-access-token-hook) +- [Row Level Security](/docs/guides/database/postgres/row-level-security) +- [Server-Side Auth](/docs/guides/auth/server-side) diff --git a/apps/docs/content/guides/auth/jwts.mdx b/apps/docs/content/guides/auth/jwts.mdx index b568d6c7b66..0e68d9952f4 100644 --- a/apps/docs/content/guides/auth/jwts.mdx +++ b/apps/docs/content/guides/auth/jwts.mdx @@ -177,3 +177,4 @@ Now that you understand what JWTs are and where they're used in Supabase, you ca ## Resources - JWT debugger: https://jwt.io/ +- [JWT Fields Reference](/docs/guides/auth/jwt-fields) - Complete reference for all JWT fields in Supabase diff --git a/apps/docs/content/guides/auth/rate-limits.mdx b/apps/docs/content/guides/auth/rate-limits.mdx index 6edd22b0df8..1233f2c06b3 100644 --- a/apps/docs/content/guides/auth/rate-limits.mdx +++ b/apps/docs/content/guides/auth/rate-limits.mdx @@ -5,4 +5,31 @@ subtitle: 'Rate limits protect your services from abuse' Supabase Auth enforces rate limits on endpoints to prevent abuse. Some rate limits are [customizable](/dashboard/project/_/auth/rate-limits). +You can also manage rate limits using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current rate limits +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + | jq 'to_entries | map(select(.key | startswith("rate_limit_"))) | from_entries' + +# Update rate limits +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "rate_limit_anonymous_users": 10, + "rate_limit_email_sent": 10, + "rate_limit_sms_sent": 10, + "rate_limit_verify": 10, + "rate_limit_token_refresh": 10, + "rate_limit_otp": 10, + "rate_limit_web3": 10 + }' +``` + <$Partial path="auth_rate_limits.mdx" /> diff --git a/apps/docs/content/guides/auth/server-side/sveltekit.mdx b/apps/docs/content/guides/auth/server-side/sveltekit.mdx index 845cd0aebbc..f8b0719df4b 100644 --- a/apps/docs/content/guides/auth/server-side/sveltekit.mdx +++ b/apps/docs/content/guides/auth/server-side/sveltekit.mdx @@ -321,7 +321,7 @@ Set up a listener for Auth events on the client, to handle session refreshes and -Create your first page. This example page calls Supabase from the server to get a list of countries from the database. +Create your first page. This example page calls Supabase from the server to get a list of colors from the database. This is an example of a public page that uses publicly readable data. diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index a80abc34d71..b2fd02f6eec 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -67,6 +67,24 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, 6. Create a signing **Key** in the [Keys](https://developer.apple.com/account/resources/authkeys/list) section of the Apple Developer Console. You can use this key to generate a secret key using the tool below, which is added to your Supabase project's Auth configuration. Make sure you safely store the `AuthKey_XXXXXXXXXX.p8` file. If you ever lose access to it, or make it public accidentally, revoke it from the Apple Developer Console and create a new one immediately. You will have to generate a new secret key using this file every 6 months, so make sure you schedule a recurring meeting in your calendar! 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers). +You can also configure the Apple auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Apple auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_apple_enabled": true, + "external_apple_client_id": "your-services-id", + "external_apple_secret": "your-generated-secret-key" + }' +``` + Use this tool to generate a new Apple client secret. No keys leave your browser! Be aware that this tool does not currently work in Safari, so use Firefox or a Chrome-based browser instead. diff --git a/apps/docs/content/guides/auth/social-login/auth-azure.mdx b/apps/docs/content/guides/auth/social-login/auth-azure.mdx index 1b31c75b679..1057da8f831 100644 --- a/apps/docs/content/guides/auth/social-login/auth-azure.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-azure.mdx @@ -44,6 +44,25 @@ Setting up OAuth with Azure consists of four broad steps: ![Obtain the client secret](/docs/img/guides/auth-azure/azure-client-secret.png) +You can also configure the Azure auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Azure auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_azure_enabled": true, + "external_azure_client_id": "your-azure-client-id", + "external_azure_secret": "your-azure-client-secret", + "external_azure_url": "your-azure-url" + }' +``` + ## Guarding against unverified email domains Microsoft Entra ID can send out unverified email domains in certain cases. This may open up your project to a vulnerability where a malicious user can impersonate already existing accounts on your project. diff --git a/apps/docs/content/guides/auth/social-login/auth-discord.mdx b/apps/docs/content/guides/auth/social-login/auth-discord.mdx index 64ca269b7c4..18992be42c0 100644 --- a/apps/docs/content/guides/auth/social-login/auth-discord.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-discord.mdx @@ -43,6 +43,24 @@ Setting up Discord logins for your application consists of 3 parts: <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "Discord" }} /> +You can also configure the Discord auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Discord auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_discord_enabled": true, + "external_discord_client_id": "your-discord-client-id", + "external_discord_secret": "your-discord-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Facebook auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Facebook auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_facebook_enabled": true, + "external_facebook_client_id": "your-facebook-app-id", + "external_facebook_secret": "your-facebook-app-secret" + }' +``` + ## Add login code to your client app +You can also configure the GitHub auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure GitHub auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_github_enabled": true, + "external_github_client_id": "your-github-client-id", + "external_github_secret": "your-github-client-secret" + }' +``` + ## Add login code to your client app In local development, you can add the client ID and secret to your `config.toml` file. diff --git a/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx index 4d1c8364493..c01d491f023 100644 --- a/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx @@ -46,6 +46,24 @@ Ensure that the appropriate scopes have been added under OAuth 2.0 Scopes at the <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "LinkedIn (OIDC)" }} /> +You can also configure the LinkedIn (OIDC) auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure LinkedIn (OIDC) auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_linkedin_oidc_enabled": true, + "external_linkedin_oidc_client_id": "your-linkedin-client-id", + "external_linkedin_oidc_secret": "your-linkedin-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Slack (OIDC) auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Slack (OIDC) auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_slack_oidc_enabled": true, + "external_slack_oidc_client_id": "your-slack-client-id", + "external_slack_oidc_secret": "your-slack-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Spotify auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Spotify auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_spotify_enabled": true, + "external_spotify_client_id": "your-spotify-client-id", + "external_spotify_secret": "your-spotify-client-secret" + }' +``` + ## Add login code to your client app The following outlines the steps to sign in using Spotify with Supabase Auth. diff --git a/apps/docs/content/guides/auth/social-login/auth-twitter.mdx b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx index 8c181621f0b..f1ed101fbc1 100644 --- a/apps/docs/content/guides/auth/social-login/auth-twitter.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx @@ -50,6 +50,24 @@ Setting up Twitter logins for your application consists of 3 parts: <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "Twitter" }} /> +You can also configure the Twitter auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Twitter auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_twitter_enabled": true, + "external_twitter_client_id": "your-twitter-api-key", + "external_twitter_secret": "your-twitter-api-secret-key" + }' +``` + ## Add login code to your client app +You can also configure the Zoom auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Zoom auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_zoom_enabled": true, + "external_zoom_client_id": "your-zoom-client-id", + "external_zoom_secret": "your-zoom-client-secret" + }' +``` + ## Add login code to your client app `. Substitute your [custom auth domain](https://workos.com/docs/custom-domains/auth-api) for "api.workos.com" if configured. +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth). +3. Set up a JWT template to assign the `role: 'authenticated'` claim to your access token. + +## Setup the Supabase client library + + + + + +```typescript +import { createClient } from '@supabase/supabase-js' +import { createClient as createAuthKitClient } from '@workos-inc/authkit-js' + +const authkit = await createAuthKitClient('WORKOS_CLIENT_ID', { + apiHostname: '', +}) + +const supabase = createClient('https://.supabase.co', 'SUPABASE_ANON_KEY', { + accessToken: async () => { + return authkit.getAccessToken() + }, +}) +``` + + + + + +## Add a new Third-Party Auth integration to your project + +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. + +## Set up a JWT template to add the authenticated role. + +Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization. + +WorkOS JWTs already contain a `role` claim that corresponds to the user's role in their organization. It is necessary to adjust the `role` claim to be `"authenticated"` like Supabase expects. This can be done using JWT templates (navigate to Authentication -> Sessions -> JWT Template in the WorkOS Dashboard). + +This template overrides the `role` claim to meet Supabase's expectations, and adds the WorkOS role in a new `user_role` claim: + +```json +{ + "role": "authenticated", + "user_role": {{organization_membership.role}} +} +``` diff --git a/apps/docs/content/guides/database/extensions/timescaledb.mdx b/apps/docs/content/guides/database/extensions/timescaledb.mdx index 5d4120418ae..8f06cc54bdf 100644 --- a/apps/docs/content/guides/database/extensions/timescaledb.mdx +++ b/apps/docs/content/guides/database/extensions/timescaledb.mdx @@ -6,7 +6,7 @@ description: 'Scalable time-series data storage and analysis' -The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/guides/platform/upgrading#upgrading-to-postgres-17) for more information. +The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/docs/guides/platform/upgrading#upgrading-to-postgres-17) for more information. diff --git a/apps/docs/content/guides/database/postgres/setup-replication-external.mdx b/apps/docs/content/guides/database/postgres/setup-replication-external.mdx index e802f490e79..b1a10c3a719 100644 --- a/apps/docs/content/guides/database/postgres/setup-replication-external.mdx +++ b/apps/docs/content/guides/database/postgres/setup-replication-external.mdx @@ -29,7 +29,7 @@ select pg_create_logical_replication_slot('example_slot', 'pgoutput'); This will need a **direct** connection (not a Connection Pooler) to your database and you can find the connection info in the [Dashboard](https://supabase.com/dashboard/project/_/settings/database). -You will also need to ensure that IPv6 is supported by your replication destination (or you can enable the [IPv4 add-on](/guides/platform/ipv4-address)) +You will also need to ensure that IPv6 is supported by your replication destination (or you can enable the [IPv4 add-on](/docs/guides/platform/ipv4-address)) If you would prefer not to use the `postgres` user, then you can run `CREATE ROLE WITH REPLICATION;` using the `postgres` user. diff --git a/apps/docs/content/guides/database/replication.mdx b/apps/docs/content/guides/database/replication.mdx index ee5445662ff..57bd900485d 100644 --- a/apps/docs/content/guides/database/replication.mdx +++ b/apps/docs/content/guides/database/replication.mdx @@ -59,7 +59,7 @@ Logical replication is typically output in 2 forms, `pgoutput` and `wal2json`. T When using logical replication, Postgres is then configured to keep WAL files around for longer than it needs them. If the files are removed too quickly, then your `replication slot` will become inactive and, if the database receives a large number of changes in a short time, then the `replication slot` can become lost as it was not able to keep up. -In order to mitigate this, Postgres has many options and settings that can be [tweaked](/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs. +In order to mitigate this, Postgres has many options and settings that can be [tweaked](/docs/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs. | Setting | Description | User-facing | Default | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------- | ------- | diff --git a/apps/docs/content/guides/database/replication/faq.mdx b/apps/docs/content/guides/database/replication/faq.mdx index f0bdd97f465..804976d0923 100644 --- a/apps/docs/content/guides/database/replication/faq.mdx +++ b/apps/docs/content/guides/database/replication/faq.mdx @@ -13,7 +13,7 @@ Connections through a pooler, such as Supavisor, will not work. # The tool in use does not support IPv6 -You can enable the [IPv4 add-on](/guides/platform/ipv4-address) for your project. +You can enable the [IPv4 add-on](/docs/guides/platform/ipv4-address) for your project. # What is XMIN and should it be used? @@ -29,7 +29,7 @@ You can view [publications](https://supabase.com/dashboard/project/default/datab # How to configure database settings for replication? -Yes. Using the Supabase CLI, you can [configure database settings](/guides/database/custom-postgres-config#cli-configurable-settings) to optimize them for your replication needs. These values can vary depending on the activity of your database size and activity. +Yes. Using the Supabase CLI, you can [configure database settings](/docs/guides/database/custom-postgres-config#cli-configurable-settings) to optimize them for your replication needs. These values can vary depending on the activity of your database size and activity. # What are some important configuration options? diff --git a/apps/docs/content/guides/database/replication/monitoring-replication.mdx b/apps/docs/content/guides/database/replication/monitoring-replication.mdx index a49b8a8f27a..300ca966898 100644 --- a/apps/docs/content/guides/database/replication/monitoring-replication.mdx +++ b/apps/docs/content/guides/database/replication/monitoring-replication.mdx @@ -4,12 +4,12 @@ title: 'Monitoring replication' Monitoring replication lag is important and there are 3 ways to do this: -1. Dashboard - Under the [Reports](/guides/platform/reports) of the dashboard, you can view the replication lag of your project +1. Dashboard - Under the [Reports](/docs/guides/platform/reports) of the dashboard, you can view the replication lag of your project 2. Database - - pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active - pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why) - pg_replication_slots - use this to check if the slot is active and you can also calculate the lag from here -3. [Metrics](/guides/telemetry/metrics) - Using the prometheus endpoint for your project +3. [Metrics](/docs/guides/telemetry/metrics) - Using the prometheus endpoint for your project - replication_slots_max_lag_bytes - this is the more important one - pg_stat_replication_replay_lag - lag to replay WAL files from the source DB on the target DB (throttled by disk or high activity) - pg_stat_replication_send_lag - lag in sending WAL files from the source DB (a high lag means that the publisher is not being asked to send new WAL files OR a network issues) diff --git a/apps/docs/content/guides/database/replication/setting-up-replication.mdx b/apps/docs/content/guides/database/replication/setting-up-replication.mdx index 36d4d528705..665356ed99f 100644 --- a/apps/docs/content/guides/database/replication/setting-up-replication.mdx +++ b/apps/docs/content/guides/database/replication/setting-up-replication.mdx @@ -68,7 +68,7 @@ Materialize has the following [documentation](https://materialize.com/docs/sql/c You can follow those steps with the following modifications: -1. Follow the steps in our [guide](/guides/database/postgres/setup-replication-external) to create a publication slot +1. Follow the steps in our [guide](/docs/guides/database/postgres/setup-replication-external) to create a publication slot diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx index 2f107c17a01..7fe2fc29772 100644 --- a/apps/docs/content/guides/deployment/branching.mdx +++ b/apps/docs/content/guides/deployment/branching.mdx @@ -716,7 +716,7 @@ You can make a similar setup with a distinct project for each environment. Or ju Branching is available on the Pro Plan and above. The price is: -- Each Preview branch costs per day +- Each Preview branch costs per hour - Each Preview branch is billed until it is removed ## Troubleshooting diff --git a/apps/docs/content/guides/functions/secrets.mdx b/apps/docs/content/guides/functions/secrets.mdx index 5439ff3c8c3..7dc1e1362c2 100644 --- a/apps/docs/content/guides/functions/secrets.mdx +++ b/apps/docs/content/guides/functions/secrets.mdx @@ -75,6 +75,35 @@ You will also need to set secrets for your production Edge Functions. You can do }} /> +### Using the Management API + +You can also manage secrets programmatically using the Management API: + +```bash +# First, get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Create a secret +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '[{ + "name": "MY_SECRET_NAME", + "value": "my-secret-value" + }]' + +# List all secrets +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" + +# Delete a secret +curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '["MY_SECRET_NAME"]' +``` + ### Using the CLI Let's create a `.env` to help us deploy our secrets to production. In this case we'll just use the same as our local secrets: diff --git a/apps/docs/content/guides/getting-started/architecture.mdx b/apps/docs/content/guides/getting-started/architecture.mdx index 970431df96a..559bb711276 100644 --- a/apps/docs/content/guides/getting-started/architecture.mdx +++ b/apps/docs/content/guides/getting-started/architecture.mdx @@ -29,7 +29,7 @@ Each Supabase project consists of several tools: ### Postgres (database) -Postgres is the core of Supabase. We do not abstract the Postgres database—you can access it and use it with full privileges. We provide tools which makes Postgres as easy to use as Firebase. +Postgres is the core of Supabase. We do not abstract the Postgres database—you can access it and use it with full privileges. We provide tools which make Postgres as easy to use as Firebase. - Official Docs: [postgresql.org/docs](https://www.postgresql.org/docs/current/index.html) - Source code: [github.com/postgres/postgres](https://github.com/postgres/postgres) (mirror) diff --git a/apps/docs/content/guides/getting-started/features.mdx b/apps/docs/content/guides/getting-started/features.mdx index 47d4cabb9b7..b1993b861a7 100644 --- a/apps/docs/content/guides/getting-started/features.mdx +++ b/apps/docs/content/guides/getting-started/features.mdx @@ -64,7 +64,7 @@ Deploy read-only databases across multiple regions, for lower latency and better ### Log drains -Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains). +Export Supabase logs to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains). ## Studio @@ -195,53 +195,56 @@ Features in Beta are tested by an external penetration tester for security issue In addition to the Beta requirements, features in GA are covered by the [uptime SLA](https://supabase.com/sla). -| Product | Feature | Stage | Available on self-hosted | -| -------------- | -------------------------- | --------------- | ------------------------------------------- | -| Database | Postgres | `GA` | ✅ | -| Database | Vector Database | `GA` | ✅ | -| Database | Auto-generated Rest API | `GA` | ✅ | -| Database | Auto-generated GraphQL API | `GA` | ✅ | -| Database | Webhooks | `beta` | ✅ | -| Database | Vault | `public alpha` | ✅ | -| Platform | | `GA` | ✅ | -| Platform | Point-in-Time Recovery | `GA` | 🚧 [wal-g](https://github.com/wal-g/wal-g) | -| Platform | Custom Domains | `GA` | N/A | -| Platform | Network Restrictions | `beta` | N/A | -| Platform | SSL enforcement | `GA` | N/A | -| Platform | Branching | `beta` | N/A | -| Platform | Terraform Provider | `public alpha` | N/A | -| Platform | Read Replicas | `private alpha` | N/A | -| Platform | Log Drains | `public alpha` | ✅ | -| Studio | | `GA` | ✅ | -| Studio | SSO | `GA` | ✅ | -| Realtime | Postgres Changes | `GA` | ✅ | -| Realtime | Broadcast | `GA` | ✅ | -| Realtime | Presence | `GA` | ✅ | -| Realtime | Broadcast Authorization | `public beta` | ✅ | -| Realtime | Presence Authorization | `public beta` | ✅ | -| Storage | | `GA` | ✅ | -| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | -| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | -| Storage | Image Transformations | `GA` | ✅ | -| Storage | Resumable Uploads | `GA` | ✅ | -| Storage | S3 compatibility | `public alpha` | ✅ | -| Edge Functions | | `beta` | ✅ | -| Edge Functions | Regional Invocations | `beta` | ✅ | -| Edge Functions | NPM compatibility | `beta` | ✅ | -| Auth | | `GA` | ✅ | -| Auth | Email login | `GA` | ✅ | -| Auth | Social login | `GA` | ✅ | -| Auth | Phone login | `GA` | ✅ | -| Auth | Passwordless login | `GA` | ✅ | -| Auth | SSO with SAML | `GA` | ✅ | -| Auth | Authorization via RLS | `GA` | ✅ | -| Auth | CAPTCHA protection | `GA` | ✅ | -| Auth | Server-side Auth | `beta` | ✅ | -| CLI | | `GA` | ✅ Works with self-hosted | -| Management API | | `GA` | N/A | -| Client Library | JavaScript | `GA` | N/A | -| Client Library | Flutter | `beta` | N/A | -| Client Library | Swift | `beta` | N/A | +| Product | Feature | Stage | Available on self-hosted | +| -------------- | -------------------------- | -------------- | ------------------------------------------- | +| Database | Postgres | `GA` | ✅ | +| Database | Vector Database | `GA` | ✅ | +| Database | Auto-generated Rest API | `GA` | ✅ | +| Database | Auto-generated GraphQL API | `GA` | ✅ | +| Database | Webhooks | `beta` | ✅ | +| Database | Vault | `public alpha` | ✅ | +| Platform | | `GA` | ✅ | +| Platform | Point-in-Time Recovery | `GA` | 🚧 [wal-g](https://github.com/wal-g/wal-g) | +| Platform | Custom Domains | `GA` | N/A | +| Platform | Network Restrictions | `GA` | N/A | +| Platform | SSL enforcement | `GA` | N/A | +| Platform | Branching | `beta` | N/A | +| Platform | Terraform Provider | `public alpha` | N/A | +| Platform | Read Replicas | `GA` | N/A | +| Platform | Log Drains | `public alpha` | ✅ | +| Studio | | `GA` | ✅ | +| Studio | SSO | `GA` | ✅ | +| Realtime | Postgres Changes | `GA` | ✅ | +| Realtime | Broadcast | `GA` | ✅ | +| Realtime | Presence | `GA` | ✅ | +| Realtime | Broadcast Authorization | `public beta` | ✅ | +| Realtime | Presence Authorization | `public beta` | ✅ | +| Realtime | Broadcast from Database | `public beta` | ✅ | +| Storage | | `GA` | ✅ | +| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | +| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | +| Storage | Image Transformations | `GA` | ✅ | +| Storage | Resumable Uploads | `GA` | ✅ | +| Storage | S3 compatibility | `GA` | ✅ | +| Edge Functions | | `GA` | ✅ | +| Edge Functions | Regional Invocations | `GA` | ✅ | +| Edge Functions | NPM compatibility | `GA` | ✅ | +| Auth | | `GA` | ✅ | +| Auth | Email login | `GA` | ✅ | +| Auth | Social login | `GA` | ✅ | +| Auth | Phone login | `GA` | ✅ | +| Auth | Passwordless login | `GA` | ✅ | +| Auth | SSO with SAML | `GA` | ✅ | +| Auth | Authorization via RLS | `GA` | ✅ | +| Auth | CAPTCHA protection | `GA` | ✅ | +| Auth | Server-side Auth | `beta` | ✅ | +| Auth | Third-Party Auth | `GA` | ✅ | +| CLI | | `GA` | ✅ Works with self-hosted | +| Management API | | `GA` | N/A | +| Client Library | JavaScript | `GA` | N/A | +| Client Library | Flutter | `GA` | N/A | +| Client Library | Swift | `GA` | N/A | +| Client Library | Python | `beta` | N/A | - ✅ = Fully Available - 🚧 = Available, but requires external tools or configuration diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx index a0baa71716b..44d85683efb 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/getting-started/mcp.mdx @@ -14,6 +14,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) - [Cline](#cline) (VS Code extension) - [Claude desktop](#claude-desktop) - [Claude code](#claude-code) +- [Amp](#amp) Once connected, your AI assistants can interact with and query your Supabase projects on your behalf. @@ -117,11 +118,84 @@ You can also add the Supabase MCP server as a locally-scoped server, which will 1. Run the following command in your terminal: ```bash - claude mcp add supabase -s local -e SUPABASE_ACCESS_TOKEN=your_token_here npx -y @supabase/mcp-server-supabase@latest + claude mcp add supabase -s local -e SUPABASE_ACCESS_TOKEN=your_token_here -- npx -y @supabase/mcp-server-supabase@latest ``` Locally-scoped servers take precedence over project-scoped servers with the same name and are stored in your project-specific user settings. +### Amp + +You can add the Supabase MCP server to Amp in two ways: + +#### Option 1: VSCode settings.json + +1. Open "Preferences: Open User Settings (JSON)" +2. Add the following configuration: + + ```json + { + "amp.mcpServers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest", + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } + } + } + } + ``` + + Replace `project-ref` and `personal-access-token` with your project ref and personal access token. + +3. Save the configuration file. +4. Restart VS Code to apply the new configuration. + +#### Option 2: Amp CLI + +1. Edit `~/.config/amp/settings.json` +1. Add the following configuration: + + ```json + { + "amp.mcpServers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest", + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } + } + } + } + ``` + + Replace `project-ref` and `personal-access-token` with your project ref and personal access token. + +1. Save the configuration file. +1. Restart Amp to apply the new configuration. + +### Qodo Gen + +1. Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ. +1. Click `Connect more tools`. +1. Click `+ Add new MCP`. +1. Add the following configuration: + + <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Qodo Gen" }} /> + +1. Click `Save`. + ### Next steps Your AI tool is now connected to Supabase using MCP. Try asking your AI assistant to create a new project, create a table, or fetch project config. diff --git a/apps/docs/content/guides/local-development/customizing-email-templates.mdx b/apps/docs/content/guides/local-development/customizing-email-templates.mdx index dfbe7aa1307..0935886c4f5 100644 --- a/apps/docs/content/guides/local-development/customizing-email-templates.mdx +++ b/apps/docs/content/guides/local-development/customizing-email-templates.mdx @@ -32,13 +32,49 @@ content_path = "./supabase/templates/invite.html" ## Available email templates -There are several Auth email templates which can be configured: +There are several Auth email templates which can be configured. Each template serves a specific authentication flow: -- `auth.email.template.invite` -- `auth.email.template.confirmation` -- `auth.email.template.recovery` -- `auth.email.template.magic_link` -- `auth.email.template.email_change` +### `auth.email.template.invite` + +**Default subject**: "You have been invited" +**When sent**: When a user is invited to join your application via email invitation +**Purpose**: Allows administrators to invite users who don't have accounts yet +**Content**: Contains a link for the invited user to accept the invitation and create their account + +### `auth.email.template.confirmation` + +**Default subject**: "Confirm Your Signup" +**When sent**: When a user signs up and needs to verify their email address +**Purpose**: Email verification for new user registrations +**Content**: Contains a confirmation link to verify the user's email address + +### `auth.email.template.recovery` + +**Default subject**: "Reset Your Password" +**When sent**: When a user requests a password reset +**Purpose**: Password recovery flow for users who forgot their password +**Content**: Contains a link to reset the user's password + +### `auth.email.template.magic_link` + +**Default subject**: "Your Magic Link" +**When sent**: When a user requests a magic link for passwordless authentication +**Purpose**: Passwordless login using email links +**Content**: Contains a secure link that automatically logs the user in when clicked + +### `auth.email.template.email_change` + +**Default subject**: "Confirm Email Change" +**When sent**: When a user requests to change their email address +**Purpose**: Verification for email address changes +**Content**: Contains a confirmation link to verify the new email address + +### `auth.email.template.reauthentication` + +**Default subject**: "Confirm Reauthentication" +**When sent**: When a user needs to re-authenticate for sensitive operations +**Purpose**: Additional verification for sensitive actions (like changing password, deleting account) +**Content**: Contains a 6-digit OTP code for verification ## Template variables diff --git a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx index 54076678dce..bc71dc34edf 100644 --- a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx +++ b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx @@ -98,28 +98,6 @@ supabase migration up - - Before updating your schema files, stop the local development environment. - - - - -<$CodeTabs> - -```bash name=Terminal -supabase stop -``` - - - - - - - - - - - Edit `supabase/schemas/employees.sql` file to add a new column to `employees` table. @@ -151,7 +129,7 @@ Some entities like views and enums expect columns to be declared in a specific o - + Diff existing migrations against your declared schema. @@ -173,7 +151,7 @@ supabase db diff -f add_age - + Verify that the generated migration contain a single incremental change. @@ -194,7 +172,7 @@ alter table "public"."employees" add column "age" smallint not null; - + Start the database locally and apply the pending migration. @@ -204,7 +182,7 @@ alter table "public"."employees" add column "age" smallint not null; <$CodeTabs> ```bash name=Terminal -supabase start && supabase migration up +supabase migration up ``` diff --git a/apps/docs/content/guides/local-development/managing-config.mdx b/apps/docs/content/guides/local-development/managing-config.mdx index 7ac8d864c72..018aba75e3f 100644 --- a/apps/docs/content/guides/local-development/managing-config.mdx +++ b/apps/docs/content/guides/local-development/managing-config.mdx @@ -56,3 +56,13 @@ client_id = "env(GITHUB_CLIENT_ID)" secret = "env(GITHUB_SECRET)" redirect_uri = "" # Overrides the default auth redirectUrl. ``` + +### Going further + +For more advanced secrets management workflows, including: + +- **Using dotenvx for encrypted secrets**: Learn how to securely manage environment variables across different branches and environments +- **Branch-specific secrets**: Understand how to manage secrets for different deployment environments +- **Encrypted configuration values**: Use encrypted values directly in your `config.toml` + +See the [Managing secrets for branches](/docs/guides/deployment/branching#managing-secrets-for-branches) section in our branching documentation, or check out the [dotenvx example repository](https://github.com/supabase/supabase/blob/master/examples/slack-clone/nextjs-slack-clone-dotenvx/README.md) for a complete implementation. diff --git a/apps/docs/content/guides/platform/access-control.mdx b/apps/docs/content/guides/platform/access-control.mdx index 444a153b35b..37b7b85dd52 100644 --- a/apps/docs/content/guides/platform/access-control.mdx +++ b/apps/docs/content/guides/platform/access-control.mdx @@ -32,6 +32,20 @@ This is a security measure to prevent accidental invites to accounts not managed +### Viewing organization members using the Management API + +You can also view organization members using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export ORG_ID="your-organization-id" + +# List organization members +curl "https://api.supabase.com/v1/organizations/$ORG_ID/members" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +``` + ### Transferring ownership of an organization Each Supabase organization must have at least one owner. If your organization has other owners then you can relinquish ownership and leave the organization by clicking **Leave team** in your organization's team [settings](/dashboard/org/_/team). diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index aadb728ba21..64efb201ba5 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -71,6 +71,26 @@ The Postgres utility [pg_dumpall](https://www.postgresql.org/docs/current/app-pg You can access daily backups in the [Scheduled backups](https://supabase.com/dashboard/project/_/database/backups/scheduled) settings in the Dashboard. Pro Plan projects can access the last 7 days' worth of daily backups. Team Plan projects can access the last 14 days' worth of daily backups, while Enterprise Plan projects can access up to 30 days' worth of daily backups. Users can restore their project to any one of the backups. If you wish to generate a logical backup on your own, you can do so through the [Supabase CLI](/docs/reference/cli/supabase-db-dump). +You can also manage backups programmatically using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# List all available backups +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups" + +# Restore from a PITR (not logical) backup (replace ISO timestamp with desired restore point) +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups/restore-pitr" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "recovery_time_target_unix": "1735689600" + }' +``` + #### Backup process for large databases Databases larger than 15GB[^1], if they're on a recent build[^2] of the Supabase platform, get automatically transitioned[^3] to use daily physical backups. Physical backups are a more performant backup mechanism that lowers the overhead and impact on the database being backed up, and also avoids holding locks on objects in your database for a long period of time. While restores are unaffected, the backups created using this method cannot be downloaded from the Backups section of the dashboard. diff --git a/apps/docs/content/guides/platform/ipv4-address.mdx b/apps/docs/content/guides/platform/ipv4-address.mdx index b8510e7ba3d..790ea522196 100644 --- a/apps/docs/content/guides/platform/ipv4-address.mdx +++ b/apps/docs/content/guides/platform/ipv4-address.mdx @@ -28,6 +28,30 @@ IPv4 addresses are guaranteed to be static for ingress traffic. If your database You can enable the IPv4 add-on in your project's [add-ons settings](/dashboard/project/_/settings/addons). +You can also manage the IPv4 add-on using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current IPv4 add-on status +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Enable IPv4 add-on +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/addons" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "addon_type": "ipv4" + }' + +# Disable IPv4 add-on +curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons/ipv4" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +``` + Note that direct database connections can experience a short amount of downtime when toggling the add-on due to DNS reconfiguration and propagation. Generally, this should be less than a minute. diff --git a/apps/docs/content/guides/platform/network-restrictions.mdx b/apps/docs/content/guides/platform/network-restrictions.mdx index aa80fbbc638..95c84224460 100644 --- a/apps/docs/content/guides/platform/network-restrictions.mdx +++ b/apps/docs/content/guides/platform/network-restrictions.mdx @@ -18,6 +18,30 @@ If direct connections to your database [resolve to a IPv6 address](https://supab Network restrictions can be configured in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) page. Ensure that you have [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project that you are enabling network restrictions. +## To get started via the Management API: + +You can also manage network restrictions using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current network restrictions +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/network-restrictions" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Update network restrictions +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/network-restrictions/apply" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "db_allowed_cidrs": [ + "192.168.0.1/24", + ] + }' +``` + ## To get started via the CLI: 1. [Install](/docs/guides/cli) the Supabase CLI 1.22.0+. diff --git a/apps/docs/content/guides/platform/read-replicas.mdx b/apps/docs/content/guides/platform/read-replicas.mdx index 358b348332f..4d6b836fbf2 100644 --- a/apps/docs/content/guides/platform/read-replicas.mdx +++ b/apps/docs/content/guides/platform/read-replicas.mdx @@ -51,6 +51,30 @@ Projects must meet these requirements to use Read Replicas: To add a Read Replica, go to the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure) in your dashboard. +You can also manage Read Replicas using the Management API (beta functionality): + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Create a new Read Replica +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/setup" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "region": "us-east-1" + }' + +# Delete a Read Replica +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/remove" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "database_identifier": "abcdefghijklmnopqrst" + }' +``` + Projects on an XL compute add-on or larger can create up to five Read Replicas. Projects on compute add-ons smaller than XL can create up to two Read Replicas. All Read Replicas inherit the compute size of their Primary database. ### Deploying a Read Replica diff --git a/apps/docs/content/guides/platform/ssl-enforcement.mdx b/apps/docs/content/guides/platform/ssl-enforcement.mdx index d0b4c82efa6..5f9b5d62167 100644 --- a/apps/docs/content/guides/platform/ssl-enforcement.mdx +++ b/apps/docs/content/guides/platform/ssl-enforcement.mdx @@ -18,6 +18,40 @@ Projects need to be at least on Postgres 13.3.0 to enable SSL enforcement. You c SSL enforcement can be configured via the "Enforce SSL on incoming connections" setting under the SSL Configuration section in [Database Settings page](https://supabase.com/dashboard/project/_/settings/database) of the dashboard. +## Manage SSL enforcement via the Management API + +You can also manage SSL enforcement using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current SSL enforcement status +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforceemnt" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Enable SSL enforcement +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforcement" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "requestedConfig": { + "database": true + } + }' + +# Disable SSL enforcement +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforcement" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "requestedConfig": { + "database": false + } + }' +``` + ## Manage SSL enforcement via the CLI To get started: diff --git a/apps/docs/content/guides/telemetry/metrics.mdx b/apps/docs/content/guides/telemetry/metrics.mdx index 0309a85ab88..267e4692747 100644 --- a/apps/docs/content/guides/telemetry/metrics.mdx +++ b/apps/docs/content/guides/telemetry/metrics.mdx @@ -22,10 +22,30 @@ The endpoint discussed in this article is not available on self-hosted. ## Accessing the metrics endpoint -Your project's metrics endpoint is accessible at `https://.supabase.co/customer/v1/privileged/metrics`. Access to the endpoint is secured via HTTP Basic Auth; the username is `service_role`, while the password is the service role JWT available through the Supabase dashboard. +Your project's metrics endpoint is accessible at `https://.supabase.co/customer/v1/privileged/metrics`. + +Access to the endpoint is secured via HTTP Basic Auth: + +- username: `service_role` +- password: the service role JWT from the [Supabase dashboard](https://supabase.com/dashboard/project/_/settings/api-keys) + +You can also retrieve your service role key programmatically using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get project API keys including service_role key +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/api-keys?reveal=true" +``` + + ```shell -> curl https://.supabase.co/customer/v1/privileged/metrics --user 'service_role:' +curl /customer/v1/privileged/metrics \ + --user 'service_role:' ``` ## Supabase Grafana diff --git a/apps/docs/spec/cli_v1_config.yaml b/apps/docs/spec/cli_v1_config.yaml index 0d858465f2d..803059e6e56 100644 --- a/apps/docs/spec/cli_v1_config.yaml +++ b/apps/docs/spec/cli_v1_config.yaml @@ -895,14 +895,14 @@ parameters: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' - - id: 'auth.email.otp_exp' - title: 'auth.email.otp_exp' + - id: 'auth.email.otp_expiry' + title: 'auth.email.otp_expiry' tags: ['auth'] required: false - default: '300' + default: '3600' description: | The expiry time for an OTP code in seconds. - Default is 300 seconds (5 minutes). + Default is 3600 seconds (1 hour). links: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 1a415da3fef..b69b6cb68af 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -4406,7 +4406,7 @@ functions: name: Call a read-only Postgres function code: | ```ts - const { data, error } = await supabase.rpc('hello_world', { get: true }) + const { data, error } = await supabase.rpc('hello_world', undefined, { get: true }) ``` data: sql: | diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json index 41bf9f6455c..218e751f61b 100644 --- a/apps/docs/turbo.json +++ b/apps/docs/turbo.json @@ -33,6 +33,7 @@ "NEXT_PUBLIC_AUTH_PERSISTED_KEY", "NEXT_PUBLIC_AUTH_NAVIGATOR_LOCK_KEY", "NEXT_PUBLIC_AUTH_DETECT_SESSION_IN_URL", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", "NEXT_PUBLIC_GOTRUE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", // These envs are technically passthrough env vars because they're only used on the server side of Nextjs diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index f688c4340fa..dc671968509 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -37,3 +37,5 @@ yarn-error.log* /public/dashboard # Sentry Auth Token .sentryclirc + +certificates \ No newline at end of file diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index a7f16a5c889..6024d017720 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -79,7 +79,7 @@ export function ApiKeyPill({ async function onCopy() { // If key is already revealed, use that value - if (data?.api_key) return data?.api_key + if (data?.api_key) return data?.api_key ?? '' try { // Fetch full key and immediately clear from cache after copying @@ -89,13 +89,15 @@ export function ApiKeyPill({ exact: true, }) - if (result.isSuccess) return result.data.api_key + if (result.isSuccess) return result.data.api_key ?? '' if (error) { toast.error('Failed to copy secret API key') + return '' } } catch (error) { console.error('Failed to fetch API key:', error) + return '' } // Fallback to the masked version if fetch fails diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index 8d3e2e43aa6..c60ecf73031 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Github, Mail } from 'lucide-react' +import { ExternalLink, Github } from 'lucide-react' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' @@ -119,7 +119,13 @@ export const ApiKeysComingSoonBanner = () => {

diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx index 09a14f8a2a4..a52b21a92f6 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx @@ -4,7 +4,6 @@ import { useQueryTableCommands, useSnippetCommands, } from 'components/layouts/SQLEditorLayout/SqlEditor.Commands' -import { useGenerateSqlCommand } from 'components/interfaces/SqlGenerator/SqlGenerator.Commands' import { useProjectLevelTableEditorCommands } from 'components/layouts/TableEditorLayout/TableEditor.Commands' import { useLayoutNavCommands } from 'components/layouts/useLayoutNavCommands' import { CommandHeader, CommandInput, CommandList, CommandMenu } from 'ui-patterns/CommandMenu' @@ -19,7 +18,6 @@ import { useSupportCommands } from './Support' import { orderCommandSectionsByPriority } from './ordering' export default function StudioCommandMenu() { - useGenerateSqlCommand() useApiKeysCommands() useApiUrlCommand() useProjectLevelTableEditorCommands() diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index ceadbd396bf..c2b2c54f742 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx @@ -8,20 +8,6 @@ export const FEATURE_PREVIEWS = [ isNew: true, isPlatformOnly: false, }, - { - key: LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - name: 'Table Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: false, - }, - { - key: LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - name: 'SQL Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: true, - }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 4fa0a7dcf67..bef4199d7cc 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -2,8 +2,6 @@ import { noop } from 'lodash' import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { FeatureFlagContext, LOCAL_STORAGE_KEYS } from 'common' -import { useFlag } from 'hooks/ui/useFlag' -import { IS_PLATFORM } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' @@ -21,15 +19,10 @@ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { const { hasLoaded } = useContext(FeatureFlagContext) - const enableTabsInterface = useFlag('tabsInterface') // [Joshen] Similar logic to feature flagging previews, we can use flags to default opt in previews const isDefaultOptIn = (feature: (typeof FEATURE_PREVIEWS)[number]) => { switch (feature.key) { - case LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS: - return enableTabsInterface - case LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS: - return enableTabsInterface default: return false } @@ -86,14 +79,3 @@ export const useIsInlineEditorEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] } - -export const useIsTableEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - return flags[LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS] -} - -export const useIsSQLEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - if (!IS_PLATFORM) return false - return flags[LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS] -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 94bc324361f..725499cb60d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -7,22 +7,17 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { removeTabsByEditor } from 'state/tabs' import { Badge, Button, Modal, ScrollArea, cn } from 'ui' import { APISidePanelPreview } from './APISidePanelPreview' import { CLSPreview } from './CLSPreview' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' import { useFeaturePreviewContext } from './FeaturePreviewContext' import { InlineEditorPreview } from './InlineEditorPreview' -import { SqlEditorTabsPreview } from './SqlEditorTabs' -import { TableEditorTabsPreview } from './TableEditorTabs' const FEATURE_PREVIEW_KEY_TO_CONTENT: { [key: string]: ReactNode } = { [LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]: , - [LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS]: , - [LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: , } @@ -62,13 +57,6 @@ const FeaturePreviewModal = () => { properties: { feature: selectedFeatureKey }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - - if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { - removeTabsByEditor(ref, 'table') - } - if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { - removeTabsByEditor(ref, 'sql') - } } function handleCloseFeaturePreviewModal() { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx index 08a82c3f74c..228da9d8b4d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx @@ -1,7 +1,6 @@ import Image from 'next/image' import { BASE_PATH } from 'lib/constants' -import { Admonition } from 'ui-patterns' export const InlineEditorPreview = () => { return ( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx deleted file mode 100644 index ee053af8657..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { BASE_PATH } from 'lib/constants' -import Image from 'next/image' - -export const SqlEditorTabsPreview = () => { - return ( -
- api-docs-side-panel-preview -

- The SQL Editor now features tabs for improved navigation and organization. Conveniently go - between the queries that you're focused on, and collapse the sidebar for a bigger real - estate while writing your queries. -

-
-

Enabling this preview will:

-
    -
  • Enable opening of queries in the SQL Editor as tabs
  • -
  • Support closing of the navigation sidebar for a larger code editor space
  • -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx deleted file mode 100644 index 1a83d1e70dd..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { ExternalLink, X } from 'lucide-react' -import Image from 'next/image' - -import { LOCAL_STORAGE_KEYS } from 'common' -import { useEditorType } from 'components/layouts/editors/EditorsLayout.hooks' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InlineLinkClassName } from 'components/ui/InlineLink' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { BASE_PATH } from 'lib/constants' -import { useAppStateSnapshot } from 'state/app-state' -import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Badge, - Button, - cn, - HoverCard, - HoverCardContent_Shadcn_, - HoverCardTrigger_Shadcn_, -} from 'ui' -import { useIsSQLEditorTabsEnabled, useIsTableEditorTabsEnabled } from './FeaturePreviewContext' - -export const TableEditorTabsPreview = () => { - return ( -
- api-docs-side-panel-preview -

- The Table Editor now features tabs for improved navigation and organization. Have multiple - tables opened across schemas and conveniently go across them without having to switch - schemas. Collapse the sidebar for a bigger real estate while browsing your data. -

-
-

Enabling this preview will:

-
    -
  • Enable opening of tables in the Table Editor as tabs
  • -
  • Support closing of the navigation sidebar for a larger data grid space
  • -
-
-
- ) -} - -export const TabsUpdateCallout = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - - - NEW - - Tabs Interface for Editors - - - The Table and SQL Editors now feature tabs for improved navigation and organization! Check - out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. - - - - - } - className="absolute top-2 right-2 px-1" - onClick={() => setTabsInterfaceAcknowledge(true)} - tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} - /> - - ) -} - -export const TabsUpdateTooltip = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - -
- - NEW - -
-
- -
-

Tabs Interface for Editors

-

- The Table and SQL Editors now feature tabs for improved navigation and organization! - Check out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. -

-
- -
-
- ) -} diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index f87aa4aa0c6..22b473f6d9f 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' import { toast } from 'sonner' -import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' @@ -18,6 +18,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() + const isUserMFAEnabled = useIsMFAEnabled() const organization = useSelectedOrganization() @@ -122,7 +123,17 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { }, [isSuccessStorage, ref]) useEffect(() => { - if (organization) setLastVisitedOrganization(organization.slug) + if (organization) { + setLastVisitedOrganization(organization.slug) + + if ( + organization.organization_requires_mfa && + !isUserMFAEnabled && + router.pathname !== '/org/[slug]' + ) { + router.push(`/org/${organization.slug}`) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization]) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 495ab339596..63ee824ffb8 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -119,7 +119,7 @@ export const UsersV2 = () => { } ) - const { data: countData } = useUsersCountQuery({ + const { data: countData, refetch: refetchCount } = useUsersCountQuery({ projectRef, connectionString: project?.connectionString, keywords: filterKeywords, @@ -437,7 +437,10 @@ export const UsersV2 = () => { icon={} type="default" loading={isRefetching && !isFetchingNextPage} - onClick={() => refetch()} + onClick={() => { + refetch() + refetchCount() + }} > Refresh diff --git a/apps/studio/components/interfaces/Billing/Billing.constants.ts b/apps/studio/components/interfaces/Billing/Billing.constants.ts index c4a48de2d74..3ccc2dd1e59 100644 --- a/apps/studio/components/interfaces/Billing/Billing.constants.ts +++ b/apps/studio/components/interfaces/Billing/Billing.constants.ts @@ -1,18 +1,44 @@ export const USAGE_APPROACHING_THRESHOLD = 0.8 export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', + { + value: 'I was just exploring, or it was a hobby/student project.', + }, + { + value: 'I was not satisfied with the customer support I received.', + label: 'Could you tell us more about your experience with our support team?', + }, + { + value: 'Supabase is missing a specific feature I need.', + label: 'What specific feature(s) are we missing?', + }, + { + value: 'I found it difficult to use or build with.', + label: 'What specific parts of Supabase did you find difficult or frustrating?', + }, + { + value: 'Performance or reliability insufficient.', + label: + 'Could you tell us more about the specific issues you encountered (e.g., UI bugs, API latency, downtime)?', + }, + { + value: 'My project was cancelled or put on hold.', + }, + { + value: 'Too expensive', + label: 'We appreciate your perspective on our pricing, what aspects of the cost felt too high?', + }, + { + value: 'The pricing is unpredictable and hard to budget for.', + label: + 'Which aspects of our pricing model made it difficult for you to predict your monthly costs?', + }, + { + value: 'My company went out of business or was acquired.', + }, + { + value: 'I lost trust in the company or its future direction.', + label: + 'Building and maintaining your trust is our highest priority, could you please share the specific event or reason that led to this loss of trust?', + }, ] diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index a197cfee60c..cb470dcb3e5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -118,7 +118,7 @@ const AddPaymentMethodForm = ({ {showSetDefaultCheckbox && ( diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx index 5b568e36437..dc32d28afbd 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx @@ -7,11 +7,9 @@ export const PaymentConfirmation = ({ paymentIntentSecret, onPaymentIntentConfirm, onLoadingChange, - paymentMethodId, onError, }: { paymentIntentSecret: string - paymentMethodId: string onPaymentIntentConfirm: (response: PaymentIntentResult) => void onLoadingChange: (loading: boolean) => void onError?: (error: Error) => void @@ -22,7 +20,7 @@ export const PaymentConfirmation = ({ if (stripe && paymentIntentSecret) { onLoadingChange(true) stripe! - .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId }) + .confirmCardPayment(paymentIntentSecret) .then((res) => { onPaymentIntentConfirm(res) onLoadingChange(false) diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index 24c2a88ed11..bc1bfbeb6ac 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -2,11 +2,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { ExternalLink, Plug } from 'lucide-react' import { parseAsBoolean, useQueryState } from 'nuqs' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import Panel from 'components/ui/Panel' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProject } from 'hooks/misc/useSelectedProject' @@ -137,13 +138,21 @@ export const Connect = () => { return [] } - const protocol = settings?.app_config?.protocol ?? 'https' - const endpoint = settings?.app_config?.endpoint ?? '' - const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' - const apiUrl = canReadAPIKeys ? apiHost : null - const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } - const projectKeys = { apiUrl, anonKey: anonKey?.api_key ?? null } + const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: false }) + + const projectKeys = useMemo(() => { + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.endpoint ?? '' + const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' + + const apiUrl = canReadAPIKeys ? apiHost : null + return { + apiUrl: apiHost ?? null, + anonKey: anonKey?.api_key ?? null, + publishableKey: apiKeys?.find(({ type }) => type === 'publishable')?.api_key ?? null, + } + }, [apiKeys, anonKey, canReadAPIKeys, settings]) const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Connect/Connect.types.ts b/apps/studio/components/interfaces/Connect/Connect.types.ts index 5b6c5f6e6d5..78e379f6a8c 100644 --- a/apps/studio/components/interfaces/Connect/Connect.types.ts +++ b/apps/studio/components/interfaces/Connect/Connect.types.ts @@ -1,12 +1,14 @@ export type projectKeys = { apiUrl: string | null anonKey: string | null + publishableKey: string | null } export interface ContentFileProps { projectKeys: { apiUrl: string - anonKey: string + anonKey?: string + publishableKey?: string } connectionStringPooler: { transactionShared: string diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index 698e5e7b328..74241727200 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -239,7 +239,7 @@ export const DatabaseConnectionString = () => { - + {isLoading && ( diff --git a/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx index e9bf493098a..11d5ab7443a 100644 --- a/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx @@ -30,7 +30,7 @@ data class TodoItem(val id: Int, val name: String) {` val supabase = createSupabaseClient( supabaseUrl = "${projectKeys.apiUrl ?? 'your-project-url'}", - supabaseKey = "${projectKeys.anonKey ?? 'your-anon-key'}" + supabaseKey = "${projectKeys.publishableKey ?? ''}" ) { install(Postgrest) } diff --git a/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx index e143f57c1cd..fe4ac30f1e3 100644 --- a/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx index a9b22da5633..fd1815f163b 100644 --- a/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` EXPO_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -EXPO_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +EXPO_PUBLIC_SUPABASE_KEY=${projectKeys.publishableKey ?? ''} `} @@ -31,17 +31,18 @@ EXPO_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} {` import 'react-native-url-polyfill/auto' import AsyncStorage from '@react-native-async-storage/async-storage' -import { createClient } from '@supabase/supabase-js' +import { createClient, processLock } from '@supabase/supabase-js' export const supabase = createClient( - process.env.EXPO_PUBLIC_SUPABASE_URL || "", - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || "", + process.env.EXPO_PUBLIC_SUPABASE_URL!, + process.env.EXPO_PUBLIC_SUPABASE_KEY!, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, + lock: processLock, }, }) `} diff --git a/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx index d0cf74eb53a..d0cb4c475df 100644 --- a/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx @@ -24,7 +24,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; Future main() async { await Supabase.initialize( url: '${projectKeys.apiUrl ?? 'your-project-url'}', - anonKey: '${projectKeys.anonKey ?? 'your-anon-key'}', + anonKey: '${projectKeys.publishableKey ?? ''}', ); runApp(MyApp()); } diff --git a/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx index 98a9f34dfb1..ecb2e2e90be 100644 --- a/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx @@ -24,7 +24,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` export const environment = { supabaseUrl: '${projectKeys.apiUrl ?? 'your-project-url'}', - supabaseKey: '${projectKeys.anonKey ?? 'your-anon-key'}', + supabaseKey: '${projectKeys.publishableKey ?? ''}', }; `} diff --git a/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx index 4cee515aadf..ac86c1a17cc 100644 --- a/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +REACT_APP_SUPABASE_KEY=${projectKeys.publishableKey ?? ''} `} @@ -32,7 +32,7 @@ REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from '@supabase/supabase-js' const supabaseUrl = process.env.REACT_APP_SUPABASE_URL -const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY +const supabaseKey = process.env.REACT_APP_SUPABASE_KEY export const supabase = createClient(supabaseUrl, supabaseAnonKey) `} diff --git a/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx index 0a99eaa0ac6..8108b197f09 100644 --- a/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx @@ -21,10 +21,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -58,10 +62,13 @@ export default async function Page() { import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = (cookieStore: ReturnType) => { return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { getAll() { @@ -88,10 +95,13 @@ export const createClient = (cookieStore: ReturnType) => { {` import { createBrowserClient } from "@supabase/ssr"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = () => createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, ); `} @@ -103,6 +113,9 @@ export const createClient = () => import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = (request: NextRequest) => { // Create an unmodified response let supabaseResponse = NextResponse.next({ @@ -112,8 +125,8 @@ export const createClient = (request: NextRequest) => { }); const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { getAll() { @@ -134,7 +147,6 @@ export const createClient = (request: NextRequest) => { return supabaseResponse }; - `} diff --git a/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx index a74132d4983..92a3ea50e64 100644 --- a/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx index ed49f7d0e55..be56beed13b 100644 --- a/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx index e960582f408..3a70ecfd3d9 100644 --- a/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,7 @@ REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.REACT_APP_SUPABASE_URL; -const supabaseKey = process.env.REACT_APP_SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys.publishableKey ? 'REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'REACT_APP_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx index 1c334849084..a28865fb223 100644 --- a/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,8 @@ VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabaseKey = import.meta.env.${projectKeys.publishableKey ? 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'VITE_SUPABASE_ANON_KEY'}; + const supabase = createClient(supabaseUrl, supabaseKey); export default supabase diff --git a/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx index 5be7b60910c..fe3f9cd88d3 100644 --- a/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx @@ -19,10 +19,12 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + `SUPABASE_KEY=${projectKeys?.publishableKey ?? projectKeys?.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +34,7 @@ SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@refinedev/supabase"; const SUPABASE_URL = process.env.SUPABASE_URL; -const SUPABASE_KEY = process.env.SUPABASE_KEY +const SUPABASE_KEY = process.env.SUPABASE_KEY; export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { db: { diff --git a/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx index 5e33bcbc7a5..cff2d98ecc4 100644 --- a/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -35,9 +39,12 @@ export function createClient(request: Request) { const cookies = parse(request.headers.get("Cookie") ?? ""); const headers = new Headers(); + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'}; + return createServerClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { get(key) { diff --git a/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx index 3fb9adc2cc6..08bfbc7b8a1 100644 --- a/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,9 +36,9 @@ SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.SUPABASE_URL; -const supabaseKey = process.env.SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'}; -export const supabase = createClient(supabaseUrl, supabaseKey); +export const supabase = createClient(supabaseUrl!, supabaseKey!); `} diff --git a/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx index f369de33918..d7dd5da02de 100644 --- a/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx @@ -20,10 +20,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -31,10 +35,10 @@ PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} {` import { createClient } from "@supabase/supabase-js"; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public" +import { PUBLIC_SUPABASE_URL, ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'} } from "$env/static/public" const supabaseUrl = PUBLIC_SUPABASE_URL; -const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; +const supabaseKey = ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx index 87ad9d3a345..a0f637e2e4a 100644 --- a/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx @@ -25,7 +25,7 @@ import Supabase let supabase = SupabaseClient( supabaseURL: URL(string: "${projectKeys.apiUrl ?? 'your-project-url'}")!, - supabaseKey: "${projectKeys.anonKey ?? 'your-anon-key'}" + supabaseKey: "${projectKeys.publishableKey ?? ''}" ) `} diff --git a/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx index ec4e1e0491c..3d5b6d91cce 100644 --- a/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index d32e9aaae95..fd966494bdf 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -134,6 +134,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { useEffect(() => { if (visible) { + setFocusedEditor(false) form.reset({ name: func?.name ?? '', schema: func?.schema ?? 'public', diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 3eee09b8556..263d20bfd4d 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -77,6 +77,7 @@ const Indexes = () => { projectRef: project.ref, connectionString: project.connectionString, name: index.name, + schema: selectedSchema, }) } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index e7c02e26881..71aab9991c1 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -53,9 +53,14 @@ import { IOPSField } from './fields/IOPSField' import { StorageTypeField } from './fields/StorageTypeField' import { ThroughputField } from './fields/ThroughputField' import { DiskCountdownRadial } from './ui/DiskCountdownRadial' -import { DiskType, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3 } from './ui/DiskManagement.constants' +import { + DISK_LIMITS, + DiskType, + RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, +} from './ui/DiskManagement.constants' import { NoticeBar } from './ui/NoticeBar' import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' +import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' export function DiskManagementForm() { // isLoading is used to avoid a useCheckPermissions() race condition @@ -69,7 +74,8 @@ export function DiskManagementForm() { (warning) => warning.project === project?.ref ) const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled - const isFlyArchitecture = project?.cloud_provider === 'FLY' + const isAws = useIsAwsCloudProvider() + const isAwsK8s = useIsAwsK8sCloudProvider() /** * Permissions @@ -118,31 +124,32 @@ export function DiskManagementForm() { setRefetchInterval(2000) } }, - enabled: project != null && !isFlyArchitecture, + enabled: project != null && isAws, } ) const { isSuccess: isAddonsSuccess } = useProjectAddonsQuery({ projectRef }) const { isWithinCooldownWindow, isSuccess: isCooldownSuccess } = useRemainingDurationForDiskAttributeUpdate({ projectRef, - enabled: project != null && !isFlyArchitecture, + enabled: project != null && isAws, }) const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery( { projectRef, }, - { enabled: project != null && !isFlyArchitecture } + { enabled: project != null && isAws } ) const { data: diskAutoscaleConfig, isSuccess: isDiskAutoscaleConfigSuccess } = - useDiskAutoscaleCustomConfigQuery( - { projectRef }, - { enabled: project != null && !isFlyArchitecture } - ) + useDiskAutoscaleCustomConfigQuery({ projectRef }, { enabled: project != null && isAws }) /** * Handle default values */ + const computeSize = project?.infra_compute_size + ? mapComputeSizeNameToAddonVariantId(project?.infra_compute_size) + : undefined + // @ts-ignore const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0, iops: 0 } const { growth_percent, max_size_gb, min_increment_gb } = diskAutoscaleConfig ?? {} @@ -151,9 +158,7 @@ export function DiskManagementForm() { provisionedIOPS: iops, throughput: throughput_mbps, totalSize: size_gb, - computeSize: project?.infra_compute_size - ? mapComputeSizeNameToAddonVariantId(project?.infra_compute_size) - : undefined, + computeSize, growthPercent: growth_percent, minIncrementGb: min_increment_gb, maxSizeGb: max_size_gb, @@ -168,6 +173,20 @@ export function DiskManagementForm() { reValidateMode: 'onChange', }) + const { computeSize: modifiedComputeSize } = form.watch() + + // We only support disk configurations for >=Large instances + // If a customer downgrades back to { + if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) { + if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) { + form.setValue('storageType', DiskType.GP3) + form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput) + form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) + } + } + }, [modifiedComputeSize, isDialogOpen, project]) + /** * State handling */ @@ -200,7 +219,7 @@ export function DiskManagementForm() { isPlanUpgradeRequired || isWithinCooldownWindow || !canUpdateDiskConfiguration || - isFlyArchitecture + !isAws const disableComputeInputs = isPlanUpgradeRequired const isDirty = !!Object.keys(form.formState.dirtyFields).length @@ -344,15 +363,17 @@ export function DiskManagementForm() { - {!isFlyArchitecture && ( + {isAws && ( <>
diff --git a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx index db4e473cb28..f7fc5456c37 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx @@ -13,11 +13,7 @@ import { } from '../DiskManagement.utils' import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { ComputeSizeRecommendationSection } from '../ui/ComputeSizeRecommendationSection' -import { - COMPUTE_BASELINE_IOPS, - DiskType, - RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3, -} from '../ui/DiskManagement.constants' +import { DiskType, RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3 } from '../ui/DiskManagement.constants' import { DiskManagementIOPSReadReplicas } from '../ui/DiskManagementReadReplicas' import FormMessage from '../ui/FormMessage' import { InputPostTab } from '../ui/InputPostTab' @@ -117,13 +113,7 @@ export function IOPSField({ form, disableInput }: IOPSFieldProps) { type="number" className="flex-grow font-mono rounded-r-none max-w-32" {...field} - value={ - disableIopsInput - ? COMPUTE_BASELINE_IOPS[ - watchedComputeSize as keyof typeof COMPUTE_BASELINE_IOPS - ] - : field.value - } + value={field.value} disabled={disableInput || disableIopsInput || isError} onChange={(e) => { setValue('provisionedIOPS', e.target.valueAsNumber, { diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ThroughputField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ThroughputField.tsx index 1aa93f0df82..f7c925e8f58 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ThroughputField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ThroughputField.tsx @@ -11,7 +11,6 @@ import { DiskStorageSchemaType } from '../DiskManagement.schema' import { calculateThroughputPrice } from '../DiskManagement.utils' import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { - COMPUTE_BASELINE_THROUGHPUT, DISK_LIMITS, DiskType, RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3, @@ -129,13 +128,7 @@ export function ThroughputField({ form, disableInput }: ThroughputFieldProps) { { setValue('throughput', e.target.valueAsNumber, { shouldDirty: true, diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index b523ded9d53..341071c2b7e 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,15 +1,18 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { Key } from 'lucide-react' +import { useMemo } from 'react' import { useParams } from 'common' import type { showApiKey } from 'components/interfaces/Docs/Docs.types' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { Button, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' @@ -17,25 +20,30 @@ const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } interface LangSelectorProps { selectedLang: string - showApiKey: showApiKey + selectedApiKey: showApiKey setSelectedLang: (selectedLang: string) => void - setShowApiKey: (showApiKey: showApiKey) => void + setSelectedApiKey: (showApiKey: showApiKey) => void } const LangSelector = ({ selectedLang, - showApiKey, + selectedApiKey, setSelectedLang, - setShowApiKey, + setSelectedApiKey, }: LangSelectorProps) => { const { ref: projectRef } = useParams() - const canReadServiceKey = useCheckPermissions( - PermissionAction.READ, - 'service_api_keys.service_role_key' - ) - const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { anonKey: anonApiKey, serviceKey: serviceApiKey } = getAPIKeys(settings) + const { data: apiKeys = [], isLoading: isLoadingAPIKeys } = useAPIKeysQuery({ + projectRef, + reveal: false, + }) + + const legacyKeys = useMemo(() => apiKeys.filter(({ type }) => type === 'legacy'), [apiKeys]) + const publishableKeys = useMemo( + () => apiKeys.filter(({ type }) => type === 'publishable'), + [apiKeys] + ) + const secretKeys = useMemo(() => apiKeys.filter(({ type }) => type === 'secret'), [apiKeys]) return (
@@ -62,48 +70,93 @@ const LangSelector = ({ > Bash - {selectedLang == 'bash' && ( -
+ {selectedLang == 'bash' && !isLoadingAPIKeys && apiKeys && apiKeys.length > 0 && ( +
- Project API key : + Project API key:
- + - <> - setShowApiKey(DEFAULT_KEY)}> - hide - - {anonApiKey && ( - - setShowApiKey({ - key: anonApiKey.api_key ?? '-', - name: 'anon (public)', - }) - } - > -

anon (public)

-
+ + setSelectedApiKey(DEFAULT_KEY)} + > + Hide keys + + + {publishableKeys.length > 0 && ( + <> + + Publishable keys + {publishableKeys.map((key) => { + const value = key.api_key + return ( + + setSelectedApiKey({ + name: `Publishable key: ${key.name}`, + key: value, + }) + } + > + {key.name} + + ) + })} + )} - {canReadServiceKey && ( - - setShowApiKey({ - key: serviceApiKey?.api_key ?? '-', - name: 'service_role (secret)', - }) - } - > -

service_role (secret)

-
+ + {secretKeys.length > 0 && ( + <> + + Secret keys + {secretKeys.map((key) => { + const value = key.prefix + '...' + return ( + + setSelectedApiKey({ name: `Secret key: ${key.name}`, key: value }) + } + > + {key.name} + + ) + })} + )} - + + + + + JWT-based legacy keys + {legacyKeys.map((key) => { + const value = key.api_key + return ( + + setSelectedApiKey({ name: `Legacy key: ${key.name}`, key: value }) + } + > + {key.name} + + ) + })} + +
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index d7f58f0d76e..7c65503db22 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -15,7 +15,10 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { prettifyJSON } from 'lib/helpers' import { getRoleImpersonationJWT } from 'lib/role-impersonation' -import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' +import { + RoleImpersonationStateContextProvider, + useGetImpersonatedRoleState, +} from 'state/role-impersonation-state' import { Badge, Button, @@ -159,7 +162,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester // Validate that the body is valid JSON try { - JSON.parse(values.body) + JSON.parse(JSON.stringify(values.body)) } catch (e) { form.setError('body', { message: 'Must be a valid JSON string' }) return @@ -416,7 +419,12 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
- + {/* [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users. */} + + + +
+
+ + + +
+
+ +
+
+

Why use a Standby key?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+
+
*/} + + + + + Start using new JWT signing keys + + + +

+ Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to + use an asymmetric JWT signing key you first have to migrate it to the new system. This + change does not cause any downtime on your project. +

+
+ + + +
+
+ + + + + + + + + + + Rotate Key + + + + + {standbyKey ? ( + <> + The standby key ({algorithmLabels[standbyKey.algorithm]}) will be promoted to 'In + use'. This will: +
    +
  • Change the current standby key to 'In use'
  • +
  • Move the current 'In use' key to 'Previously used'
  • +
  • Move any 'Previously used' key to 'Revoked'
  • +
+ + ) : ( + <> + Since there is no standby key, you need to choose an algorithm for the new key: +
+ setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + ES256 (ECC) + RS256 (RSA) + EdDSA (Ed25519) + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+ + )} +
+
+ + + +
+
+ + + + + Confirm key rotation + + Review the key rotation process below. Ensure your application's components have + already picked up and are trusting your standby key to avoid downtime. + + + + +
+ {standbyKey ? ( +
+ + + STANDBY KEY + + {algorithmLabels[standbyKey!.algorithm]} + + + + + + CURRENTLY USED + +
+ ) : ( +
+ + + New Key + + {algorithmLabels[newKeyAlgorithm]} + + + + + + CURRENTLY USED + +
+ )} + +
+ + + CURRENTLY USED + + {inUseKey?.algorithm && algorithmLabels[inUseKey.algorithm]} + + + + + + PREVIOUS KEY + +
+
+
+ + + + +
+
+ + + + + Key Details + + + + {selectedKey && ( + <> +
+
+ +

Public Key (PEM format)

+
+
+                    {typeof selectedKey.public_jwk === 'string'
+                      ? selectedKey.public_jwk
+                      : JSON.stringify(selectedKey.public_jwk ?? '', null, 2)}
+                  
+
+
+

JWKS URL

+
+                    {`${window.location.origin}/jwt/v1/jwks.json`}
+                  
+
+ + )} +
+
+
+ + {selectedKey && + selectedKey.status === 'previously_used' && + (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && ( + handleRevokeKey(selectedKey.id)} + onCancel={resetDialog} + title={`Revoke ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, revoke this signing key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will no longer be trusted!', + description: + 'By revoking a signing key, all applications trusting it will no longer do so. If there are JWTs (access tokens) that are valid at the time of revocation, they will no longer be trusted, causing users with such JWTs to be signed out.', + }} + /> + )} + + {selectedKey && + selectedKey.status === 'previously_used' && + legacyKey?.id === selectedKey.id && + (legacyAPIKeysStatus?.enabled ?? true) && ( + resetDialog()}> + + + Disable JWT-based legacy API keys first + + + It's not possible to revoke the legacy JWT secret unless you have already disabled + JWT-based legacy API keys. This is because revoking the JWT secret invalidates the + JWT-based legacy API keys. + + + OK + + + + )} + + {selectedKey && selectedKey.status === 'revoked' && ( + handleDeleteKey(selectedKey.id)} + onCancel={resetDialog} + title={`Permanently delete ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, permanently delete this key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will be permanently deleted.', + description: + 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.', + }} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx new file mode 100644 index 00000000000..e8395ed57b0 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -0,0 +1,147 @@ +import { motion } from 'framer-motion' +import { CircleArrowDown, Eye, Key, MoreVertical, ShieldOff, Timer } from 'lucide-react' + +import { components } from 'api-types' +import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' + +interface SigningKeyRowProps { + signingKey: components['schemas']['SigningKeyResponse'] + setSelectedKey: (key: JWTSigningKey | null) => void + setShownDialog: ( + dialog: + | 'legacy' + | 'create' + | 'rotate' + | 'confirm-rotate' + | 'key-details' + | 'revoke' + | 'delete' + | null + ) => void + handlePreviouslyUsedKey: (keyId: string) => void + legacyKey?: components['schemas']['SigningKeyResponse'] | null +} + +const MotionTableRow = motion(TableRow) + +export const SigningKeyRow = ({ + signingKey, + setSelectedKey, + setShownDialog, + handlePreviouslyUsedKey, + legacyKey, +}: SigningKeyRowProps) => ( + + +
+ + {signingKey.status === 'standby' ? : } + {statusLabels[signingKey.status]} + +
+
+ +
+ + {signingKey.id} + + +
+
+ + + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx new file mode 100644 index 00000000000..d0460153ee9 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx @@ -0,0 +1,28 @@ +import { FeatureBanner } from 'components/ui/FeatureBanner' +import { Import } from 'lucide-react' +import { Button } from 'ui' + +export const StartUsingJwtSigningKeysBanner = ({ + onClick, + isLoading, +}: { + onClick: () => void + isLoading: boolean +}) => { + return ( + +
+

Start using JWT signing keys

+

+ Right now your project is using the legacy JWT secret. To start taking advantage of the + new JWT signing keys, migrate your project's secret to the new set up. +

+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 8af7d72ade4..778dd9933ed 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -91,7 +91,7 @@ const BillingEmail = () => { useEffect(() => { if (billingCustomer) { form.reset({ - billingEmail: billing_email, + billingEmail: billing_email ?? '', additionalBillingEmails: billingCustomer.additional_emails ?? [], }) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts deleted file mode 100644 index d2f5238e5f4..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', -] diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index 445b2c2ba7b..6a942301803 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -82,7 +82,7 @@ export const BillingSettings = () => { )} - {isBillingCreditsEnabledOnProfileLevel && isNotOrgWithPartnerBilling && ( + {isBillingCreditsEnabledOnProfileLevel && ( <> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index a2e71206e62..b33f047760d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -325,7 +325,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={form.getValues().paymentMethod} /> )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 5123b3bca2c..9070143191f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -5,18 +5,18 @@ */ import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' -import { PaymentMethod } from '@stripe/stripe-js' -import { getURL } from 'lib/helpers' import { forwardRef, useImperativeHandle } from 'react' import { toast } from 'sonner' const NewPaymentMethodElement = forwardRef( ( { - pending_subscription_flow_enabled, email, readOnly, - }: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean }, + }: { + email?: string | null | undefined + readOnly: boolean + }, ref ) => { const stripe = useStripe() @@ -26,40 +26,26 @@ const NewPaymentMethodElement = forwardRef( if (!stripe || !elements) return await elements.submit() - if (pending_subscription_flow_enabled) { - // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment - const { error, paymentMethod } = await stripe.createPaymentMethod({ - elements, - }) - if (error || paymentMethod == null) { - toast.error(error?.message ?? ' Failed to process card details') - return - } - return paymentMethod - } else { - const { error, setupIntent } = await stripe.confirmSetup({ - elements, - redirect: 'if_required', - confirmParams: { - return_url: getURL(), - expand: ['payment_method'], - }, - }) - - if (error || !setupIntent.payment_method) { - toast.error(error?.message ?? ' Failed to save card details') - return - } - - return setupIntent.payment_method as PaymentMethod + // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment + const { error, paymentMethod } = await stripe.createPaymentMethod({ + elements, + }) + if (error || paymentMethod == null) { + toast.error(error?.message ?? ' Failed to process card details') + return } + return paymentMethod } useImperativeHandle(ref, () => ({ createPaymentMethod, })) - return + return ( + + ) } ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index 02d6159a65d..eb2cb8e44be 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -7,11 +7,15 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { CriticalIcon, WarningIcon } from 'ui' import { PricingMetric } from 'data/analytics/org-daily-stats-query' +import { usePathname } from 'next/navigation' export const Restriction = () => { const org = useSelectedOrganization() const { data: usage, isSuccess: isSuccessOrgUsage } = useOrgUsageQuery({ orgSlug: org?.slug }) + const pathname = usePathname() + const isUsagePage = pathname?.endsWith('/usage') + const hasExceededAnyLimits = Boolean( usage?.usages.find( (metric) => @@ -69,6 +73,11 @@ export const Restriction = () => { {org.plan.id === 'free' ? 'Upgrade plan' : 'Change spend cap'} + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + {!isUsagePage && ( + + )} - - - +
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })} +
+
+ + setMessage(event.target.value)} + rows={3} + /> +
+ {hasProjectsWithComputeDowngrade && ( + + This is due to changes in compute instances from the downgrade. Affected projects + include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}. + + )} - - + + +
+

+ The unused amount for the remaining time of your billing cycle will be refunded as credits +

+ +
+ + + + +
+
+ ) } - -export default ExitSurveyModal diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 85441004f95..3754491fbd1 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -286,8 +286,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 4102d6f65bb..a91fa6368eb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -5,7 +5,9 @@ import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants' +import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import PartnerManagedResource from 'components/ui/PartnerManagedResource' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' @@ -23,12 +25,10 @@ import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' -import ExitSurveyModal from './ExitSurveyModal' +import { ExitSurveyModal } from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' -import PartnerManagedResource from 'components/ui/PartnerManagedResource' -import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' const PlanUpdateSidePanel = () => { const router = useRouter() diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 9cc8fbc274f..e1b473bf68e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from '@tanstack/react-query' import { Check, InfoIcon } from 'lucide-react' import Link from 'next/link' import { useMemo, useRef, useState } from 'react' @@ -11,7 +10,6 @@ import { } from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { organizationKeys } from 'data/organizations/keys' import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview' import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' @@ -43,7 +41,7 @@ const getRandomTweet = () => { const PLAN_HEADINGS = { tier_pro: - 'the Pro plan and create unlimited projects, daily backups and premium support when you need it', + 'the Pro plan to unlock unlimited projects, daily backups, and email support whenever you need it', tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention', default: 'to a new plan', } as const @@ -85,7 +83,6 @@ export const SubscriptionPlanUpdateDialog = ({ projects, }: Props) => { const { resolvedTheme } = useTheme() - const queryClient = useQueryClient() const selectedOrganization = useSelectedOrganization() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) @@ -116,6 +113,7 @@ export const SubscriptionPlanUpdateDialog = ({ ) const onSuccessfulPlanChange = () => { + setPaymentConfirmationLoading(false) toast.success( `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` ) @@ -134,6 +132,7 @@ export const SubscriptionPlanUpdateDialog = ({ onSuccessfulPlanChange() }, onError: (error) => { + setPaymentConfirmationLoading(false) toast.error(`Unable to update subscription: ${error.message}`) }, } @@ -169,9 +168,13 @@ export const SubscriptionPlanUpdateDialog = ({ if (!selectedOrganization?.slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') + setPaymentConfirmationLoading(true) + const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() if (paymentMethod) { setSelectedPaymentMethod(paymentMethod.id) + } else { + setPaymentConfirmationLoading(false) } if ( @@ -182,23 +185,6 @@ export const SubscriptionPlanUpdateDialog = ({ return } - if (paymentMethod) { - queryClient.setQueriesData( - organizationKeys.paymentMethods(selectedOrganization.slug), - (prev: any) => { - if (!prev) return prev - return { - ...prev, - defaultPaymentMethodId: paymentMethod?.id, - data: prev.data.map((pm: any) => ({ - ...pm, - is_default: pm.id === paymentMethod?.id, - })), - } - } - ) - } - // If the user is downgrading from team, should have spend cap disabled by default const tier = subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO @@ -253,6 +239,10 @@ export const SubscriptionPlanUpdateDialog = ({ { + // Do not allow closing mid-change + if (isUpdating || paymentConfirmationLoading || isConfirming) { + return + } if (!open) onClose() }} > @@ -561,17 +551,15 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && !subscriptionPreviewIsLoading && changeType === 'upgrade' && ( + {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
setSelectedPaymentMethod(pm)} + createPaymentMethodInline={true} readOnly={paymentConfirmationLoading || isConfirming || isUpdating} />
@@ -700,7 +688,6 @@ export const SubscriptionPlanUpdateDialog = ({ paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={selectedPaymentMethod!} /> )} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx new file mode 100644 index 00000000000..7d890a0fb09 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -0,0 +1,132 @@ +import { ReactNode } from 'react' +import { Control } from 'react-hook-form' + +import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm' +import { useFlag } from 'hooks/ui/useFlag' +import { FormField_Shadcn_, RadioGroup_Shadcn_, RadioGroupItem_Shadcn_ } from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { OptInToOpenAIToggle } from './OptInToOpenAIToggle' + +interface AIOptInLevelSelectorProps { + control: Control + disabled?: boolean + label?: ReactNode + layout?: 'horizontal' | 'vertical' | 'flex-row-reverse' +} + +const AI_OPT_IN_LEVELS_OLD = [ + { + value: 'disabled', + title: 'Disabled', + description: + 'You do not consent to sharing any database information with Supabase AI and understand that responses will be generic and not tailored to your database', + }, + { + value: 'schema', + title: 'Send anonymous metadata', + description: + 'You consent to sending anonymous data to Supabase AI, which can improve the answers it shows you.', + }, +] + +const AI_OPT_IN_LEVELS = [ + { + value: 'disabled', + title: 'Disabled', + description: + 'You do not consent to sharing any database information with Amazon Bedrock and understand that responses will be generic and not tailored to your database', + }, + { + value: 'schema', + title: 'Schema Only', + description: + 'You consent to sharing your database’s schema metadata (such as table and column names, data types, and relationships—but not actual database data) with Amazon Bedrock', + }, + { + value: 'schema_and_log', + title: 'Schema & Logs', + description: + 'You consent to sharing your schema and logs (which may contain PII/database data) with Amazon Bedrock for better results', + }, + { + value: 'schema_and_log_and_data', + title: 'Schema, Logs & Database Data', + description: + 'You consent to give Amazon Bedrock full access to run database read only queries and analyze results for optimal results', + }, +] + +export const AIOptInLevelSelector = ({ + control, + disabled, + label, + layout = 'vertical', +}: AIOptInLevelSelectorProps) => { + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + const useBedrockAssistant = useFlag('useBedrockAssistant') + + const optInLevels = useBedrockAssistant ? AI_OPT_IN_LEVELS : AI_OPT_IN_LEVELS_OLD + + return ( + + {useBedrockAssistant && ( + <> + {!newOrgAiOptIn && ( + + )} +

+ Supabase AI can provide more relevant answers if you choose to share different + levels of data. This feature is powered by Amazon Bedrock which does not store or + log your prompts and completions, nor does it use them to train AWS models or + distribute them to third parties. This is an organization-wide setting, so please + select the level of data you are comfortable sharing. +

+ + )} + +
+ } + > +
+ ( + + {optInLevels.map((item) => ( +
+ + +
+ ))} +
+ )} + /> +
+ + ) +} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx new file mode 100644 index 00000000000..2838f7df444 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx @@ -0,0 +1,50 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect } from 'react' + +import { FormActions } from 'components/ui/Forms/FormActions' +import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useFlag } from 'hooks/ui/useFlag' +import { Card, CardContent, CardFooter, Form_Shadcn_ } from 'ui' +import { AIOptInLevelSelector } from './AIOptInLevelSelector' + +export const DataPrivacyForm = () => { + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const permissionsHelperText = !canUpdateOrganization + ? "You need additional permissions to manage this organization's settings" + : undefined + + useEffect(() => { + form.reset({ aiOptInLevel: currentOptInLevel }) + }, [currentOptInLevel, form]) + + return ( + +
+ + + + + + form.reset()} + helper={permissionsHelperText} + disabled={!canUpdateOrganization} + /> + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index d256ecfc4f2..2f28ffd5781 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -1,139 +1,38 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' import { NoProjectsOnPaidOrgInfo } from 'components/interfaces/Billing/NoProjectsOnPaidOrgInfo' -import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' -import { FormActions } from 'components/ui/Forms/FormActions' -import { FormPanel } from 'components/ui/Forms/FormPanel' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { + ScaffoldContainer, + ScaffoldSection, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { OPT_IN_TAGS } from 'lib/constants' -import { Form, Input, Toggle } from 'ui' -import OptInToOpenAIToggle from './OptInToOpenAIToggle' import OrganizationDeletePanel from './OrganizationDeletePanel' +import { DataPrivacyForm } from './DataPrivacyForm' +import { OrganizationDetailsForm } from './OrganizationDetailsForm' + const GeneralSettings = () => { - const { slug } = useParams() - const queryClient = useQueryClient() - const selectedOrganization = useSelectedOrganization() - const { name } = selectedOrganization ?? {} - - const formId = 'org-general-settings' - const isOptedIntoAi = useOrgOptedIntoAi() - const initialValues = { name: name ?? '', isOptedIntoAi } - const organizationDeletionEnabled = useIsFeatureEnabled('organizations:delete') - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - - const onUpdateOrganization = async (values: any, { resetForm }: any) => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!slug) return console.error('Slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - const updatedOptInTags = - values.isOptedIntoAi && !existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags.concat([OPT_IN_TAGS.AI_SQL]) - : !values.isOptedIntoAi && existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags.filter((x) => x !== OPT_IN_TAGS.AI_SQL) - : existingOptInTags - - updateOrganization( - { slug, name: values.name, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - resetForm({ values, initialValues: values }) - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully saved settings') - }, - } - ) - } return ( - - + + -
- {({ handleReset, values, initialValues, resetForm }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + Organization Details + + - // [Alaister] although this "technically" is breaking the rules of React hooks - // it won't error because the hooks are always rendered in the same order - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - const values = { name: name ?? '', isOptedIntoAi } - resetForm({ values, initialValues: values }) - }, [selectedOrganization?.slug]) - - return ( - - - - } - > - General settings}> - - - -
- - - -
-
-
-
- ) - }} -
+ + Data Privacy + + {organizationDeletionEnabled && canDeleteOrganization && } -
+ ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index e360e000d20..becd7b76488 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -1,71 +1,111 @@ -import Link from 'next/link' -import { cn, Collapsible_Shadcn_, CollapsibleTrigger_Shadcn_, CollapsibleContent_Shadcn_ } from 'ui' -import { useState } from 'react' -import { ChevronRight } from 'lucide-react' +import { InlineLink } from 'components/ui/InlineLink' +import { useFlag } from 'hooks/ui/useFlag' -interface OptInToOpenAIToggleProps { - className?: string -} -export default function OptInToOpenAIToggle({ className }: OptInToOpenAIToggleProps) { - const [open, setOpen] = useState(false) +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogSection, + DialogTitle, + DialogTrigger, +} from 'ui' + +export const OptInToOpenAIToggle = () => { + const useBedrockAssistant = useFlag('useBedrockAssistant') return ( - - -
- -

- Important information regarding opting in -

-
-
- -
-

- Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the query - you submit and information about the databases you manage through Supabase with OpenAI, - L.L.C. and its affiliates in order to provide the Supabase AI tool. -

-

- OpenAI will only access information about the structure of your databases, such as table - names, column and row headings. OpenAI will not access the contents of the database - itself. -

-

- OpenAI uses this information to generate responses to your query, and does not retain or - use the information to train its algorithms or otherwise improve its products and - services. -

-

- If you have your own individual account on Supabase, we will use any personal - information collected through [Supabase AI] to provide you with the [Supabase AI] tool. - If you are in the UK, EEA or Switzerland, the processing of this personal information is - necessary for the performance of a contract between you and us. -

-

- Supabase collects information about the queries you submit through Supabase AI and the - responses you receive to assess the performance of the Supabase AI tool and improve our - services. If you are in the UK, EEA or Switzerland, the processing is necessary for our - legitimate interests, namely informing our product development and improvement. -

-

- For more information about how we use personal information, please see our{' '} - - privacy policy - - . -

-
-
-
+ + + + + + + Data Privacy and Supabase AI + + + {useBedrockAssistant ? ( + <> +

+ Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong + focus on data privacy and security. +

+ +

+ Amazon Bedrock does not store or log your prompts and completions. This data is not + used to train any AWS models and is not distributed to third parties or model + providers. Model providers do not have access to Amazon Bedrock logs or customer + prompts and completions. +

+ +

+ By default, no information is shared with Bedrock unless you explicitly provide + consent. With your permission, Supabase may share customer-generated prompts, + database schema, database data, and project logs with Bedrock. This information is + used solely to generate responses to your queries and is not retained by Bedrock or + used to train their foundation models. +

+ +

+ If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and + Supabase has a Business Associate Agreement in place covering this use. +

+ +

+ For more detailed information about how we collect and use your data, see our{' '} + Privacy Policy. You can + choose which types of information you consent to share by selecting from the options + in the AI settings. +

+ + ) : ( + <> +

+ Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the + query you submit and information about the databases you manage through Supabase + with OpenAI, L.L.C. and its affiliates in order to provide the Supabase AI tool. +

+ +

+ OpenAI will only access information about the structure of your databases, such as + table names, column and row headings. OpenAI will not access the contents of the + database itself. +

+ +

+ OpenAI uses this information to generate responses to your query, and does not + retain or use the information to train its algorithms or otherwise improve its + products and services. +

+ +

+ If you have your own individual account on Supabase, we will use any personal + information collected through [Supabase AI] to provide you with the [Supabase AI] + tool. If you are in the UK, EEA or Switzerland, the processing of this personal + information is necessary for the performance of a contract between you and us. +

+ +

+ Supabase collects information about the queries you submit through Supabase AI and + the responses you receive to assess the performance of the Supabase AI tool and + improve our services. If you are in the UK, EEA or Switzerland, the processing is + necessary for our legitimate interests, namely informing our product development and + improvement. +

+ +

+ For more information about how we use personal information, please see our{' '} + privacy policy. +

+ + )} +
+
+
) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index d0cd4ba4e73..dc878869f72 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -1,4 +1,4 @@ -import Panel from 'components/ui/Panel' +import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Admonition } from 'ui-patterns' @@ -8,30 +8,28 @@ const OrganizationDeletePanel = () => { const selectedOrganization = useSelectedOrganization() return ( - DANGER ZONE

}> - - {selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( - - - - ) : ( - - )} - -
+ + Danger Zone + {selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( + + + + ) : ( + + )} + ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx new file mode 100644 index 00000000000..b264df89b00 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx @@ -0,0 +1,131 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import CopyButton from 'components/ui/CopyButton' +import { FormActions } from 'components/ui/Forms/FormActions' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import type { ResponseError } from 'types' +import { + Card, + CardContent, + CardFooter, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as Input, + PrePostTab, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const OrgDetailsSchema = z.object({ + name: z.string().min(1, 'Organization name is required'), +}) + +export const OrganizationDetailsForm = () => { + const { slug } = useParams() + const queryClient = useQueryClient() + const selectedOrganization = useSelectedOrganization() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const { mutate: updateOrganization, isLoading: isUpdatingDetails } = + useOrganizationUpdateMutation() + + const orgDetailsForm = useForm>({ + resolver: zodResolver(OrgDetailsSchema), + defaultValues: { name: selectedOrganization?.name ?? '' }, + }) + + const onUpdateOrganizationDetails = async (values: z.infer) => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + if (!slug) return console.error('Slug is required') + + updateOrganization( + { slug, name: values.name }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated organization name') + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update organization name: ${error.message}`) + }, + } + ) + } + + const permissionsHelperText = !canUpdateOrganization + ? "You need additional permissions to manage this organization's settings" + : undefined + + useEffect(() => { + if (selectedOrganization && !isUpdatingDetails) { + orgDetailsForm.reset({ name: selectedOrganization.name ?? '' }) + } + }, [selectedOrganization, orgDetailsForm, isUpdatingDetails]) + + return ( + +
+ + + ( + + + + + + )} + /> + + + + + } + > + + + + + + orgDetailsForm.reset()} + helper={permissionsHelperText} + disabled={!canUpdateOrganization} + /> + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index c6598e97661..a1c811a09ad 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -120,9 +120,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr ({ clientSecret: setupIntent ? setupIntent.client_secret! : '', appearance: getStripeElementsAppearanceOptions(resolvedTheme), - ...(setupIntent?.pending_subscription_flow_enabled_for_creation === true - ? { paymentMethodCreation: 'manual' } - : {}), + paymentMethodCreation: 'manual', }) as const, [setupIntent, resolvedTheme] ) @@ -553,9 +551,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr @@ -645,7 +640,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={paymentMethod.id} onError={(err) => { toast.error(err.message, { duration: 10_000 }) setNewOrgLoading(false) diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index dad92b5c257..e636c1e34ba 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -1,9 +1,11 @@ -import { Boxes } from 'lucide-react' +import { Boxes, Lock } from 'lucide-react' import Link from 'next/link' +import { useIsMFAEnabled } from 'common' import { ActionCard } from 'components/ui/ActionCard' import { useProjectsQuery } from 'data/projects/projects-query' import { Organization } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' export const OrganizationCard = ({ organization, @@ -12,17 +14,44 @@ export const OrganizationCard = ({ organization: Organization href?: string }) => { + const isUserMFAEnabled = useIsMFAEnabled() const { data: allProjects = [] } = useProjectsQuery() + const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length + const isMfaRequired = organization.organization_requires_mfa return ( div]:w-full [&>div]:items-center')} icon={} title={organization.name} - description={`${organization.plan.name} Plan${numProjects > 0 ? `${' '}•${' '}${numProjects} project${numProjects > 1 ? 's' : ''}` : ''}`} + description={ +
+
+ {organization.plan.name} Plan + {numProjects > 0 && ( + <> + • + + {numProjects} project{numProjects > 1 ? 's' : ''} + + + )} +
+ {isMfaRequired && ( + + + + + + MFA enforced + + + )} +
+ } /> ) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index 07d460f1bce..9d1b56009d0 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -4,7 +4,7 @@ import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' export function getAvailableRegions(cloudProvider: CloudProvider): Region { switch (cloudProvider) { case 'AWS': - case 'AWS_NEW': + case 'AWS_K8S': return AWS_REGIONS case 'FLY': return FLY_REGIONS diff --git a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx index d44f1d7fc24..120428fe922 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx @@ -2,6 +2,7 @@ import { useChat } from 'ai/react' import { useEffect, useState } from 'react' import { Markdown } from 'components/interfaces/Markdown' +import { onErrorChat } from 'components/ui/AIAssistantPanel/AIAssistant.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { BASE_PATH } from 'lib/constants' import { AiIconAnimation, Button, Label_Shadcn_, Textarea } from 'ui' @@ -44,6 +45,7 @@ export const SchemaGenerator = ({ api: `${BASE_PATH}/api/ai/onboarding/design`, id: 'schema-generator', maxSteps: 7, + onError: onErrorChat, onFinish: () => { setInput('') }, diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 8a4cc98cef7..87c29b77990 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -29,8 +29,10 @@ import { FormField_Shadcn_, FormMessage_Shadcn_, Input_Shadcn_, + Switch, } from 'ui' import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' const formId = 'realtime-configuration-form' @@ -70,16 +72,14 @@ export const RealtimeSettings = () => { // max_channels_per_client: z.coerce.number().min(1).max(10000), // max_joins_per_second: z.coerce.number().min(1).max(5000), - // [Filipe] This field is temporarily hidden from the UI - // allow_public: z.boolean(), + allow_public: z.boolean(), }) const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { ...REALTIME_DEFAULT_CONFIG, - // [Filipe] This field is temporarily hidden from the UI - // allow_public: !REALTIME_DEFAULT_CONFIG.private_only, + allow_public: !REALTIME_DEFAULT_CONFIG.private_only, }, }) @@ -87,8 +87,7 @@ export const RealtimeSettings = () => { if (!projectRef) return console.error('Project ref is required') updateRealtimeConfig({ ref: projectRef, - // [Filipe] This field is temporarily hidden from the UI - // private_only: !data.allow_public, + private_only: !data.allow_public, connection_pool: data.connection_pool, max_concurrent_users: data.max_concurrent_users, }) @@ -97,10 +96,9 @@ export const RealtimeSettings = () => { useEffect(() => { // [Joshen] Temp typed with any - API typing marks all the properties as nullable, // but checked with Filipe that they're not supposed to - // [Filipe] This field is temporarily hidden from the UI - // if (data) form.reset({ ...data, allow_public: !data.private_only } as any) - - if (data) form.reset({ ...data } as any) + if (isSuccess) { + form.reset({ ...data, allow_public: !data.private_only } as any) + } }, [isSuccess]) return ( @@ -111,10 +109,7 @@ export const RealtimeSettings = () => { ) : ( - {/* - [Filipe] We're hidding this field until we implement a 'kill all sockets` on change to be triggered in realtime server - */} - {/* + { )} /> - */} + )} -

+

{label}

{actions}
diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx new file mode 100644 index 00000000000..4883fc624af --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportChart.tsx @@ -0,0 +1,58 @@ +/** + * ReportChart + * + * A wrapper component that uses the useChartData hook to fetch data for a chart + * and then passes the data and loading state to the ComposedChartHandler. + * + * This component acts as a bridge between the data-fetching logic and the + * presentational chart component. + */ +import LogChartHandler from 'components/ui/Charts/LogChartHandler' +import { useChartData } from 'hooks/useChartData' +import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' + +const ReportChart = ({ + chart, + startDate, + endDate, + interval, + updateDateRange, +}: { + chart: any + startDate: string + endDate: string + interval: string + updateDateRange: UpdateDateRange +}) => { + const { + data, + isLoading: isLoading, + chartAttributes, + highlightedValue, + } = useChartData({ + attributes: chart.attributes, + startDate, + endDate, + interval, + data: undefined, + highlightedValue: + chart.id === 'client-connections' || chart.id === 'pgbouncer-connections' + ? true + : chart.showMaxValue, + }) + + return ( + 0 ? chartAttributes : chart.attributes) as MultiAttribute[] + } + data={data} + isLoading={isLoading} + highlightedValue={highlightedValue as any} + updateDateRange={updateDateRange} + /> + ) +} +export default ReportChart diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index 318f03e3546..adbe74201f6 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -12,6 +12,7 @@ export interface RoleImpersonationPopoverProps { serviceRoleLabel?: string variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both' align?: 'center' | 'start' | 'end' + disallowAuthenticatedOption?: boolean } const RoleImpersonationPopover = ({ @@ -19,6 +20,7 @@ const RoleImpersonationPopover = ({ serviceRoleLabel, variant = 'regular', align = 'end', + disallowAuthenticatedOption = false, }: RoleImpersonationPopoverProps) => { const state = useRoleImpersonationStateSnapshot() @@ -64,7 +66,10 @@ const RoleImpersonationPopover = ({ side="bottom" align={align} > - + ) diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx index 17b121074ca..fa591e1e8a0 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx @@ -10,11 +10,13 @@ import UserImpersonationSelector from './UserImpersonationSelector' export interface RoleImpersonationSelectorProps { serviceRoleLabel?: string padded?: boolean + disallowAuthenticatedOption?: boolean } const RoleImpersonationSelector = ({ serviceRoleLabel, padded = true, + disallowAuthenticatedOption = false, }: RoleImpersonationSelectorProps) => { const state = useRoleImpersonationStateSnapshot() @@ -81,21 +83,25 @@ const RoleImpersonationSelector = ({ icon={} /> - } - /> + {!disallowAuthenticatedOption && ( + } + /> + )} {selectedOption === 'service_role' && (

- The default Postgres/superuser role. This has admin privileges. + The default Postgres/superuser role. + {disallowAuthenticatedOption ?
: ' '} + This has admin privileges.
It will bypass Row Level Security (RLS) policies.

@@ -103,16 +109,21 @@ const RoleImpersonationSelector = ({ {selectedOption === 'anon' && (

- For "anonymous access". This is the role which the API (PostgREST) will use when a user + For "anonymous access". + {disallowAuthenticatedOption ?
: ' '} + This is the role which the API (PostgREST)
- is not logged in. It will respect Row Level Security (RLS) policies. + will use when a user is not logged in. + {disallowAuthenticatedOption ?
: ' '} + It will respect Row Level Security (RLS) policies.

)} {selectedOption === 'authenticated' && (

- For "authenticated access". This is the role which the API (PostgREST) will use when -
a user is logged in. It will respect Row Level Security (RLS) policies. + For "authenticated access". This is the role which the API (PostgREST) +
+ will use when a user is logged in. It will respect Row Level Security (RLS) policies.

)} diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 2f062081911..6b16ccc1aa3 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { getContentById } from 'data/content/content-id-query' import { UpsertContentPayload, @@ -11,12 +12,11 @@ import { import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' export interface RenameQueryModalProps { @@ -34,6 +34,7 @@ const RenameQueryModal = ({ }: RenameQueryModalProps) => { const { ref } = useParams() const organization = useSelectedOrganization() + const useBedrockAssistant = useFlag('useBedrockAssistant') const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() @@ -42,7 +43,6 @@ const RenameQueryModal = ({ { enabled: visible } ) const isSQLSnippet = snippet.type === 'sql' - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref }) // Customers on HIPAA plans should not have access to Supabase AI @@ -66,12 +66,12 @@ const RenameQueryModal = ({ const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { - titleSql({ sql: snippet.content.sql }) + titleSql({ sql: snippet.content.sql, useBedrockAssistant }) } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) if ('sql' in content) { - titleSql({ sql: content.sql }) + titleSql({ sql: content.sql, useBedrockAssistant }) } } catch (error) { toast.error('Unable to generate title based on query contents') @@ -112,10 +112,8 @@ const RenameQueryModal = ({ snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabsSnap.updateTab(tabId, { label: nameInput }) - } + const tabId = createTabId('sql', { id }) + tabsSnap.updateTab(tabId, { label: nameInput }) toast.success('Successfully renamed snippet!') if (onComplete) onComplete() diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 054174f05f3..c8f472df446 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -19,11 +19,12 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { isError } from 'data/utils/error-check' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' import { formatSql } from 'lib/formatSql' import { detectOS, uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -51,7 +52,6 @@ import { TooltipTrigger, cn, } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' import { RunQueryWarningModal } from './RunQueryWarningModal' import { @@ -81,6 +81,7 @@ export const SQLEditor = () => { const os = detectOS() const router = useRouter() const { ref, id: urlId } = useParams() + const useBedrockAssistant = useFlag('useBedrockAssistant') const { profile } = useProfile() const project = useSelectedProject() @@ -92,10 +93,8 @@ export const SQLEditor = () => { const snapV2 = useSqlEditorV2StateSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() const databaseSelectorState = useDatabaseSelectorStateSnapshot() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() + const { includeSchemaMetadata, isHipaaProjectDisallowed } = useOrgAiOptInLevel() const [selectedSchemas] = useSchemasForAi(project?.ref!) - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { sourceSqlDiff, @@ -210,17 +209,15 @@ export const SQLEditor = () => { const setAiTitle = useCallback( async (id: string, sql: string) => { try { - const { title: name } = await generateSqlTitle({ sql }) + const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant }) snapV2.renameSnippet({ id, name }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabs.updateTab(tabId, { label: name }) - } + const tabId = createTabId('sql', { id }) + tabs.updateTab(tabId, { label: name }) } catch (error) { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } }, - [generateSqlTitle, snapV2] + [generateSqlTitle, useBedrockAssistant, snapV2] ) const prettifyQuery = useCallback(async () => { @@ -464,7 +461,9 @@ export const SQLEditor = () => { completion, isLoading: isCompletionLoading, } = useCompletion({ - api: `${BASE_PATH}/api/ai/sql/complete`, + api: useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/complete-v2` + : `${BASE_PATH}/api/ai/sql/complete`, body: { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx index 869b3a48efe..bf00d51029c 100644 --- a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx +++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx @@ -19,6 +19,7 @@ export const ApiKeysMoved = () => { { name: 'Infrastructure', key: 'infrastructure', url: '#' }, { name: 'Integrations', key: 'integrations', url: '#' }, { name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' }, + { name: 'JWT Keys', key: 'jwt-keys', url: '#', label: 'NEW' }, { name: 'Add Ons', key: 'addons', url: '#' }, { name: 'Vault', @@ -37,14 +38,19 @@ export const ApiKeysMoved = () => { return (
-
-

API keys have moved

+
+

API keys and JWT settings have moved

- They can now be found in the new API Keys page + They can now be found in their own respective pages.

- - - +
+ + + + + + +
{/* Menu illustration using the actual ProductMenu component */} diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 56b402af109..0b1ed99ab3a 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,4 +1,3 @@ -import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useQueryClient } from '@tanstack/react-query' import { AlertCircle } from 'lucide-react' import { useEffect, useRef } from 'react' @@ -9,7 +8,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' @@ -17,9 +15,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { PROJECT_STATUS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, Input } from 'ui' -import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import { ApiKeysMoved } from './ApiKeysMoved' -import JWTSettings from './JWTSettings' import { PostgrestConfig } from './PostgrestConfig' const ServiceList = () => { @@ -32,15 +28,6 @@ const ServiceList = () => { const { data: databases, isError } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) - const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) - const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const jwtSecretUpdateError = data?.jwtSecretUpdateError - - const previousJwtSecretUpdateStatus = useRef() - const { Failed, Updated, Updating } = JwtSecretUpdateStatus - const jwtSecretUpdateErrorMessage = - JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] - // Get the API service const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) @@ -54,30 +41,6 @@ const ServiceList = () => { ? loadBalancers?.[0].endpoint ?? '' : selectedDatabase?.restUrl - useEffect(() => { - if (previousJwtSecretUpdateStatus.current === Updating) { - switch (jwtSecretUpdateStatus) { - case Updated: - client.invalidateQueries(configKeys.api(projectRef)) - client.invalidateQueries(configKeys.settings(projectRef)) - client.invalidateQueries(configKeys.postgrest(projectRef)) - toast.success('Successfully updated JWT secret') - break - case Failed: - toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) - break - } - } - - previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus - }, [jwtSecretUpdateStatus]) - - useEffect(() => { - if (source !== undefined) { - state.setSelectedDatabaseId('load-balancer') - } - }, [source]) - return (
{isLoading ? ( @@ -147,10 +110,6 @@ const ServiceList = () => { -
- -
-
diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 2dc1c2d1850..74dc615cf2a 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -32,7 +32,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { ProjectAddonVariantMeta } from 'data/subscriptions/types' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useIsOrioleDb, useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject' +import { useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { BASE_PATH, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants' @@ -53,7 +53,6 @@ const Addons = () => { const parentProject = useProjectByRef(selectedProject?.parent_project_ref) const isBranch = parentProject !== undefined const isProjectActive = useIsProjectActive() - const isOrioleDb = useIsOrioleDb() const isOrioleDbInAws = useIsOrioleDbInAws() const { data: settings } = useProjectSettingsV2Query({ projectRef }) diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 16e7d30340f..af56adf36cd 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -1,4 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -11,13 +12,14 @@ import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { AddonVariantId } from 'data/subscriptions/types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject' import { formatCurrency } from 'lib/helpers' -import { ExternalLink } from 'lucide-react' import { useAddonsPagePanel } from 'state/addons-page' import { Button, Radio, SidePanel, cn } from 'ui' import { Admonition } from 'ui-patterns' const IPv4SidePanel = () => { + const isAws = useIsAwsCloudProvider() const { ref: projectRef } = useParams() const organization = useSelectedOrganization() @@ -86,7 +88,7 @@ const IPv4SidePanel = () => { onCancel={closePanel} onConfirm={onConfirm} loading={isLoading || isSubmitting} - disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4} + disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4 || !isAws} tooltip={ isFreePlan ? 'Unable to enable IPv4 on a Free Plan' @@ -117,6 +119,13 @@ const IPv4SidePanel = () => { database via a IPv4 address.

+ {!isAws && ( + + )} + {isPgBouncerEnabled ? ( { className="col-span-4 !p-0" name="ipv4" key={option.identifier} - disabled={isFreePlan} + disabled={isFreePlan || !isAws} checked={selectedOption === option.identifier} label={option.name} value={option.identifier} diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index 427a7d66422..961805f9013 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import DeleteProjectModal from './DeleteProjectModal' +import { DeleteProjectModal } from './DeleteProjectModal' export interface DeleteProjectButtonProps { type?: 'danger' | 'default' diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index bc8c94655cf..966e6d481e6 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -1,4 +1,3 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -9,13 +8,18 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Input } from 'ui' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' -const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => { +export const DeleteProjectModal = ({ + visible, + onClose, +}: { + visible: boolean + onClose: () => void +}) => { const router = useRouter() const { project } = useProjectContext() const organization = useSelectedOrganization() @@ -31,7 +35,25 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const isFree = projectPlan === 'free' const [message, setMessage] = useState('') - const [selectedReasons, setSelectedReasons] = useState([]) + const [selectedReason, setSelectedReason] = useState([]) + + // Single select for cancellation reason + const onSelectCancellationReason = (reason: string) => { + setSelectedReason([reason]) + } + + // Helper to get label for selected reason + const getReasonLabel = (reason: string | undefined) => { + const found = CANCELLATION_REASONS.find((r) => r.value === reason) + return found?.label || 'What can we improve on?' + } + + const textareaLabel = getReasonLabel(selectedReason[0]) + + const [shuffledReasons] = useState(() => [ + ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5), + { value: 'None of the above' }, + ]) const { mutate: deleteProject, isLoading: isDeleting } = useProjectDeleteMutation({ onSuccess: async () => { @@ -41,7 +63,7 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( orgSlug: organization?.slug, projectRef, message, - reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), exitAction: 'delete', }) } catch (error) { @@ -58,122 +80,102 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const { mutateAsync: sendExitSurvey, isLoading: isSending } = useSendDowngradeFeedbackMutation() const isSubmitting = isDeleting || isSending - useEffect(() => { - if (visible) { - setSelectedReasons([]) - setMessage('') - } - }, [visible]) - - const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { - resource: { - project_id: project?.id, - }, - }) - - const onSelectCancellationReason = (reason: string) => { - const existingSelection = selectedReasons.find((x) => x === reason) - const updatedSelection = - existingSelection === undefined - ? selectedReasons.concat([reason]) - : selectedReasons.filter((x) => x !== reason) - setSelectedReasons(updatedSelection) - } - async function handleDeleteProject() { if (project === undefined) return - if (!isFree && selectedReasons.length === 0) { - return toast.error('Please select at least one reason for deleting your project') + if (!isFree && selectedReason.length === 0) { + return toast.error('Please select a reason for deleting your project') } deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug }) } + useEffect(() => { + if (visible) { + setSelectedReason([]) + setMessage('') + } + }, [visible]) + return ( - <> - { - if (!isSubmitting) onClose() - }} - > - {/* + { + if (!isSubmitting) onClose() + }} + > + {/* [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor but leaving that for the future. */} - {!isFree && ( - <> -
-

Help us improve.

-

- We always strive to improve Supabase as much as we can. Please let us know the - reasons you are deleting your project so that we can improve in the future. -

+ {!isFree && ( + <> +
+

+ Help us improve by sharing why you're deleting your project. +

+
+
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })}
-
-
- {CANCELLATION_REASONS.map((option) => { - const active = selectedReasons.find((x) => x === option) - return ( - - ) - })} -
-
- setMessage(event.target.value)} - /> -
+
+ + setMessage(event.target.value)} + />
- - )} - - +
+ + )} + ) } - -export default DeleteProjectModal diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index 3675e5a8f2b..9c8aad82053 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { AlertCircle, BarChart2, ChevronRight } from 'lucide-react' +import { BarChart2 } from 'lucide-react' import Link from 'next/link' import { toast } from 'sonner' @@ -13,15 +13,11 @@ import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useProjectByRef } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, Form, Input, WarningIcon, @@ -125,7 +121,7 @@ const General = () => {
-
+

Restart project

diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 96c1465002a..47daf680895 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -14,7 +14,7 @@ import { useProjectPauseMutation } from 'data/projects/project-pause-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useIsAwsNew } from 'hooks/misc/useSelectedProject' +import { useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -33,9 +33,9 @@ const PauseProjectButton = () => { 'queue_jobs.projects.pause' ) - const isAwsNew = useIsAwsNew() + const isAwsK8s = useIsAwsK8sCloudProvider() const isFreePlan = organization?.plan.id === 'free' - const isPaidAndNotAwsNew = !isFreePlan && !isAwsNew + const isPaidAndNotAwsK8s = !isFreePlan && !isAwsK8s const { mutate: pauseProject, isLoading: isPausing } = useProjectPauseMutation({ onSuccess: (_, variables) => { @@ -53,7 +53,7 @@ const PauseProjectButton = () => { } const buttonDisabled = - isPaidAndNotAwsNew || project === undefined || isPaused || !canPauseProject || !isProjectActive + isPaidAndNotAwsK8s || project === undefined || isPaused || !canPauseProject || !isProjectActive return ( <> @@ -72,7 +72,7 @@ const PauseProjectButton = () => { ? 'You need additional permissions to pause this project' : !isProjectActive ? 'Unable to pause project as project is not active' - : isPaidAndNotAwsNew + : isPaidAndNotAwsK8s ? 'Projects on a paid plan will always be running' : undefined, }, diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx index 5f485beaebf..c3ff46c0ac5 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx @@ -187,6 +187,7 @@ const ProjectUpgradeAlert = () => { [right-sized](https://supabase.com/docs/guides/platform/upgrading#disk-sizing) with the upgrade.`} /> )} + {/* @ts-ignore */} {(data?.potential_breaking_changes ?? []).length > 0 && ( diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 75320e24b38..4b00ae86f5d 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -9,10 +9,12 @@ import { useIsProjectActive, useProjectContext, } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectRestartMutation } from 'data/projects/project-restart-mutation' import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { Button, @@ -20,9 +22,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, cn, } from 'ui' import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' @@ -32,6 +31,7 @@ const RestartServerButton = () => { const queryClient = useQueryClient() const { project } = useProjectContext() const isProjectActive = useIsProjectActive() + const isAwsK8s = useIsAwsK8sCloudProvider() const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>() const projectRef = project?.ref ?? '' @@ -88,69 +88,68 @@ const RestartServerButton = () => { return ( <> - - -
- - {canRestartProject && isProjectActive && !projectRestartDisabled && ( - - -
-
- {((project !== undefined && (!canRestartProject || !isProjectActive)) || - projectRestartDisabled) && ( - - {projectRestartDisabled - ? 'Project restart is currently disabled' - : !canRestartProject - ? 'You need additional permissions to restart this project' - : !isProjectActive - ? 'Unable to restart project as project is not active' - : ''} - +
+ setServiceToRestart('project')} + tooltip={{ + content: { + side: 'bottom', + text: projectRestartDisabled + ? 'Project restart is currently disabled' + : !canRestartProject + ? 'You need additional permissions to restart this project' + : !isProjectActive + ? 'Unable to restart project as project is not active' + : isAwsK8s + ? 'Project restart is not supported for AWS (Revamped) projects' + : '', + }, + }} + > + Restart project + + {canRestartProject && isProjectActive && !projectRestartDisabled && ( + + +
x.organization_id === project?.organization_id ) const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan + const isAwsK8s = useIsAwsK8sCloudProvider() // Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet const [defaultRegion] = Object.entries(AWS_REGIONS).find( @@ -182,6 +183,7 @@ const DeployNewReplicaPanel = ({ isWalgEnabled && currentComputeAddon !== undefined && !hasOverdueInvoices && + !isAwsK8s && !isProWithSpendCapEnabled const computeAddons = @@ -261,6 +263,19 @@ const DeployNewReplicaPanel = ({ />
+ ) : isAwsK8s ? ( + + + + Read replicas are not supported for AWS (Revamped) projects + + + + Projects provisioned by other cloud providers currently will not be able to use read + replicas + + + ) : currentPgVersion < 15 ? ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 8f767224235..ca84227d92f 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -19,7 +19,6 @@ import { AlertTitle_Shadcn_, Alert_Shadcn_, Badge, - Button, Input, Tooltip, TooltipContent, @@ -27,6 +26,13 @@ import { } from 'ui' import { ProjectUpgradeAlert } from '../General/Infrastructure/ProjectUpgradeAlert' import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration' +import { + DatabaseExtensionsWarning, + ObjectsToBeDroppedWarning, + ReadReplicasWarning, + UnsupportedExtensionsWarning, + UserDefinedObjectsInInternalSchemasWarning, +} from './UpgradeWarnings' const InfrastructureInfo = () => { const { ref } = useParams() @@ -73,6 +79,12 @@ const InfrastructureInfo = () => { const isInactive = project?.status === 'INACTIVE' const hasReadReplicas = (databases ?? []).length > 1 + // @ts-ignore [Bobbie] to be removed after 2025-06-30 prod deploy + const hasExtensionDependentObjects = (data?.extension_dependent_objects ?? []).length > 0 + const hasObjectsToBeDropped = (data?.objects_to_be_dropped ?? []).length > 0 + const hasUnsupportedExtensions = (data?.unsupported_extensions || []).length > 0 + const hasObjectsInternalSchema = (data?.user_defined_objects_in_internal_schemas || []).length > 0 + return ( <> @@ -181,62 +193,38 @@ const InfrastructureInfo = () => { )} - {data?.eligible && !hasReadReplicas && } - {data.eligible && hasReadReplicas && ( - - - A new version of Postgres is available for your project - - - You will need to remove all read replicas prior to upgrading your Postgres - version to the latest available ({latestPgVersion}). - - - )} - {!data?.eligible && (data?.extension_dependent_objects || []).length > 0 && ( - - - A new version of Postgres is available - - -
-

- You'll need to remove the following extensions before upgrading: -

+ {data.eligible ? ( + hasReadReplicas ? ( + + ) : ( + + ) + ) : null} -
    - {(data?.extension_dependent_objects || []).map((obj) => ( -
  • - {obj} -
  • - ))} -
-
-

- {projectUpgradeEligibilityData?.potential_breaking_changes?.includes( - 'pg17_upgrade_unsupported_extensions' - ) - ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.' - : 'Check the docs for which ones might need to be removed.'} -

- - -
-
- )} + {!data.eligible ? ( + hasExtensionDependentObjects ? ( + + ) : hasObjectsToBeDropped ? ( + + ) : hasUnsupportedExtensions ? ( + + ) : hasObjectsInternalSchema ? ( + + ) : null + ) : null} )} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx new file mode 100644 index 00000000000..4f4e9d8679a --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx @@ -0,0 +1,185 @@ +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' + +export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: string }) => { + return ( + + + A new version of Postgres is available for your project + + + You will need to remove all read replicas prior to upgrading your Postgres version to the + latest available ({latestPgVersion}). + + + ) +} + +export const DatabaseExtensionsWarning = ({ + extensions, + potentialBreakingChanges, +}: { + extensions: string[] + potentialBreakingChanges?: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following extensions before upgrading:

+ +
    + {extensions.map((obj) => ( +
  • + {obj} +
  • + ))} +
+
+

+ {potentialBreakingChanges?.includes('pg17_upgrade_unsupported_extensions') + ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.' + : 'Check the docs for which ones might need to be removed.'} +

+
+ + + ) +} + +export const ObjectsToBeDroppedWarning = ({ + objectsToBeDropped, +}: { + objectsToBeDropped: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following objects before upgrading:

+ +
    + {objectsToBeDropped.map((obj) => ( +
  • + {obj} +
  • + ))} +
+
+

Check the docs for which objects need to be removed.

+ +
+
+ ) +} + +export const UnsupportedExtensionsWarning = ({ + unsupportedExtensions, +}: { + unsupportedExtensions: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following extensions before upgrading:

+ +
    + {unsupportedExtensions.map((obj: string) => ( +
  • + {obj} +
  • + ))} +
+
+

+ These extensions are not supported in newer versions of Supabase Postgres. If you are not + using them, it is safe to remove them. +

+ +
+
+ ) +} + +export const UserDefinedObjectsInInternalSchemasWarning = ({ objects }: { objects: string[] }) => { + return ( + + A new version of Postgres is available + +
+

+ You'll need to move these objects out of auth/realtime/storage schemas before upgrading: +

+ +
    + {objects.map((obj: string) => ( +
  • + {obj} +
  • + ))} +
+
+

+ These schemas are Supabase-managed and creating custom objects in them is no longer + supported. Check the changelog to see how to move them to your own schemas. +

+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx new file mode 100644 index 00000000000..a14b40e82e6 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx @@ -0,0 +1,111 @@ +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Badge, + Button_Shadcn_, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from 'ui' +import { useErrorCodesQuery } from 'data/content-api/docs-error-codes-query' +import { type ErrorCodeQueryQuery, Service } from 'data/graphql/graphql' +import { AlertTriangle } from 'lucide-react' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +interface ErrorCodeDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + errorCode: string + service?: Service +} + +export const ErrorCodeDialog = ({ + open, + onOpenChange, + errorCode, + service, +}: ErrorCodeDialogProps) => { + const { data, isLoading, isSuccess, refetch } = useErrorCodesQuery( + { code: errorCode, service }, + { enabled: open } + ) + + return ( + + + + + Help for error code {errorCode} + + + {isLoading && } + {isSuccess && } + {!isLoading && !isSuccess && } + + + + + ) +} + +const LoadingState = () => ( + <> + + + +) + +const SuccessState = ({ data }: { data: ErrorCodeQueryQuery | undefined }) => { + const errors = data?.errors?.nodes?.filter((error) => !!error.message) + if (!errors || errors.length === 0) { + return <>No information found for this error code. + } + + return ( + <> +

Possible explanations for this error:

+
+ {errors.map((error) => ( + + ))} +
+ + ) +} + +const ErrorExplanation = ({ + code, + service, + message, +}: { + code: string + service: Service + message?: string | null +}) => { + if (!message) return null + + return ( + <> + {service} +

{message}

+ + ) +} + +const ErrorState = ({ refetch }: { refetch?: () => void }) => ( + + + Lookup failed + +

Failed to look up error code help info

+ {refetch && ( + + Try again + + )} +
+
+) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index e741d74fe43..fd4de31277c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -1,3 +1,4 @@ +import { Service } from 'data/graphql/graphql' import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -12,6 +13,7 @@ import { Separator, } from 'ui' import { TimestampInfo } from 'ui-patterns' +import { ErrorCodeDialog } from '../ErrorCodeDialog' import type { LogSearchCallback, PreviewLogData } from '../Logs.types' import { ResponseCodeFormatter } from '../LogsFormatters' @@ -32,12 +34,18 @@ const PropertyRow = ({ keyName, value, dataTestId, + path, }: { keyName: string value: any dataTestId?: string + path?: string }) => { const { setSearch } = useLogsUrlState() + const [showErrorInfo, setShowErrorInfo] = useState(false) + + const service = path?.startsWith('/auth/') ? Service.Auth : undefined + const handleSearch: LogSearchCallback = async (event: string, { query }: { query?: string }) => { setSearch(query || '') } @@ -118,88 +126,108 @@ const PropertyRow = ({ } return ( - - -
-
-

- {keyName} -

+ <> + + +
- {isExpanded ? ( - - ) : isTimestamp ? ( - - ) : isStatus ? ( -
- -
- ) : isMethod ? ( -
- -
- ) : ( -
{value}
- )} +

+ {keyName} +

+
+ {isExpanded ? ( + + ) : isTimestamp ? ( + + ) : isStatus ? ( +
+ +
+ ) : isMethod ? ( +
+ +
+ ) : ( +
{value}
+ )} +
-
- - - Copy {keyName} - {!isObject && ( - { - setIsExpanded(!isExpanded) - }} - > - {isExpanded ? 'Collapse' : 'Expand'} value - - )} - {(isMethod || isUserAgent || isStatus || isPath) && ( - { - handleSearch('search-input-change', { query: value }) - }} - > - Search by {keyName} - - )} - {isSearch - ? getSearchPairs().map((pair) => ( - { - handleSearch('search-input-change', { query: pair }) - }} - > - Search by {pair} - - )) - : null} - - - + + + {keyName === 'error_code' && ( + { + setShowErrorInfo(true) + }} + > + More information + + )} + Copy {keyName} + {!isObject && ( + { + setIsExpanded(!isExpanded) + }} + > + {isExpanded ? 'Collapse' : 'Expand'} value + + )} + {(isMethod || isUserAgent || isStatus || isPath) && ( + { + handleSearch('search-input-change', { query: value }) + }} + > + Search by {keyName} + + )} + {isSearch + ? getSearchPairs().map((pair) => ( + { + handleSearch('search-input-change', { query: pair }) + }} + > + Search by {pair} + + )) + : null} + + + + {keyName === 'error_code' && ( + + )} + ) } const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => { const { timestamp, event_message, metadata, id, status, ...rest } = log + const path = typeof log.path === 'string' ? log.path : undefined const log_file = log?.metadata?.[0]?.log_file return ( @@ -212,7 +240,7 @@ const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => { )} {Object.entries(rest).map(([key, value]) => { - return + return })} {log?.event_message && ( diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index 06ee5653348..ef3597a06fb 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -20,7 +20,6 @@ export interface LogsWarning { linkText?: string } export interface LogsEndpointParams { - project: string // project ref iso_timestamp_start?: string iso_timestamp_end?: string sql?: string diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 1af9461d3f9..d1f9fbc2273 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -4,12 +4,12 @@ import { get, isEqual } from 'lodash' import uniqBy from 'lodash/uniqBy' import { useEffect } from 'react' +import { IS_PLATFORM } from 'common' import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator' import type { PlanId } from 'data/subscriptions/types' import logConstants from 'shared-data/logConstants' import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants' import type { Filters, LogData, LogsEndpointParams } from './Logs.types' -import { IS_PLATFORM } from 'common' /** * Convert a micro timestamp from number/string to iso timestamp diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index afc27de6f8c..bb7625b410a 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsMFAEnabled, useParams } from 'common' import { generateOtherRoutes, generateProductRoutes, @@ -18,6 +18,7 @@ import { useHideSidebar } from 'hooks/misc/useHideSidebar' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLints } from 'hooks/misc/useLints' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { Home } from 'icons' import { useAppStateSnapshot } from 'state/app-state' @@ -41,10 +42,7 @@ import { Sidebar as SidebarPrimitive, useSidebar, } from 'ui' -import { - useIsAPIDocsSidePanelEnabled, - useIsSQLEditorTabsEnabled, -} from './App/FeaturePreview/FeaturePreviewContext' +import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext' export const ICON_SIZE = 32 export const ICON_STROKE_WIDTH = 1.5 @@ -158,10 +156,12 @@ export function SideBarNavLink({ route, active, onClick, + disabled, ...props }: { route: any active?: boolean + disabled?: boolean onClick?: () => void } & ComponentPropsWithoutRef) { const [sidebarBehaviour] = useLocalStorageQuery( @@ -170,6 +170,7 @@ export function SideBarNavLink({ ) const buttonProps = { + disabled, tooltip: sidebarBehaviour === 'closed' ? route.label : '', isActive: active, className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''), @@ -188,7 +189,7 @@ export function SideBarNavLink({ return ( - {route.link ? ( + {route.link && !disabled ? ( {content} @@ -223,7 +224,7 @@ const ProjectLinks = () => { const { securityLints, errorLints } = useLints() const showWarehouse = useFlag('warehouse') - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + const showUnifiedLogs = useFlag('unifiedLogs') const activeRoute = router.pathname.split('/')[3] @@ -239,16 +240,14 @@ const ProjectLinks = () => { 'realtime:all', ]) - const toolRoutes = generateToolRoutes(ref, project, { - sqlEditorTabs: isSqlEditorTabsEnabled, - }) + const toolRoutes = generateToolRoutes(ref, project) const productRoutes = generateProductRoutes(ref, project, { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, storage: storageEnabled, realtime: realtimeEnabled, }) - const otherRoutes = generateOtherRoutes(ref, project) + const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs }) const settingsRoutes = generateSettingsRoutes(ref, project) return ( @@ -351,6 +350,10 @@ const OrganizationLinks = () => { const router = useRouter() const { slug } = useParams() + const org = useSelectedOrganization() + const isUserMFAEnabled = useIsMFAEnabled() + const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled + const activeRoute = router.pathname.split('/')[3] const navMenuItems = [ @@ -398,6 +401,7 @@ const OrganizationLinks = () => { {navMenuItems.map((item, i) => ( {lastSignIn === type && ( - - -
-
-
- - Last used - + + Last used + )}
- - - Project metadata (tables, columns, and data types) is being shared with OpenAI - - - setShowAiSettingsModal(true)} - > - Change this configuration - - - - ) -} - -export type AiMetadataSkipReason = 'forbidden' | 'no_project' - -export function ExcludeSchemaAlert({ - metadataSkipReason, -}: { - metadataSkipReason: AiMetadataSkipReason | undefined -}) { - const { setShowAiSettingsModal } = useAppStateSnapshot() - - return ( - - - - Project metadata (tables, columns, and data types) is not being shared with OpenAI - - - {metadataSkipReason === 'no_project' ? ( - 'Switch to a project to change this setting' - ) : ( - setShowAiSettingsModal(true)} - > - Change this configuration - - )} - - - ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx deleted file mode 100644 index cf30643e27a..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useIsLoggedIn } from 'common' -import { useAppStateSnapshot } from 'state/app-state' -import { AiIconAnimation } from 'ui' -import { BadgeExperimental, useRegisterCommands, useSetQuery } from 'ui-patterns/CommandMenu' -import { orderCommandSectionsByPriority } from '../App/CommandMenu/ordering' -import { COMMAND_MENU_SECTIONS } from '../App/CommandMenu/CommandMenu.utils' - -export function useGenerateSqlCommand() { - const isLoggedIn = useIsLoggedIn() - - const { setShowGenerateSqlModal } = useAppStateSnapshot() - const setQuery = useSetQuery() - - useRegisterCommands( - COMMAND_MENU_SECTIONS.QUERY, - [ - { - id: 'generate-sql-ai', - name: 'Run SQL with Supabase AI', - action: () => { - setShowGenerateSqlModal(true) - setQuery('') - }, - icon: () => , - badge: () => , - }, - ], - { - enabled: isLoggedIn, - orderSection: orderCommandSectionsByPriority, - sectionMeta: { priority: 2 }, - } - ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx deleted file mode 100644 index 137a11f4dd6..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import dynamic from 'next/dynamic' -import { memo } from 'react' - -import { useCommandMenuInitiated } from 'ui-patterns/CommandMenu' - -const LazyGenerateSql = dynamic(() => import('./SqlGeneratorImpl'), { ssr: false }) - -export const GenerateSql = memo(() => { - const isInitiated = useCommandMenuInitiated() - return isInitiated && -}) -GenerateSql.displayName = 'GenerateSql' diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts deleted file mode 100644 index 4ca094a049c..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { stripIndent } from 'common-tags' - -type QueryCategory = { - category: string - queries: string[] -} - -type SampleQueries = QueryCategory[] - -export const SAMPLE_QUERIES: SampleQueries = [ - { - category: 'Tables', - queries: [ - 'Create a table that stores a list of cities, and insert 10 rows of sample data into it', - 'Create tables (with foreign key relationships) for blog posts and comments', - 'Create tables for employees, reviewers, and employee reviews, with columns for employee ID, reviewer ID, and review text', - ], - }, - { - category: 'Views', - queries: [ - 'Create a view that shows the total revenue for each customer', - 'Create a view that shows all orders that were placed in the last week', - 'Create a view that shows all products that are currently out of stock', - 'Create a materialized view that shows the customer_orders table the total value of orders in a month', - ], - }, - { - category: 'Indexes', - queries: [ - 'Create an index on the primary key column of my orders table', - 'Create a partial index on the orders table:', - 'Create an index on the customer_id column of the customer_orders table', - ], - }, - { - category: 'Select', - queries: [ - 'Retrieve a list of employees from the employees table who have a salary greater than $50,000', - 'Retrieve a list of all employees with the title "Manager" from the employees table', - 'Retrieve a list of all employees hired in the last 6 months', - 'Retrieve the department and average salary of each department from the employees table, group by department', - ], - }, - { - category: 'Triggers', - queries: [ - 'Create a trigger that updates the updated_at column on the orders table with the current time when the row of the orders table is updated', - 'Create a trigger to add a new user to the users table when someone new registers', - 'Create a trigger to send an email whenever there is an insert in the orders table', - 'Create a trigger to delete all orders placed by a customer when that customer is deleted from the customers table', - ], - }, - { - category: 'Row Level Security', - queries: [ - 'Create an RLS policy that grants only authenticated access to the profiles table', - 'Create an RLS policy that grants SELECT access to the sales_rep role for the customers table, but denies access to all other roles', - "Create an RLS policy that grants INSERT access access to the manager role for the employees table, but only for rows where the employee's department_id matches a list of departments that the manager is responsible for", - ], - }, - { - category: 'Functions', - queries: [ - "Create a function to add a new entry to a user's table when a new user signs up", - 'Create an a function to calculate the average price of a product in a given category', - 'Create a function to insert a new order and update the inventory for the ordered products', - 'Create a function to calculate the total sales for a product given an id', - ], - }, -] - -export function generatePrompt(prompt: string, definitions?: any) { - return stripIndent` - ${ - definitions !== undefined - ? ` - Given the following Postgres SQL tables: - ${definitions} - ` - : '' - } - - Generate a Postgres SQL query based on the following natural language prompt. - - Only output valid SQL - all explanations must be SQL comments - - SQL comments should be short - - Your very last output should be "\`\`\`" - - For primary keys, always use "integer primary key generated always as identity" - - Natural language prompt: - ${prompt} - - Postgres SQL query (markdown SQL only): - `.trim() -} - -/** - * Formats a string for use as a title. - * - * Removes punctuation and capitalizes each word - */ -export function formatTitle(value: string) { - let words = value.replace(/\.$/, '').replace(/['"]/g, '').split(' ') - words = words.map((word) => { - // Don't capitalize code - if (/[._\(\)]+/.test(word)) { - return word - } - return word.charAt(0).toUpperCase() + word.slice(1) - }) - return words.join(' ') -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx deleted file mode 100644 index d7a802c8a32..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import { AlertTriangle, User } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useEffectOnce } from 'react-use' - -import { IS_PLATFORM } from 'common' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { formatSql } from 'lib/formatSql' -import { useAppStateSnapshot } from 'state/app-state' -import { - AiIconAnimation, - Button, - CodeBlock, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, - Input_Shadcn_, - Modal, - StatusIcon, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import { - BadgeExperimental, - generateCommandClassNames, - useHistoryKeys, - useQuery, - useSetQuery, -} from 'ui-patterns/CommandMenu' -import type { UseAiChatOptions } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { - AiWarning, - MessageRole, - MessageStatus, - useAiChat, -} from 'ui-patterns/CommandMenu/prepackaged/ai' -import type { AiMetadataSkipReason } from './SqlGenerator.Alerts' -import { ExcludeSchemaAlert, IncludeSchemaAlert } from './SqlGenerator.Alerts' -import { SAMPLE_QUERIES, generatePrompt } from './SqlGenerator.utils' -import { SQLOutputActions } from './SqlOutputActions' - -function useSchemaMetadataForAi() { - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const project = useSelectedProject() - - const [schemas] = useSchemasForAi(project?.ref!) - const includeMetadata = - ((isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM) && - schemas.length > 0 && - !!project - - const metadataSkipReason: AiMetadataSkipReason = !project ? 'no_project' : 'forbidden' - - const { data } = useEntityDefinitionsQuery( - { - schemas: schemas, - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: includeMetadata } - ) - - const api = useMemo( - () => - includeMetadata - ? { - includeMetadata, - data, - } - : { includeMetadata, metadataSkipReason }, - [includeMetadata, data, metadataSkipReason] - ) - - return api -} - -function useAiSqlGeneration() { - const { showGenerateSqlModal } = useAppStateSnapshot() - - const schemaMetadata = useSchemaMetadataForAi() - const definitions = useMemo( - () => (schemaMetadata.data ?? []).map((def) => def.sql.trim()).join('\n\n'), - [schemaMetadata] - ) - - const query = useQuery() - const setQuery = useSetQuery() - const [isLoading, setIsLoading] = useState(false) - - const messageTemplate = useCallback>( - (message) => generatePrompt(message, schemaMetadata.includeMetadata ? definitions : undefined), - [schemaMetadata, definitions] - ) - const { submit, reset, messages, isResponding, hasError } = useAiChat({ - messageTemplate, - setIsLoading, - }) - - useHistoryKeys({ - enable: showGenerateSqlModal && !isResponding, - stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content), - }) - - const handleSubmit = useCallback( - (message: string) => { - setQuery('') - submit(message) - }, - [setQuery, submit] - ) - - const handleReset = useCallback(() => { - setQuery('') - reset() - }, [setQuery, reset]) - - useEffectOnce(() => { - if (query) handleSubmit(query) - }) - - return { - query, - setQuery, - isLoading, - isResponding, - hasError, - messages, - handleSubmit, - handleReset, - usesMetadata: schemaMetadata.includeMetadata, - metadataSkipReason: schemaMetadata.metadataSkipReason, - } -} - -export default function SqlGeneratorImpl() { - const { showGenerateSqlModal, setShowGenerateSqlModal } = useAppStateSnapshot() - const inputRef = useRef(null) - - const timeoutHandle = useRef>() - useEffect(() => () => clearTimeout(timeoutHandle.current), []) - - const { - query, - setQuery, - isLoading, - isResponding, - hasError, - messages, - handleSubmit, - handleReset, - usesMetadata, - metadataSkipReason, - } = useAiSqlGeneration() - - return ( - - Generate SQL - - - } - hideClose - hideFooter - visible={showGenerateSqlModal} - onOpenAutoFocus={() => { - timeoutHandle.current = setTimeout(() => inputRef.current?.focus()) - }} - onCancel={() => { - if (messages.length > 0) { - handleReset() - timeoutHandle.current = setTimeout(() => inputRef.current?.focus()) - } else { - setShowGenerateSqlModal(false) - } - }} - className="w-11/12 !max-w-3xl h-4/5 max-h-[800px] grid-rows-[min-content,_1fr] bg-overlay" - > - -
- {messages.length > 0 && } - {messages.length === 0 && !hasError && ( - - )} - {hasError && } -
-
- {usesMetadata ? ( - - ) : ( - - )} - { - if (!isLoading || !isResponding) { - setQuery(e.target.value) - } - }} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!query || isLoading || isResponding) { - return - } - return handleSubmit(query) - default: - return - } - }} - /> -
-
-
- ) -} - -function Messages({ - messages, - handleReset, -}: { - messages: ReturnType['messages'] - handleReset: () => void -}) { - const X_PADDING = 'px-4' - - const UserAvatar = useCallback(() => { - return ( -
- -
- ) - }, []) - - return messages.map((message, i) => { - switch (message.role) { - case MessageRole.User: - return ( -
- -
{message.content}
-
- ) - case MessageRole.Assistant: - const unformattedAnswer = message.content - .replace(/```sql/g, '') - .replace(/```.*/gs, '') - .replace(/-- End of SQL query\.*/g, '') - .trim() - - const answer = - message.status === MessageStatus.Complete - ? formatSql(unformattedAnswer) - : unformattedAnswer - const cantHelp = answer.replace(/^-- /, '') === "Sorry, I don't know how to help with that." - - return ( -
-
- - {message.status === MessageStatus.Pending ? ( -
- ) : cantHelp ? ( -
- -

- Sorry, I don't know how to help with that. -

- -
- ) : ( -
- - {answer} - - - {message.status === MessageStatus.Complete && ( - - )} -
- )} -
-
- ) - } - }) -} - -function EmptyState({ - query, - handleSubmit, -}: { - query: string - handleSubmit: (query: string) => void -}) { - const [activeTab, setActiveTab] = useState(SAMPLE_QUERIES[0].category) - - useEffect(() => { - function handleSwitchTab(event: KeyboardEvent) { - if (query || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) { - return - } - - const activeSampleIndex = SAMPLE_QUERIES.findIndex( - (samples) => samples.category === activeTab - )! - const nextIndex = - event.key === 'ArrowRight' - ? Math.min(activeSampleIndex + 1, SAMPLE_QUERIES.length - 1) - : Math.max(0, activeSampleIndex - 1) - setActiveTab(SAMPLE_QUERIES[nextIndex].category) - } - - document.addEventListener('keydown', handleSwitchTab) - return () => document.removeEventListener('keydown', handleSwitchTab) - }, [query, activeTab]) - - return ( - <> -

- Describe what you need and Supabase AI will try to generate the relevant SQL statements -

-

- Here are some example prompts to try out: -

-
- setActiveTab(value)} - className="focus-visible:ring-0" - > - - {SAMPLE_QUERIES.map((samples) => ( - {samples.category} - ))} - - - {SAMPLE_QUERIES.map((samples) => ( - - {samples.queries.map((sampleQuery) => ( - { - if (!query) { - handleSubmit(sampleQuery) - } - }} - > - - {sampleQuery} - - ))} - - ))} - - - - ) -} - -function ErrorMessage({ handleReset }: { handleReset: () => void }) { - return ( -
- -

- Sorry, looks like Supabase AI is having a hard time! -

-

Please try again in a bit.

- -
- ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx deleted file mode 100644 index 7a280bd6ae4..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { codeBlock, stripIndent } from 'common-tags' -import { Check, Clipboard, Save } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' -import CopyToClipboard from 'react-copy-to-clipboard' -import { toast } from 'sonner' - -import { useParams } from 'common' -import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { uuidv4 } from 'lib/helpers' -import { useProfile } from 'lib/profile' -import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { Button, cn } from 'ui' -import type { Message } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { MessageRole, MessageStatus, queryAi } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { formatTitle } from './SqlGenerator.utils' - -const useSaveGeneratedSql = () => { - const { ref } = useParams() - const { profile } = useProfile() - const selectedProject = useSelectedProject() - const snapV2 = useSqlEditorV2StateSnapshot() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) - - const saveGeneratedSql = useCallback( - (answer: string, title: string) => { - if (!ref) return console.error('Project ref is required') - if (!profile) return console.error('Profile is required') - if (!selectedProject) return console.error('Project is required') - - if (!canCreateSQLSnippet) { - toast('Unable to save query as you do not have sufficient permissions for this project') - return - } - - // Remove markdown syntax from returned answer - answer = answer.replace(/`/g, '').replace(/sql\n/g, '').trim() - - const formattedSql = codeBlock` - -- Note: This query was generated via Supabase AI, please verify the correctness of the - -- SQL snippet before running it against your database as we are not able to guarantee it - -- will do exactly what you requested the AI. - ${answer} - ` - - try { - const snippet = createSqlSnippetSkeletonV2({ - id: uuidv4(), - name: title || 'Generated query', - sql: formattedSql, - owner_id: profile.id, - project_id: selectedProject.id, - }) - snapV2.addSnippet({ projectRef: ref, snippet }) - toast.success(`Successfully saved snippet!`) - } catch (error: any) { - toast.error(`Failed to create new query: ${error.message}`) - } - }, - [canCreateSQLSnippet, profile?.id, ref, selectedProject?.id, snapV2] - ) - - return saveGeneratedSql -} - -export interface SQLOutputActionsProps { - answer: string - messages: Message[] - className?: string -} - -export function SQLOutputActions({ answer, messages, className }: SQLOutputActionsProps) { - const { ref } = useParams() - const saveGeneratedSql = useSaveGeneratedSql() - - const [showCopied, setShowCopied] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [isSaved, setIsSaved] = useState(false) - - const onSelectSaveSnippet = async () => { - setIsSaving(true) - - let suggestedTitle - try { - suggestedTitle = await queryAi( - [ - ...messages, - { - role: MessageRole.User, - content: stripIndent` - Generate a title for the above SQL snippet following all of these rules: - - The title is only for the last SQL snippet - - Focus on the main purposes of this snippet - - Use as few words as possible - - Title should be nouns, not verbs - - Do not include word articles (eg. a, the, for, of) - - Do not use words like "SQL" or "snippet" or "title" - - Do not output markdown, quotes, etc - - Do not be too verbose - `, - status: MessageStatus.Complete, - }, - ], - 10000 - ) - } catch (error) { - suggestedTitle = '' - } - - const formattedTitle = formatTitle(suggestedTitle) - await saveGeneratedSql(answer, formattedTitle) - setIsSaved(true) - setIsSaving(false) - } - - useEffect(() => { - if (!showCopied) return - const timer = setTimeout(() => setShowCopied(false), 2000) - return () => clearTimeout(timer) - }, [showCopied]) - - useEffect(() => { - if (!isSaved) return - const timer = setTimeout(() => setIsSaved(false), 2000) - return () => clearTimeout(timer) - }, [isSaved]) - - return ( -
- - - - {ref !== undefined && ( - - )} -
- ) -} diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index f0c41e6e951..552e59f16e8 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -13,7 +13,8 @@ import { import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { IS_PLATFORM } from 'lib/constants' -import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Admonition } from 'ui-patterns' export interface CreateBucketModalProps { visible: boolean @@ -115,29 +116,36 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { onSubmit={onSubmit} > {({ values }: { values: any }) => { + const isPublicBucket = values.public + return ( <> - + -
+
- {values.public && ( - + {isPublicBucket && ( +

Users can read objects in public buckets without any authorization.

@@ -145,7 +153,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { Row level security (RLS) policies are still required for other operations such as object uploads and deletes.

-
+ )}
diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 53815b39e63..aff15a75ef7 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -3,16 +3,18 @@ import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +import { InlineLink } from 'components/ui/InlineLink' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation' import { IS_PLATFORM } from 'lib/constants' +import { Admonition } from 'ui-patterns' import type { StorageBucket } from './Storage.types' export interface EditBucketModalProps { @@ -84,6 +86,10 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => >
{({ values, resetForm }: { values: any; resetForm: any }) => { + const isChangingBucketVisibility = bucket?.public !== values.public + const isMakingBucketPrivate = bucket?.public && !values.public + const isMakingBucketPublic = !bucket?.public && values.public + // [Alaister] although this "technically" is breaking the rules of React hooks // it won't error because the hooks are always rendered in the same order // eslint-disable-next-line react-hooks/rules-of-hooks @@ -106,45 +112,56 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => return ( <> - + -
+
- {bucket?.public !== values.public && ( - -

- {!bucket?.public && values.public - ? `This will make all objects in the bucket "${bucket?.name}" public` - : bucket?.public && !values.public - ? `All objects in "${bucket?.name}" will be made private and will only be accessible via signed URLs or downloaded with the right authorisation headers` +

+ {isMakingBucketPublic + ? `This will make all objects in your bucket publicly accessible.` + : isMakingBucketPrivate + ? `All objects in your bucket will be private and only accessible via signed URLs, or downloaded with the right authorisation headers.` : ''}

-
+ {isMakingBucketPrivate && ( +

+ Assets cached in the CDN may still be publicly accessible. You can + consider{' '} + + purging the cache + {' '} + or moving your assets to a new bucket. +

+ )} + )}
diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx deleted file mode 100644 index 0fbf052477c..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import { useTableEditorStateSnapshot } from 'state/table-editor' - -export interface EmptyStateProps {} - -const EmptyState = ({}: EmptyStateProps) => { - const snap = useTableEditorStateSnapshot() - const { selectedSchema } = useQuerySchemaState() - const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema) - const canCreateTables = - useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema - - const [sort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( - 'table-editor-sort', - 'alphabetical' - ) - - const { project } = useProjectContext() - const { data } = useEntityTypesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schemas: [selectedSchema], - sort, - }) - - const totalCount = data?.pages?.[0].data.count ?? 0 - - return ( -
- {totalCount === 0 ? ( - -

- There are no tables available in this schema. -

-
- ) : ( -
- -

- Select a table from the navigation panel on the left to view its data - {canCreateTables && ', or create a new one.'} -

-
-
- )} -
- ) -} - -export default EmptyState diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 2bfb205ed15..debbd89ef68 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -5,7 +5,6 @@ import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' @@ -21,6 +20,7 @@ import { tableRowKeys } from 'data/table-rows/keys' import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' import { tableKeys } from 'data/tables/keys' +import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -50,7 +50,6 @@ import { import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' import TableEditor from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' -import { RetrieveTableResult } from 'data/tables/table-retrieve-query' export interface SidePanelEditorProps { editable?: boolean @@ -71,7 +70,6 @@ const SidePanelEditor = ({ const { ref } = useParams() const snap = useTableEditorStateSnapshot() const tabsSnap = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) const queryClient = useQueryClient() @@ -493,7 +491,7 @@ const SidePanelEditor = ({ `Table ${table.name} has been updated but there were some errors. Please check these errors separately.` ) } else { - if (isTableEditorTabsEnabled && ref && payload.name) { + if (ref && payload.name) { // [Joshen] Only table entities can be updated via the dashboard const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) tabsSnap.updateTab(tabId, { label: payload.name }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 08ec744a22f..a29a158fc05 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { isUndefined } from 'lodash' +import Link from 'next/link' import { useRouter } from 'next/router' import { useCallback } from 'react' @@ -18,13 +18,11 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import Link from 'next/link' import { useAppStateSnapshot } from 'state/app-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' -import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import DeleteConfirmationDialogs from './DeleteConfirmationDialogs' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' import TableDefinition from './TableDefinition' @@ -44,7 +42,6 @@ export const TableGridEditor = ({ const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ @@ -65,9 +62,9 @@ export const TableGridEditor = ({ connectionString: project?.connectionString, }) - const onClearDashboardHistory = () => { + const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) - } + }, [appSnap, projectRef]) const onTableCreated = useCallback( (table: { id: number }) => { @@ -78,19 +75,12 @@ export const TableGridEditor = ({ const onTableDeleted = useCallback(async () => { // For simplicity for now, we just open the first table within the same schema - if (isTableEditorTabsEnabled && selectedTable) { + if (selectedTable) { // Close tab const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) tabs.handleTabClose({ id: tabId, router, editor: 'table', onClearDashboardHistory }) - } else { - const tables = await getTables(selectedSchema) - if (tables.length > 0) { - router.push(`/project/${projectRef}/editor/${tables[0].id}`) - } else { - router.push(`/project/${projectRef}/editor`) - } } - }, [getTables, isTableEditorTabsEnabled, projectRef, router, selectedSchema]) + }, [onClearDashboardHistory, router, selectedTable, tabs]) // NOTE: DO NOT PUT HOOKS AFTER THIS LINE if (isLoadingSelectedTable || !projectRef) { @@ -105,7 +95,7 @@ export const TableGridEditor = ({ ) } - if (isUndefined(selectedTable)) { + if (!selectedTable) { return (
@@ -114,45 +104,41 @@ export const TableGridEditor = ({ title={`Unable to find your table with ID ${id}`} description="This table doesn't exist in your database" > - {isTableEditorTabsEnabled && ( - <> - {!!tabId ? ( - - ) : openTabs.length > 0 ? ( - - ) : ( - - )} - + {!!tabId ? ( + + ) : openTabs.length > 0 ? ( + + ) : ( + )}
diff --git a/apps/studio/components/interfaces/TableGridEditor/index.ts b/apps/studio/components/interfaces/TableGridEditor/index.ts index 50e1839b986..52936211e7e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/index.ts +++ b/apps/studio/components/interfaces/TableGridEditor/index.ts @@ -1,2 +1 @@ -export { default as EmptyState } from './EmptyState' export { default as SidePanelEditor } from './SidePanelEditor/SidePanelEditor' diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx new file mode 100644 index 00000000000..2eeaf8a7bff --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -0,0 +1,90 @@ +import { + createParser, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringLiteral, + parseAsTimestamp, +} from 'nuqs' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, + SLIDER_DELIMITER, + SORT_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { ChartConfig } from 'ui' +import { TooltipLabel } from './components/TooltipLabel' + +export const CHART_CONFIG = { + success: { + label: , + color: 'hsl(var(--foreground-muted))', + }, + warning: { + label: , + color: 'hsl(var(--warning-default))', + }, + error: { + label: , + color: 'hsl(var(--destructive-default))', + }, +} satisfies ChartConfig + +export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const +export const LOG_TYPES = [ + 'auth', + 'edge', + 'edge_function', + 'function_events', + 'postgres', + 'postgres_upgrade', + 'postgrest', + 'storage', + 'supavisor', +] as const + +const parseAsSort = createParser({ + parse(queryValue) { + const [id, desc] = queryValue.split(SORT_DELIMITER) + if (!id && !desc) return null + return { id, desc: desc === 'desc' } + }, + serialize(value) { + return `${value.id}.${value.desc ? 'desc' : 'asc'}` + }, +}) + +export const SEARCH_PARAMS_PARSER = { + // CUSTOM FILTERS + level: parseAsArrayOf(parseAsStringLiteral(LEVELS), ARRAY_DELIMITER), + log_type: parseAsArrayOf(parseAsString, ARRAY_DELIMITER), + latency: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.dns': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.connection': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.tls': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.ttfb': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.transfer': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + status: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + regions: parseAsArrayOf(parseAsStringLiteral(REGIONS), ARRAY_DELIMITER), + method: parseAsArrayOf(parseAsStringLiteral(METHODS), ARRAY_DELIMITER), + host: parseAsString, + pathname: parseAsString, + date: parseAsArrayOf(parseAsTimestamp, RANGE_DELIMITER), + + // REQUIRED FOR SORTING & PAGINATION + sort: parseAsSort, + size: parseAsInteger.withDefault(40), + start: parseAsInteger.withDefault(0), + + // REQUIRED FOR INFINITE SCROLLING (Live Mode and Load More) + direction: parseAsStringLiteral(['prev', 'next']).withDefault('next'), + cursor: parseAsTimestamp.withDefault(new Date()), + live: parseAsBoolean.withDefault(false), + + // REQUIRED FOR SELECTION + id: parseAsString, +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx new file mode 100644 index 00000000000..48cea7695ce --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -0,0 +1,185 @@ +import { format } from 'date-fns' +import { User } from 'lucide-react' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types' +import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils' +import { cn } from 'ui' +import { LOG_TYPES, METHODS } from './UnifiedLogs.constants' +import { ColumnSchema } from './UnifiedLogs.schema' +import { LogsMeta, SheetField } from './UnifiedLogs.types' +import { getLevelLabel } from './UnifiedLogs.utils' + +// instead of filterFields, maybe just 'fields' with a filterDisabled prop? +// that way, we could have 'message' or 'headers' field with label and value as well as type! +export const filterFields = [ + { + label: 'Time Range', + value: 'date', + type: 'timerange', + defaultOpen: true, + commandDisabled: true, + }, + { + label: 'Level', + value: 'level', + type: 'checkbox', + defaultOpen: true, + options: LEVELS.map((level) => ({ label: level, value: level })), + component: (props: Option) => { + // TODO: type `Option` with `options` values via Generics + const value = props.value as (typeof LEVELS)[number] + return ( +
+ + {props.label} + +
+
+ {getLevelLabel(value)} +
+
+ ) + }, + }, + { + label: 'Log Type', + value: 'log_type', + type: 'checkbox', + defaultOpen: true, + options: LOG_TYPES.map((type) => ({ label: type, value: type })), + component: (props: Option) => { + return ( +
+ + {props.label.replace('_', ' ')} + + {/* [Joshen] Temporarily hiding, this feels excessive */} + {/* {props.value} */} +
+ ) + }, + }, + { + label: 'Host', + value: 'host', + type: 'input', + }, + { + label: 'Pathname', + value: 'pathname', + type: 'input', + }, + { + label: 'Auth User', + value: 'auth_user', + type: 'input', + }, + { + label: 'Status Code', + value: 'status', + type: 'checkbox', + options: [ + { label: '200', value: 200 }, + { label: '400', value: 400 }, + { label: '404', value: 404 }, + { label: '500', value: 500 }, + ], // REMINDER: this is a placeholder to set the type in the client.tsx + component: (props: Option) => { + if (typeof props.value === 'boolean') return null + if (typeof props.value === 'undefined') return null + if (typeof props.value === 'string') return null + return ( + {props.value} + ) + }, + }, + { + label: 'Method', + value: 'method', + type: 'checkbox', + options: METHODS.map((region) => ({ label: region, value: region })), + component: (props: Option) => { + return {props.value} + }, + }, +] satisfies DataTableFilterField[] + +export const sheetFields = [ + { + id: 'id', + label: 'Request ID', + type: 'readonly', + skeletonClassName: 'w-64', + }, + { + id: 'date', + label: 'Date', + type: 'timerange', + component: (props) => { + const date = new Date(props.date) + const month = format(date, 'LLL') + const day = format(date, 'dd') + const year = format(date, 'y') + const time = format(date, 'HH:mm:ss') + + return ( +
+ {month} + · + {day} + · + {year} + · + {time} +
+ ) + }, + skeletonClassName: 'w-36', + }, + { + id: 'auth_user', + label: 'Auth User', + type: 'readonly', + condition: (props) => Boolean(props.auth_user), + component: (props) => ( +
+ + {props.auth_user} +
+ ), + skeletonClassName: 'w-56', + }, + { + id: 'status', + label: 'Status', + type: 'checkbox', + component: (props) => { + return ( + {props.status} + ) + }, + skeletonClassName: 'w-12', + }, + { + id: 'method', + label: 'Method', + type: 'checkbox', + component: (props) => { + return {props.method} + }, + skeletonClassName: 'w-10', + }, + { + id: 'host', + label: 'Host', + type: 'input', + skeletonClassName: 'w-24', + }, + { + id: 'pathname', + label: 'Pathname', + type: 'input', + skeletonClassName: 'w-56', + }, +] satisfies SheetField[] diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts new file mode 100644 index 00000000000..1661458a027 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts @@ -0,0 +1,44 @@ +import { useQueryState } from 'nuqs' +import { useEffect, useMemo, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +export const useResetFocus = () => { + useHotKey(() => { + // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) + // REMINDER: we cannot just document.activeElement?.blur(); as the next tab will focus the next element in line, + // which is not what we want. We want to reset entirely. + document.body.setAttribute('tabindex', '0') + document.body.focus() + document.body.removeAttribute('tabindex') + }, '.') +} + +export const useLiveMode = (data: TData[]) => { + const [live] = useQueryState('live', SEARCH_PARAMS_PARSER.live) + // REMINDER: used to capture the live mode on timestamp + const liveTimestamp = useRef(live ? new Date().getTime() : undefined) + + useEffect(() => { + if (live) liveTimestamp.current = new Date().getTime() + else liveTimestamp.current = undefined + }, [live]) + + const anchorRow = useMemo(() => { + if (!live) return undefined + + const item = data.find((item) => { + // return first item that is there if not liveTimestamp + if (!liveTimestamp.current) return true + // return first item that is after the liveTimestamp + if (item.date.getTime() > liveTimestamp.current) return false + return true + // return first item if no liveTimestamp + }) + + return item + }, [live, data]) + + return { row: anchorRow, timestamp: liveTimestamp.current } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts new file mode 100644 index 00000000000..3d0d377fc77 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -0,0 +1,607 @@ +import dayjs from 'dayjs' + +import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types' + +// Pagination and control parameters +const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const + +// Special filter parameters that need custom handling +const SPECIAL_FILTER_PARAMS = ['date'] as const + +// Combined list of all parameters to exclude from standard filtering +const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const +const BASE_CONDITIONS_EXCLUDED_PARAMS = [...PAGINATION_PARAMS, 'date', 'level'] as const + +/** + * Builds query conditions from search parameters and returns WHERE clause + * @param search SearchParamsType object containing query parameters + * @returns Object with whereConditions array and formatted WHERE clause + */ +const buildQueryConditions = (search: QuerySearchParamsType) => { + const whereConditions: string[] = [] + + // Process all search parameters for filtering + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters + if (EXCLUDED_QUERY_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + whereConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`) + return + } + + // Handle scalar values + if (value !== null && value !== undefined) { + if (['host', 'pathname'].includes(key)) { + whereConditions.push(`${key} LIKE '%${value}%'`) + } else { + whereConditions.push(`${key} = '${value}'`) + } + } + }) + + // Create final WHERE clause + const finalWhere = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '' + + return { whereConditions, finalWhere } +} + +/** + * Builds level-specific condition for different log types + */ +const buildLevelConditions = (logType: string, levelFilter: string[]) => { + const conditions = [] + + switch (logType) { + case 'edge': + if (levelFilter.includes('success')) + conditions.push('edge_logs_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('edge_logs_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('edge_logs_response.status_code >= 500') + break + case 'postgres': + if (levelFilter.includes('success')) conditions.push("pgl_parsed.error_severity = 'LOG'") + if (levelFilter.includes('warning')) conditions.push("pgl_parsed.error_severity = 'WARNING'") + if (levelFilter.includes('error')) conditions.push("pgl_parsed.error_severity = 'ERROR'") + break + case 'edge function': + if (levelFilter.includes('success')) + conditions.push('fel_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('fel_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('fel_response.status_code >= 500') + break + case 'auth': + if (levelFilter.includes('success')) + conditions.push('el_in_al_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('el_in_al_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('el_in_al_response.status_code >= 500') + break + case 'supavisor': + if (levelFilter.includes('success')) + conditions.push("LOWER(svl_metadata.level) NOT IN ('error', 'warn', 'warning')") + if (levelFilter.includes('warning')) + conditions.push( + "(LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning')" + ) + if (levelFilter.includes('error')) conditions.push("LOWER(svl_metadata.level) = 'error'") + break + } + + return conditions +} + +/** + * Creates WHERE clause for a specific log type including level filtering + */ +const createFilterWhereClause = ( + logType: string, + levelFilter: string[], + baseConditions: string[] +) => { + const hasLevelFilter = levelFilter.length > 0 + + let where = '' + + if (hasLevelFilter) { + const levelConditions = buildLevelConditions(logType, levelFilter) + + if (levelConditions.length > 0) { + if (baseConditions.length > 0) { + where = `WHERE (${levelConditions.join(' OR ')}) AND ${baseConditions.join(' AND ')}` + } else { + where = `WHERE (${levelConditions.join(' OR ')})` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + + // Special case for auth logs + if (logType === 'auth') { + if (where) { + where = where.replace('WHERE', 'WHERE al_metadata.request_id is not null AND') + } else { + where = 'WHERE al_metadata.request_id is not null' + } + } + + return where +} + +/** + * Builds base conditions array from search params + */ +const buildBaseConditions = (search: SearchParamsType): string[] => { + const baseConditions: string[] = [] + + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters, date and level (handled separately) + if (BASE_CONDITIONS_EXCLUDED_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + baseConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(', ')})`) + } + // Handle scalar values + else if (value !== null && value !== undefined) { + baseConditions.push(`${key} = '${value}'`) + } + }) + + return baseConditions +} + +/** + * Calculates how much the chart start datetime should be offset given the current datetime filter params + * and determines the appropriate bucketing level (minute, hour, day) + * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) + */ +const calculateChartBucketing = (search: SearchParamsType | Record): string => { + // Extract start and end times from the date array if available + const dateRange = search.date || [] + + // Handle timestamps that could be in various formats + const convertToMillis = (timestamp: any) => { + if (!timestamp) return null + // If timestamp is a Date object + if (timestamp instanceof Date) return timestamp.getTime() + + // If timestamp is a string that needs parsing + if (typeof timestamp === 'string') return dayjs(timestamp).valueOf() + + // If timestamp is already a number (unix timestamp) + // Check if microseconds (16 digits) and convert to milliseconds + if (typeof timestamp === 'number') { + const str = timestamp.toString() + if (str.length >= 16) return Math.floor(timestamp / 1000) + return timestamp + } + + return null + } + + let startMillis = convertToMillis(dateRange[0]) + let endMillis = convertToMillis(dateRange[1]) + + // Default values if not set + if (!startMillis) startMillis = dayjs().subtract(1, 'hour').valueOf() + if (!endMillis) endMillis = dayjs().valueOf() + + const startTime = dayjs(startMillis) + const endTime = dayjs(endMillis) + + let truncationLevel = 'MINUTE' + + const minuteDiff = endTime.diff(startTime, 'minute') + const hourDiff = endTime.diff(startTime, 'hour') + const dayDiff = endTime.diff(startTime, 'day') + + console.log(`Time difference: ${minuteDiff} minutes, ${hourDiff} hours, ${dayDiff} days`) + + // Adjust bucketing based on time range + if (dayDiff >= 2) { + truncationLevel = 'DAY' + } else if (hourDiff >= 12) { + truncationLevel = 'HOUR' + } else { + truncationLevel = 'MINUTE' + } + + return truncationLevel +} + +/** + * Edge logs query fragment + * + * excludes `/rest/` in the path + */ +const getEdgeLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'edge' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as pathname, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path does not include /rest/ + WHERE edge_logs_request.path NOT LIKE '%/rest/%' + AND edge_logs_request.path NOT LIKE '%/storage/%' + + ` +} + +// Postgrest logs + +// WHERE pathname includes `/rest/` +const getPostgrestLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'postgrest' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as pathname, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path includes /rest/ + WHERE edge_logs_request.path LIKE '%/rest/%' + ` +} + +/** + * Postgres logs query fragment + */ +const getPostgresLogsQuery = () => { + return ` + select + id, + pgl.timestamp as timestamp, + 'postgres' as log_type, + pgl_parsed.sql_state_code as status, + CASE + WHEN pgl_parsed.error_severity = 'LOG' THEN 'success' + WHEN pgl_parsed.error_severity = 'WARNING' THEN 'warning' + WHEN pgl_parsed.error_severity = 'ERROR' THEN 'error' + ELSE null + END as level, + null as pathname, + null as host, + event_message as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from postgres_logs as pgl + cross join unnest(pgl.metadata) as pgl_metadata + cross join unnest(pgl_metadata.parsed) as pgl_parsed + ` +} + +/** + * Edge function logs query fragment + */ +const getEdgeFunctionLogsQuery = () => { + return ` + select + id, + fel.timestamp as timestamp, + 'edge function' as log_type, + CAST(fel_response.status_code AS STRING) as status, + CASE + WHEN fel_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN fel_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN fel_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + fel_request.url as pathname, + fel_request.host as host, + COALESCE(function_logs_agg.last_event_message, '') as event_message, + fel_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + function_logs_agg.function_log_count as log_count, + function_logs_agg.logs as logs + from function_edge_logs as fel + cross join unnest(metadata) as fel_metadata + cross join unnest(fel_metadata.response) as fel_response + cross join unnest(fel_metadata.request) as fel_request + left join unnest(fel_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + left join ( + SELECT + fl_metadata.execution_id, + COUNT(fl.id) as function_log_count, + ANY_VALUE(fl.event_message) as last_event_message, + ARRAY_AGG(STRUCT(fl.id, fl.timestamp, fl.event_message, fl_metadata.level, fl_metadata.event_type)) as logs + FROM function_logs as fl + CROSS JOIN UNNEST(fl.metadata) as fl_metadata + WHERE fl_metadata.execution_id IS NOT NULL + GROUP BY fl_metadata.execution_id + ) as function_logs_agg on fel_metadata.execution_id = function_logs_agg.execution_id + ` +} + +/** + * Auth logs query fragment + */ +const getAuthLogsQuery = () => { + return ` + select + al.id as id, + el_in_al.timestamp as timestamp, + 'auth' as log_type, + CAST(el_in_al_response.status_code AS STRING) as status, + CASE + WHEN el_in_al_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN el_in_al_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN el_in_al_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + el_in_al_request.path as pathname, + el_in_al_request.host as host, + null as event_message, + el_in_al_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from auth_logs as al + cross join unnest(metadata) as al_metadata + left join ( + edge_logs as el_in_al + cross join unnest (metadata) as el_in_al_metadata + cross join unnest (el_in_al_metadata.response) as el_in_al_response + cross join unnest (el_in_al_response.headers) as el_in_al_response_headers + cross join unnest (el_in_al_metadata.request) as el_in_al_request + left join unnest(el_in_al_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + ) + on al_metadata.request_id = el_in_al_response_headers.cf_ray + WHERE al_metadata.request_id is not null + ` +} + +/** + * Supavisor logs query fragment + */ +const getSupavisorLogsQuery = () => { + return ` + select + id, + svl.timestamp as timestamp, + 'supavisor' as log_type, + 'undefined' as status, + CASE + WHEN LOWER(svl_metadata.level) = 'error' THEN 'error' + WHEN LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning' THEN 'warning' + ELSE 'success' + END as level, + null as pathname, + null as host, + null as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from supavisor_logs as svl + cross join unnest(metadata) as svl_metadata + ` +} + +// WHERE pathname includes `/storage/` +const getSupabaseStorageLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'storage' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as path, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + -- ONLY include logs where the path includes /storage/ + WHERE edge_logs_request.path LIKE '%/storage/%' + ` +} + +/** + * Combine all log sources to create the unified logs CTE + */ +const getUnifiedLogsCTE = () => { + return ` +WITH unified_logs AS ( + ${getEdgeLogsQuery()} + union all + ${getPostgrestLogsQuery()} + union all + ${getPostgresLogsQuery()} + union all + ${getEdgeFunctionLogsQuery()} + union all + ${getAuthLogsQuery()} + union all + ${getSupavisorLogsQuery()} + union all + ${getSupabaseStorageLogsQuery()} +) + ` +} + +/** + * Unified logs SQL query + */ +export const getUnifiedLogsQuery = (search: QuerySearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // The unified SQL query with UNION ALL statements + const sql = ` +${getUnifiedLogsCTE()} +SELECT + id, + timestamp, + log_type, + status, + level, + pathname, + host, + event_message, + method, + api_role, + auth_user, + log_count, + logs +FROM unified_logs +${finalWhere} +` + + return sql +} + +/** + * Get a count query for the total logs within the timeframe + * Also returns facets for all filter dimensions + */ +export const getLogsCountQuery = (search: QuerySearchParamsType): string => { + const { finalWhere } = buildQueryConditions(search) + const methodWhere = + finalWhere.length > 0 ? `${finalWhere} AND method is NOT NULL` : 'WHERE method IS NOT NULL' + + // Create a count query using the same unified logs CTE + const sql = ` +${getUnifiedLogsCTE()} +-- Get total count +SELECT 'total' as dimension, 'all' as value, COUNT(*) as count +FROM unified_logs +${finalWhere} + +UNION ALL + +-- Get counts by level +SELECT 'level' as dimension, level as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY level + +UNION ALL + +-- Get counts by log_type +SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY log_type + +UNION ALL + +-- Get counts by method +SELECT 'method' as dimension, method as value, COUNT(*) as count +FROM unified_logs +${methodWhere} +GROUP BY method +` + + return sql +} + +/** + * Enhanced logs chart query with dynamic bucketing based on time range + * Incorporates dynamic bucketing from the older implementation + */ +export const getLogsChartQuery = (search: QuerySearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // Determine appropriate bucketing level based on time range + const truncationLevel = calculateChartBucketing(search) + + return ` +${getUnifiedLogsCTE()} +SELECT + TIMESTAMP_TRUNC(timestamp, ${truncationLevel}) as time_bucket, + COUNTIF(level = 'success') as success, + COUNTIF(level = 'warning') as warning, + COUNTIF(level = 'error') as error, + COUNT(*) as total_per_bucket +FROM unified_logs +${finalWhere} +GROUP BY time_bucket +ORDER BY time_bucket ASC +` +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts new file mode 100644 index 00000000000..a6037e1270b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { LOG_TYPES, METHODS, REGIONS } from './UnifiedLogs.constants' + +export const columnSchema = z.object({ + id: z.string(), + log_type: z.enum(LOG_TYPES), + method: z.enum(METHODS), + host: z.string(), + pathname: z.string(), + level: z.enum(LEVELS), + status: z.number(), + date: z.date(), + timestamp: z.number(), + event_message: z.string().optional(), + log_count: z.number().optional(), // used to count function logs for a given execution_id + logs: z.array(z.any()).optional(), // array of function logs + auth_user: z.string().optional(), +}) + +export type ColumnSchema = z.infer + +export const columnFilterSchema = z.object({ + level: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(LEVELS).array()) + .optional(), + method: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(METHODS).array()) + .optional(), + host: z.string().optional(), + pathname: z.string().optional(), + status: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.coerce.number().array()) + .optional(), + regions: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(REGIONS).array()) + .optional(), + date: z + .string() + .transform((val) => val.split(RANGE_DELIMITER).map(Number)) + .pipe(z.coerce.date().array()) + .optional(), + auth_user: z.string().optional(), +}) + +export type ColumnFilterSchema = z.infer + +export const facetMetadataSchema = z.object({ + rows: z.array(z.object({ value: z.any(), total: z.number() })), + total: z.number(), + min: z.number().optional(), + max: z.number().optional(), +}) + +export type FacetMetadataSchema = z.infer + +export type BaseChartSchema = { timestamp: number; [key: string]: number } + +export const timelineChartSchema = z.object({ + timestamp: z.number(), // UNIX + ...LEVELS.reduce( + (acc, level) => ({ + ...acc, + [level]: z.number().default(0), + }), + {} as Record<(typeof LEVELS)[number], z.ZodNumber> + ), + // REMINDER: make sure to have the `timestamp` field in the object +}) satisfies z.ZodType + +export type TimelineChartSchema = z.infer diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx new file mode 100644 index 00000000000..f4bd6be13d1 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -0,0 +1,414 @@ +import { + ColumnFiltersState, + getCoreRowModel, + getFacetedRowModel, + getFilteredRowModel, + getSortedRowModel, + getFacetedMinMaxValues as getTTableFacetedMinMaxValues, + getFacetedUniqueValues as getTTableFacetedUniqueValues, + Row, + RowSelectionState, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table' +import { useQueryStates } from 'nuqs' +import { useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { arrSome, inDateRange } from 'components/ui/DataTable/DataTable.utils' +import { DataTableFilterCommand } from 'components/ui/DataTable/DataTableFilters/DataTableFilterCommand' +import { DataTableHeaderLayout } from 'components/ui/DataTable/DataTableHeaderLayout' +import { DataTableInfinite } from 'components/ui/DataTable/DataTableInfinite' +import { DataTableSheetDetails } from 'components/ui/DataTable/DataTableSheetDetails' +import { DataTableSideBarLayout } from 'components/ui/DataTable/DataTableSideBarLayout' +import { DataTableToolbar } from 'components/ui/DataTable/DataTableToolbar' +import { FilterSideBar } from 'components/ui/DataTable/FilterSideBar' +import { LiveButton } from 'components/ui/DataTable/LiveButton' +import { LiveRow } from 'components/ui/DataTable/LiveRow' +import { DataTableProvider } from 'components/ui/DataTable/providers/DataTableProvider' +import { TimelineChart } from 'components/ui/DataTable/TimelineChart' +import { useUnifiedLogsChartQuery } from 'data/logs/unified-logs-chart-query' +import { useUnifiedLogsCountQuery } from 'data/logs/unified-logs-count-query' +import { useUnifiedLogsInfiniteQuery } from 'data/logs/unified-logs-infinite-query' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { + ChartConfig, + cn, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + Separator, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' +import { RefreshButton } from '../../ui/DataTable/RefreshButton' +import { UNIFIED_LOGS_COLUMNS } from './components/Columns' +import { MemoizedDataTableSheetContent } from './components/DataTableSheetContent' +import { FunctionLogsTab } from './components/FunctionLogsTab' +import { CHART_CONFIG, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { filterFields as defaultFilterFields, sheetFields } from './UnifiedLogs.fields' +import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' +import { QuerySearchParamsType } from './UnifiedLogs.types' +import { getFacetedUniqueValues, getLevelRowClassName, logEventBus } from './UnifiedLogs.utils' + +export const UnifiedLogs = () => { + useResetFocus() + + const { ref: projectRef } = useParams() + const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) + + const { sort, start, size, id, cursor, direction, live, ...filter } = search + const defaultColumnSorting = sort ? [sort] : [] + const defaultColumnVisibility = { uuid: false } + const defaultRowSelection = search.id ? { [search.id]: true } : {} + const defaultColumnFilters = Object.entries(filter) + .map(([key, value]) => ({ id: key, value })) + .filter(({ value }) => value ?? undefined) + + const [topBarHeight, setTopBarHeight] = useState(0) + const [activeTab, setActiveTab] = useState('details') + const [sorting, setSorting] = useState(defaultColumnSorting) + const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) + const [rowSelection, setRowSelection] = useState(defaultRowSelection) + + const [columnVisibility, setColumnVisibility] = useLocalStorageQuery( + 'data-table-visibility', + defaultColumnVisibility + ) + const [columnOrder, setColumnOrder] = useLocalStorageQuery( + 'data-table-column-order', + [] + ) + + // Create a stable query key object by removing nulls/undefined, id, and live + // Mainly to prevent the react queries from unnecessarily re-fetching + const searchParameters = Object.entries(search).reduce( + (acc, [key, value]) => { + if (!['id', 'live'].includes(key) && value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, + {} as Record + ) as QuerySearchParamsType + + const { + data: unifiedLogsData, + isLoading, + isFetching, + hasNextPage, + refetch: refetchLogs, + fetchNextPage, + fetchPreviousPage, + } = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters }) + const { + data: counts, + isLoading: isLoadingCounts, + isFetching: isFetchingCounts, + refetch: refetchCounts, + } = useUnifiedLogsCountQuery({ + projectRef, + search: searchParameters, + }) + const { + data: unifiedLogsChart = [], + isFetching: isFetchingCharts, + refetch: refetchCharts, + } = useUnifiedLogsChartQuery({ + projectRef, + search: searchParameters, + }) + + const refetchAllData = () => { + refetchLogs() + refetchCounts() + refetchCharts() + } + + const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts + + const rawFlatData = useMemo(() => { + return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? [] + }, [unifiedLogsData?.pages]) + // [Joshen] Refer to unified-logs-infinite-query on why the need to deupe + const flatData = useMemo(() => { + return rawFlatData.filter((value, idx) => { + return idx === rawFlatData.findIndex((x) => x.id === value.id) + }) + }, [rawFlatData]) + const liveMode = useLiveMode(flatData) + + const totalDBRowCount = counts?.totalRowCount + const filterDBRowCount = flatData.length + + const facets = counts?.facets + const totalFetched = flatData?.length + + // Create a filtered version of the chart config based on selected levels + const filteredChartConfig = useMemo(() => { + const levelFilter = search.level || ['success', 'warning', 'error'] + return Object.fromEntries( + Object.entries(CHART_CONFIG).filter(([key]) => levelFilter.includes(key as any)) + ) as ChartConfig + }, [search.level]) + + const getRowClassName = ( + row: Row + ) => { + const rowTimestamp = row.original.timestamp + const isPast = rowTimestamp <= (liveMode.timestamp || -1) + const levelClassName = getLevelRowClassName(row.original.level as any) + return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]') + } + + const table = useReactTable({ + data: flatData, + columns: UNIFIED_LOGS_COLUMNS, + state: { + columnFilters, + sorting, + columnVisibility, + rowSelection, + columnOrder, + }, + enableMultiRowSelection: false, + columnResizeMode: 'onChange', + filterFns: { inDateRange, arrSome }, + meta: { getRowClassName }, + getRowId: (row) => row.id, + onColumnVisibilityChange: setColumnVisibility, + onColumnFiltersChange: setColumnFilters, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnOrderChange: setColumnOrder, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getTTableFacetedUniqueValues(), + getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), + }) + + const selectedRow = useMemo(() => { + if ((isLoading || isFetching) && !flatData.length) return + const selectedRowKey = Object.keys(rowSelection)?.[0] + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [rowSelection, flatData]) + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + // REMINDER: this is currently needed for the cmdk search + // TODO: auto search via API when the user changes the filter instead of hardcoded + const filterFields = useMemo(() => { + return defaultFilterFields.map((field) => { + const facetsField = facets?.[field.value] + if (!facetsField) return field + if (field.options && field.options.length > 0) return field + + // REMINDER: if no options are set, we need to set them via the API + const options = facetsField.rows.map(({ value }) => ({ label: `${value}`, value })) + + if (field.type === ('slider' as any)) { + return { + ...(field as any), + min: facetsField.min ?? (field as any).min, + max: facetsField.max ?? (field as any).max, + options, + } + } + + return { ...field, options } + }) + }, [facets]) + + useEffect(() => { + const columnFiltersWithNullable = filterFields.map((field) => { + const filterValue = columnFilters.find((filter) => filter.id === field.value) + if (!filterValue) return { id: field.value, value: null } + return { id: field.value, value: filterValue.value } + }) + + const search = columnFiltersWithNullable.reduce( + (prev, curr) => { + // Add to search parameters + prev[curr.id as string] = curr.value + return prev + }, + {} as Record + ) + + setSearch(search) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters]) + + useEffect(() => { + setSearch({ sort: sorting?.[0] || null }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sorting]) + + useEffect(() => { + if (isLoading || isFetching) return + if (Object.keys(rowSelection)?.length && !selectedRow) { + setSearch({ id: null }) + setRowSelection({}) + } else { + setSearch({ id: Object.keys(rowSelection)?.[0] || null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rowSelection, selectedRow, isLoading, isFetching]) + + // Set up event listener for trace tab selection + useEffect(() => { + const unsubscribe = logEventBus.on('selectTraceTab', (rowId) => { + setRowSelection({ [rowId]: true }) + setActiveTab('trace') + }) + return () => { + unsubscribe() + } + }, [setRowSelection]) + + return ( + + + +
+ + + [ + , + fetchPreviousPage ? ( + + ) : null, + ]} + /> + + + + + + + + + + { + if (!liveMode.timestamp) return null + if (props?.row?.original.id !== liveMode?.row?.id) return null + return + }} + setColumnOrder={setColumnOrder} + setColumnVisibility={setColumnVisibility} + searchParamsParser={SEARCH_PARAMS_PARSER} + /> + + {selectedRow?.original?.logs && selectedRow?.original?.logs?.length > 0 && ( + <> + + +
+
+

+ Function Logs ( + {selectedRow?.original?.logs && + Array.isArray(selectedRow?.original?.logs) + ? selectedRow?.original?.logs?.length + : 0} + ) +

+
+
+ +
+
+
+ + )} +
+
+ + {selectedRowKey && ( + <> + + +
+ + + + Log Details + + + + + + + +
+
+ + )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts new file mode 100644 index 00000000000..22dc02b9552 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts @@ -0,0 +1,57 @@ +import { type inferParserType } from 'nuqs' + +import { LOG_TYPES, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +type Percentile = 50 | 75 | 90 | 95 | 99 + +export type LogType = (typeof LOG_TYPES)[number] + +export type UnifiedLogSchema = { + id: string + timestamp: Date + log_type: LogType + code: string + level: string + path: string | null + event_message: string + method: string + api_role: string + auth_user: string | null +} + +export type LogsMeta = { + currentPercentiles: Record +} + +export type PageParam = { cursor: number; direction: 'next' | 'prev' } + +export type SearchParamsType = inferParserType +export type QuerySearchParamsType = Omit + +export type SearchParams = { + [key: string]: string | string[] | undefined +} + +/** ----------------------------------------- */ + +export type SheetField> = { + id: keyof TData + label: string + // FIXME: rethink that! I dont think we need this as there is no input type + // REMINDER: readonly if we only want to copy the value (e.g. uuid) + // TODO: we might have some values that are not in the data but can be computed + type: 'readonly' | 'input' | 'checkbox' | 'slider' | 'timerange' + component?: ( + // REMINDER: this is used to pass additional data like the `InfiniteQueryMeta` + props: TData & { + metadata?: { + totalRows: number + filterRows: number + totalRowsFetched: number + } & TMeta + } + ) => JSX.Element | null | string + condition?: (props: TData) => boolean + className?: string + skeletonClassName?: string +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts new file mode 100644 index 00000000000..a32831a39a8 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -0,0 +1,75 @@ +import { type Table as TTable } from '@tanstack/react-table' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { cn } from 'ui' +import { FacetMetadataSchema } from './UnifiedLogs.schema' + +export const logEventBus = { + listeners: new Map void>>(), + + on(event: 'selectTraceTab', callback: (rowId: string) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)?.add(callback) + return () => this.listeners.get(event)?.delete(callback) + }, + + emit(event: 'selectTraceTab', rowId: string) { + this.listeners.get(event)?.forEach((callback) => callback(rowId)) + }, +} + +export const getFacetedUniqueValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || []) + } +} + +export const getFacetedMinMaxValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + const min = facets?.[columnId]?.min + const max = facets?.[columnId]?.max + if (typeof min === 'number' && typeof max === 'number') return [min, max] + if (typeof min === 'number') return [min, min] + if (typeof max === 'number') return [max, max] + return undefined + } +} + +export const getLevelLabel = (value: (typeof LEVELS)[number]): string => { + switch (value) { + case 'success': + return '2xx' + case 'warning': + return '4xx' + case 'error': + return '5xx' + default: + return 'Unknown' + } +} + +export function getLevelRowClassName(value: (typeof LEVELS)[number]): string { + switch (value) { + case 'success': + return '' + case 'warning': + return cn( + 'bg-warning/5 hover:bg-warning/10 data-[state=selected]:bg-warning/20 focus-visible:bg-warning/10', + 'dark:bg-warning/10 dark:hover:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:focus-visible:bg-warning/20' + ) + case 'error': + return cn( + 'bg-destructive/5 hover:bg-destructive/10 data-[state=selected]:bg-destructive/20 focus-visible:bg-destructive/10', + 'dark:bg-error/10 dark:hover:bg-destructive/20 dark:data-[state=selected]:bg-destructive/30 dark:focus-visible:bg-destructive/20' + ) + case 'info': + return cn( + 'bg-info/5 hover:bg-info/10 data-[state=selected]:bg-info/20 focus-visible:bg-info/10', + 'dark:bg-info/10 dark:hover:bg-info/20 dark:data-[state=selected]:bg-info/30 dark:focus-visible:bg-info/20' + ) + default: + return '' + } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx new file mode 100644 index 00000000000..0d9f329dd92 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx @@ -0,0 +1,37 @@ +import { User } from 'lucide-react' + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' + +interface AuthUserHoverCardProps { + authUser: string +} + +export const AuthUserHoverCard = ({ authUser }: AuthUserHoverCardProps) => { + if (!authUser) return null + + return ( + + + + + +
+
+ +

User Details

+
+
+
+ ID/Email: {authUser} +
+ {/* Additional user details can be added here if available */} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx new file mode 100644 index 00000000000..49334f1b61b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -0,0 +1,193 @@ +import { ColumnDef } from '@tanstack/react-table' + +import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' +import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' +import { AuthUserHoverCard } from './AuthUserHoverCard' +import { HoverCardTimestamp } from './HoverCardTimestamp' +import { LogTypeIcon } from './LogTypeIcon' +import { TextWithTooltip } from './TextWithTooltip' + +export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = [ + { + accessorKey: 'level', + header: '', + cell: ({ row }) => { + const level = row.getValue('level') + return + }, + enableHiding: false, + enableResizing: false, + filterFn: (row, columnId, filterValue) => true, + size: 48, + minSize: 48, + maxSize: 48, + meta: { + headerClassName: + 'w-[--header-level-size] max-w-[--header-level-size] min-w-[--header-level-size]', + cellClassName: 'w-[--col-level-size] max-w-[--col-level-size] min-w-[--col-level-size]', + }, + }, + { + accessorKey: 'date', + header: ({ column }) => , + cell: ({ row }) => { + const date = new Date(row.getValue('date')) + return + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 130, + minSize: 130, + maxSize: 130, + meta: { + headerClassName: + 'w-[--header-date-size] max-w-[--header-date-size] min-w-[--header-date-size]', + cellClassName: + 'font-mono w-[--col-date-size] max-w-[--col-date-size] min-w-[--col-date-size]', + }, + }, + { + accessorKey: 'log_type', + header: '', + cell: ({ row }) => { + const logType = row.getValue('log_type') + return ( +
+ +
+ ) + }, + enableHiding: false, + filterFn: (row, columnId, filterValue) => true, + size: 40, + minSize: 40, + maxSize: 40, + enableResizing: false, + meta: { + headerClassName: + 'w-[--header-log_type-size] max-w-[--header-log_type-size] min-w-[--header-log_type-size]', + cellClassName: + 'text-right px-0 relative justify-end items-center font-mono w-[--col-log_type-size] max-w-[--col-log_type-size] min-w-[--col-log_type-size]', + }, + }, + { + // TODO: make it a type of MethodSchema! + accessorKey: 'method', + header: 'Method', + filterFn: 'arrIncludesSome', + cell: ({ row }) => { + const value = row.getValue('method') + return {value} + }, + enableResizing: false, + size: 70, + minSize: 70, + meta: { + cellClassName: + 'font-mono text-muted-foreground w-[--col-method-size] max-w-[--col-method-size] min-w-[--col-method-size]', + headerClassName: + 'w-[--header-method-size] max-w-[--header-method-size] min-w-[--header-method-size]', + }, + }, + { + accessorKey: 'status', + header: '', + cell: ({ row }) => { + const value = row.getValue('status') + return ( +
+ {row.original.auth_user && ( +
+ +
+ )} + ('level')} + /> +
+ ) + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 50, + minSize: 50, + meta: { + headerClassName: + 'w-[--header-status-size] max-w-[--header-status-size] min-w-[--header-status-size]', + cellClassName: + 'font-mono w-[--col-status-size] max-w-[--col-status-size] min-w-[--col-status-size]', + }, + }, + + { + accessorKey: 'host', + header: 'Host', + cell: ({ row }) => { + const value = row.getValue('host') + return + }, + size: 220, + minSize: 220, + meta: { + cellClassName: 'font-mono w-[--col-host-size] max-w-[--col-host-size]', + headerClassName: 'min-w-[--header-host-size] w-[--header-host-size]', + }, + }, + { + accessorKey: 'pathname', + header: 'Pathname', + cell: ({ row }) => { + const value = row.getValue('pathname') ?? '' + return + }, + size: 320, + minSize: 320, + meta: { + cellClassName: 'font-mono w-[--col-pathname-size] max-w-[--col-pathname-size]', + headerClassName: 'min-w-[--header-pathname-size] w-[--header-pathname-size]', + }, + }, + { + accessorKey: 'event_message', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('event_message') + const logCount = row.original.log_count + + return ( +
+ {logCount && ( + + + + {logCount} + + + + {logCount} {logCount === 1 ? 'log' : 'logs'} for this execution + + + )} + {value && ( + + + + )} +
+ ) + }, + enableResizing: true, + size: 200, + minSize: 200, + meta: { + headerClassName: + 'w-[--header-event_message-size] max-w-[--header-event_message-size] min-w-[--header-event_message-size]', + cellClassName: + 'font-mono w-[--col-event_message-size] max-w-[--col-event_message-size] min-w-[--col-event_message-size]', + }, + }, +] diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx new file mode 100644 index 00000000000..ff08c51e681 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx @@ -0,0 +1,105 @@ +import { Table } from '@tanstack/react-table' +import { HTMLAttributes, memo } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { DataTableSheetRowAction } from 'components/ui/DataTable/DataTableSheetRowAction' +import { cn, Skeleton } from 'ui' +import { SheetField } from '../UnifiedLogs.types' + +interface SheetDetailsContentSkeletonProps { + fields: SheetField[] +} + +const SheetDetailsContentSkeleton = ({ + fields, +}: SheetDetailsContentSkeletonProps) => { + return ( +
+ {fields.map((field) => ( +
+
{field.label}
+
+ +
+
+ ))} +
+ ) +} + +interface DataTableSheetContentProps extends HTMLAttributes { + data?: TData + table: Table + fields: SheetField[] + filterFields: DataTableFilterField[] + metadata?: TMeta & { + totalRows: number + filterRows: number + totalRowsFetched: number + } +} + +export function DataTableSheetContent({ + data, + table, + className, + fields, + filterFields, + metadata, + ...props +}: DataTableSheetContentProps) { + if (!data) return + + return ( +
+ {fields.map((field) => { + if (field.condition && !field.condition(data)) return null + + const Component = field.component + const value = String(data[field.id]) + + return ( +
+ {field.type === 'readonly' ? ( +
+
{field.label}
+
+ {Component ? : value} +
+
+ ) : ( + +
{field.label}
+
+ {Component ? : value} +
+
+ )} +
+ ) + })} +
+ ) +} + +export const MemoizedDataTableSheetContent = memo(DataTableSheetContent, (prev, next) => { + // REMINDER: only check if data is the same, rest is useless + return prev.data === next.data +}) as typeof DataTableSheetContent diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx new file mode 100644 index 00000000000..a9998ffe56b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx @@ -0,0 +1,53 @@ +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { HoverCardTimestamp } from './HoverCardTimestamp' + +interface FunctionLogEntry { + id: string + timestamp: string + event_message: string + level: string + event_type: string +} + +interface FunctionLogsTabProps { + logs?: FunctionLogEntry[] +} + +export const FunctionLogsTab = ({ logs = [] }: FunctionLogsTabProps) => { + if (!logs || logs.length === 0) { + return ( +
+

No function logs found

+
+ ) + } + + return ( +
+
+ {logs.map((log) => { + const date = new Date(Number(log.timestamp) / 1000) + + return ( +
+
+
+ + + +
+
+ {log.event_message} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx new file mode 100644 index 00000000000..337fe574ed6 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx @@ -0,0 +1,14 @@ +import { TimestampInfo } from 'ui-patterns' + +interface HoverCardTimestampProps { + date: Date + className?: string +} + +export function HoverCardTimestamp({ date, className }: HoverCardTimestampProps) { + return ( +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx new file mode 100644 index 00000000000..5f9d067407a --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -0,0 +1,65 @@ +import { BookHeart, Box, Cpu, Database, Globe } from 'lucide-react' + +import { Auth, EdgeFunctions, Storage } from 'icons' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { type LOG_TYPES } from '../UnifiedLogs.constants' + +interface LogTypeIconProps { + type: (typeof LOG_TYPES)[number] + size?: number + strokeWidth?: number + className?: string +} + +export const LogTypeIcon = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + const iconMap = { + edge: () => , + postgrest: () => , + auth: () => , + edge_function: () => ( + + ), + postgres: () => , + function_events: () => ( + + ), + supavisor: () => , + postgres_upgrade: () => , + storage: () => , + + // cron: () => , + } + + const IconComponent = + iconMap[type] || (() => ) + + return ( + + + + + +
{type}
+
+
+ ) +} + +export const LogTypeIconWithText = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + return ( +
+ + {type} +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx new file mode 100644 index 00000000000..fae42e933ac --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' +import { cn, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from 'ui' + +interface TextWithTooltipProps { + text: string | number + className?: string +} + +export function TextWithTooltip({ text, className }: TextWithTooltipProps) { + const [isTruncated, setIsTruncated] = useState(false) + const textRef = useRef(null) + + useEffect(() => { + const checkTruncation = () => { + if (textRef.current) { + const { scrollWidth, clientWidth } = textRef.current + setIsTruncated(scrollWidth > clientWidth) + } + } + + const resizeObserver = new ResizeObserver(() => { + checkTruncation() + }) + + if (textRef.current) { + resizeObserver.observe(textRef.current) + } + + checkTruncation() + + return () => { + resizeObserver.disconnect() + } + }, []) + + return ( + + + +
+ {text} +
+
+ + {text} + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx new file mode 100644 index 00000000000..7745d22b864 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx @@ -0,0 +1,10 @@ +import { getLevelLabel } from '../UnifiedLogs.utils' + +export const TooltipLabel = ({ level }: { level: 'success' | 'warning' | 'error' }) => { + return ( +
+
{level}
+
{getLevelLabel(level)}
+
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index a677ab116e7..8b9b80ffef2 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -29,7 +29,7 @@ export const AssistantButton = () => { }, }} > - + ) } diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx index 0291b55750f..1bcecd1dd0f 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx @@ -365,7 +365,7 @@ export const EnableBranchingModal = () => {

- Preview branches are billed $0.32 per day + Preview branches are billed $0.01344 per hour

This cost will continue for as long as the branch has not been removed. diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 62ca07f0412..e0a4b5955d4 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -81,6 +81,7 @@ export const OrganizationDropdown = () => { const href = !!routeSlug ? router.pathname.replace('[slug]', org.slug) : `/org/${org.slug}` + return ( { + const { ref: projectRef } = useParams() + + const navigationItems = [ + { + label: 'JWT Secret', + href: `/project/${projectRef}/settings/jwt`, + id: 'legacy-jwt-keys', + }, + { + label: 'JWT Signing Keys (Coming Soon)', + href: `/project/${projectRef}/settings/jwt/signing-keys`, + id: 'signing-keys', + }, + ] + + return ( + + + {children} + + + ) +} + +export default JWTKeysLayout diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 7296bb61f6f..2edee7ba163 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -2,13 +2,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' +import { LOCAL_STORAGE_KEYS } from 'common' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { LogsSidebarMenuV2 } from './LogsSidebarMenuV2' -import { LOCAL_STORAGE_KEYS } from 'common' interface LogsLayoutProps { title?: string diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 993484f7197..f47831e49e3 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -80,22 +80,27 @@ export function SidebarCollapsible({ } export function LogsSidebarMenuV2() { + const router = useRouter() + const { ref } = useParams() as { ref: string } + const warehouseEnabled = useFlag('warehouse') + const [searchText, setSearchText] = useState('') const [createCollectionOpen, setCreateCollectionOpen] = useState(false) const canCreateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare') - const router = useRouter() - const { ref } = useParams() as { ref: string } + const { data: tenantData } = useWarehouseTenantQuery({ projectRef: ref }) + const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled, realtimeAll: realtimeEnabled, } = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all']) - const warehouseEnabled = useFlag('warehouse') + const { data: whCollections, isLoading: whCollectionsLoading } = useWarehouseCollectionsQuery( { projectRef: ref }, { enabled: IS_PLATFORM && warehouseEnabled && !!tenantData } ) + const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan() const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index d8903ee448c..e610bb3a4e1 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -14,14 +14,12 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { useNotificationsStateSnapshot } from 'state/notifications' import { Button, - CriticalIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, - WarningIcon, cn, } from 'ui' import NotificationRow from './NotificationRow' @@ -103,41 +101,33 @@ export const NotificationsPopoverV2 = () => { text: 'Notifications', }, }} - type={hasNewNotifications ? 'outline' : 'text'} - className={cn( - 'rounded-none h-[30px] w-[32px]', - // !hasCritical || !hasWarning || !hasNewNotifications ? 'w-[26px]' : '', - 'group', - hasNewNotifications ? 'rounded-full px-1.5' : 'px-1', - hasCritical - ? 'border-destructive-500 hover:border-destructive-600 hover:bg-destructive-300' - : hasWarning - ? 'border-warning-500 hover:border-warning-600 hover:bg-warning-300' - : '' - )} + type="text" + className={cn('rounded-none h-[30px] w-[32px] group relative')} icon={ - hasCritical ? ( - - ) : hasWarning ? ( - - ) : hasNewNotifications ? ( -

+ 9 ? 'px-0.5 w-auto' : 'w-4' + '!h-[18px] !w-[18px] text-foreground-light group-hover:text-foreground' )} - > -

{summary?.unread_count}

-
- ) : null - } - iconRight={ - + /> + {hasCritical && ( +
+
+
+ )} + {hasWarning && !hasCritical && ( +
+
+
+ )} + {!!hasNewNotifications && !hasCritical && !hasWarning && ( +
+
+
+ )} +
} /> diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 96df83e1bb9..41a6434e197 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,4 +1,4 @@ -import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' +import { Blocks, FileText, Lightbulb, List, Logs, Settings } from 'lucide-react' import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils' @@ -19,12 +19,7 @@ import { } from 'icons' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -export const generateToolRoutes = ( - ref?: string, - project?: Project, - features?: { sqlEditorTabs: boolean } -): Route[] => { - const { sqlEditorTabs } = features ?? {} +export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -42,8 +37,7 @@ export const generateToolRoutes = ( icon: , link: !IS_PLATFORM ? `/project/${ref}/sql/1` - : ref && - (isProjectBuilding ? buildingUrl : `/project/${ref}/sql${!!sqlEditorTabs ? '' : '/new'}`), + : ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), }, ] } @@ -122,10 +116,16 @@ export const generateProductRoutes = ( ] } -export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => { +export const generateOtherRoutes = ( + ref?: string, + project?: Project, + features?: { unifiedLogs?: boolean } +): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` + const showUnifiedLogs = features?.unifiedLogs ?? false + return [ { key: 'advisors', @@ -149,6 +149,16 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/logs`), }, + ...(showUnifiedLogs + ? [ + { + key: 'unified-logs', + label: 'Unified Logs', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/unified-logs`), + }, + ] + : []), { key: 'api', label: 'API Docs', diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index 9e8e36faf38..63caa017d88 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const PauseFailedState = () => { @@ -22,6 +18,10 @@ const PauseFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const PauseFailedState = () => {
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 3c887f9ac36..4368aab294b 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,13 +4,8 @@ import { useRouter } from 'next/router' import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' -import AISettingsModal from 'components/ui/AISettingsModal' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import { Loading } from 'components/ui/Loading' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' @@ -101,14 +96,8 @@ const ProjectLayout = forwardRef
- {productMenu} diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index 6ba94a4833b..bf20923f157 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const RestoreFailedState = () => { @@ -22,6 +18,10 @@ const RestoreFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const RestoreFailedState = () => {
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 6f847292c24..f7f7138e956 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -64,6 +64,13 @@ export const generateSettingsMenu = ( items: [], label: 'NEW', }, + { + name: 'JWT Keys', + key: 'jwt', + url: `/project/${ref}/settings/jwt`, + items: [], + label: 'NEW', + }, ] : []), { diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index ce1e4ab1267..1afb9f51a7a 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -18,6 +18,7 @@ import { useProfile } from 'lib/profile' import { Menu, cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ReportMenuItem } from './ReportMenuItem' +import { useFlag } from 'hooks/ui/useFlag' const ReportsMenu = () => { const router = useRouter() @@ -25,6 +26,7 @@ const ReportsMenu = () => { const { ref, id } = useParams() const pageKey = (id || router.pathname.split('/')[4]) as string const storageEnabled = useIsFeatureEnabled('project_storage:all') + const authEnabled = useFlag('authreportv2') const canCreateCustomReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'report', owner_id: profile?.id }, @@ -97,6 +99,20 @@ const ReportsMenu = () => { key: 'api-overview', url: `/project/${ref}/reports/api-overview`, }, + ...(authEnabled + ? [ + { + name: 'Auth', + key: 'auth', + url: `/project/${ref}/reports/auth`, + }, + ] + : []), + { + name: 'Database', + key: 'database', + url: `/project/${ref}/reports/database`, + }, ...(storageEnabled ? [ { @@ -106,12 +122,6 @@ const ReportsMenu = () => { }, ] : []), - - { - name: 'Database', - key: 'database', - url: `/project/${ref}/reports/database`, - }, ], }, ] diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 4dd9bd7ba7c..55b8dee51f7 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,5 +1,9 @@ +import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal' import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal' @@ -19,10 +23,6 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import uuidv4 from 'lib/uuid' -import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' import { SnippetWithContent, useSnippetFolders, @@ -59,7 +59,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const tabs = useTabsStateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), @@ -293,12 +292,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ onSuccess: (data) => { - if (isSQLEditorTabsEnabled) { - // Update Tabs state - currently unknown how to differentiate between sql and non-sql content - // so we're just deleting all tabs for with matching IDs - const tabIds = data.map((id) => createTabId('sql', { id })) - tabs.removeTabs(tabIds) - } + // Update Tabs state - currently unknown how to differentiate between sql and non-sql content + // so we're just deleting all tabs for with matching IDs + const tabIds = data.map((id) => createTabId('sql', { id })) + tabs.removeTabs(tabIds) }, onError: (error, data) => { if (error.message.includes('Contents not found')) { @@ -541,10 +538,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (isSuccess && isSQLEditorTabsEnabled) { + if (isSuccess) { sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) } - }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup]) + }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup]) return ( <> @@ -572,15 +569,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={projectSnippetsTreeState} aria-label="project-level-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -656,15 +651,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={favoritesTreeState} aria-label="favorite-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -746,15 +739,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }} expandedIds={expandedFolderIds} nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 895eaee2bb2..3633fdf5b74 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query' import { Snippet } from 'data/content/sql-folders-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -83,7 +82,6 @@ export const SQLEditorTreeViewItem = ({ const { profile } = useProfile() const { className, onClick } = getNodeProps() const snapV2 = useSqlEditorV2StateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const isOwner = profile?.id === element?.metadata.owner_id const isSharedSnippet = element.metadata.visibility === 'project' @@ -262,13 +260,12 @@ export const SQLEditorTreeViewItem = ({ {}} onFocusCapture={(e) => e.stopPropagation()} > Open in new tab diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index d1e8269143b..62cea948bb6 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -10,7 +10,6 @@ import { MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { formatTableRowsToSQL, getEntityLintDetails, @@ -70,11 +69,9 @@ const EntityListItem: ItemRenderer = ({ const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() - // For tabs preview flag logic - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const tabId = createTabId(entity.type, { id: entity.id }) const tabs = useTabsStateSnapshot() - const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false + const isPreview = tabs.previewTabId === tabId const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id) const isActive = Number(id) === entity.id diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index a306730b114..84bd7f4f689 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import AlertError from 'components/ui/AlertError' @@ -45,11 +44,10 @@ import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { - const { ref, id: _id } = useParams() + const { id: _id } = useParams() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const isMobile = useBreakpoint() const [showModal, setShowModal] = useState(false) @@ -118,10 +116,10 @@ const TableEditorMenu = () => { const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session - if (isTableEditorTabsEnabled && entityTypes && !searchText) { + if (entityTypes && !searchText) { tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes }) } - }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp]) + }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp]) return ( <> diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index ba199aab480..8afcfe3bd4d 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -6,7 +6,6 @@ import { useRouter } from 'next/router' import { toast } from 'sonner' import { useParams } from 'common' -import { TabsUpdateCallout } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -114,7 +113,6 @@ export function NewTab() { return (
-
{actions.map((item, i) => ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 66fb4b9b715..847a3c11d45 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -12,7 +12,6 @@ import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' import { useParams } from 'common' -import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { useAppStateSnapshot } from 'state/app-state' import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs' import { @@ -173,43 +172,41 @@ export const EditorTabs = () => { {/* Non-draggable new tab */} {hasNewTab && ( - <> - + +
+ New +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} > - -
- New -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - > - - {' '} -
- - + + {' '} +
+ )} @@ -235,8 +232,6 @@ export const EditorTabs = () => {
- - diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx new file mode 100644 index 00000000000..27ba57f0ce0 --- /dev/null +++ b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react' + +import ProjectLayout from '../ProjectLayout/ProjectLayout' + +const UnifiedLogsLayout = ({ children }: PropsWithChildren) => { + return {children} +} + +export default UnifiedLogsLayout diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index eafb111c3b0..9f0da404d7f 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -2,10 +2,6 @@ import { usePathname } from 'next/navigation' import { ComponentProps, ReactNode } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useTabsStateSnapshot } from 'state/tabs' import { cn } from 'ui' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' @@ -26,12 +22,6 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore const editor = useEditorType() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - - const tableEditorTabsEnabled = editor === 'table' && isTableEditorTabsEnabled - const sqlEditorTabsEnabled = editor === 'sql' && isSQLEditorTabsEnabled - const hasNoOpenTabs = editor === 'table' ? tabs.openTabs.filter((x) => !x.startsWith('sql')).length === 0 : false const hideTabs = @@ -40,16 +30,14 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore return (
- {tableEditorTabsEnabled || sqlEditorTabsEnabled ? ( -
- {hideTabs ? : } -
- ) : null} +
+ {hideTabs ? : } +
{children}
diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx index e9a20a5e1bd..a268b1aaeff 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx @@ -63,43 +63,17 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { const currentFolderIdx = openedFolders.length - 1 const currentFolder = openedFolders[currentFolderIdx] - if (itemSearchString) { - if (!currentFolder) { - // At root of bucket - await fetchFolderContents({ - bucketId: bucket.id, - folderId: bucket.id, - folderName: bucket.name, - index: -1, - searchString: itemSearchString, - }) - } else { - await fetchFolderContents({ - bucketId: bucket.id, - folderId: currentFolder.id, - folderName: currentFolder.name, - index: currentFolderIdx, - searchString: itemSearchString, - }) - } - } else { - if (!currentFolder) { - // At root of bucket - await fetchFolderContents({ - bucketId: bucket.id, - folderId: bucket.id, - folderName: bucket.name, - index: -1, - }) - } else { - await fetchFolderContents({ - bucketId: bucket.id, - folderId: currentFolder.id, - folderName: currentFolder.name, - index: currentFolderIdx, - }) - } - } + const folderId = !currentFolder ? bucket.id : currentFolder.id + const folderName = !currentFolder ? bucket.name : currentFolder.name + const index = !currentFolder ? -1 : currentFolderIdx + + await fetchFolderContents({ + bucketId: bucket.id, + folderId, + folderName, + index, + searchString: itemSearchString, + }) } else if (view === STORAGE_VIEWS.COLUMNS) { if (openedFolders.length > 0) { const paths = openedFolders.map((folder) => folder.name) @@ -110,6 +84,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { folderId: bucket.id, folderName: bucket.name, index: -1, + searchString: itemSearchString, }) } } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 1ebea56eb35..640b511c37c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,28 +1,24 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import type { Message as MessageType } from 'ai/react' import { useChat } from 'ai/react' import { AnimatePresence, motion } from 'framer-motion' -import { last } from 'lodash' import { ArrowDown, FileText, Info, RefreshCw, X } from 'lucide-react' import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' +import { LOCAL_STORAGE_KEYS } from 'common' import { useParams, useSearchParamsShallow } from 'common/hooks' import { Markdown } from 'components/interfaces/Markdown' -import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' import { constructHeaders } from 'data/fetchers' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' -import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import uuidv4 from 'lib/uuid' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -36,15 +32,16 @@ import { TooltipTrigger, } from 'ui' import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ButtonTooltip } from '../ButtonTooltip' -import DotGrid from '../DotGrid' +import { DotGrid } from '../DotGrid' +import { ErrorBoundary } from '../ErrorBoundary' +import { onErrorChat } from './AIAssistant.utils' import { AIAssistantChatSelector } from './AIAssistantChatSelector' -import AIOnboarding from './AIOnboarding' -import CollapsibleCodeBlock from './CollapsibleCodeBlock' +import { AIOnboarding } from './AIOnboarding' +import { AIOptInModal } from './AIOptInModal' +import { CollapsibleCodeBlock } from './CollapsibleCodeBlock' import { Message } from './Message' import { useAutoScroll } from './hooks' -import { ErrorBoundary } from '../ErrorBoundary' const MemoizedMessage = memo( ({ @@ -68,8 +65,7 @@ const MemoizedMessage = memo( { const router = useRouter() const project = useSelectedProject() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() const selectedOrganization = useSelectedOrganization() const { ref, id: entityId } = useParams() const searchParams = useSearchParamsShallow() - const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + const newOrgAiOptIn = useFlag('newOrgAiOptIn') const disablePrompts = useFlag('disableAssistantPrompts') + const useBedrockAssistant = useFlag('useBedrockAssistant') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() + const [updatedOptInSinceMCP] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, + false + ) + const inputRef = useRef(null) const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll() + const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const showMetadataWarning = + IS_PLATFORM && + !!selectedOrganization && + ((!useBedrockAssistant && aiOptInLevel === 'disabled') || + (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema'))) + // Add a ref to store the last user message const lastUserMessageRef = useRef(null) @@ -129,11 +137,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const { mutate: sendEvent } = useSendEventMutation() - const handleError = useCallback((error: Error) => { - const errorMessage = JSON.parse(error.message).message - toast.error(errorMessage) - }, []) - // Handle completion of the assistant's response const handleChatFinish = useCallback((message: MessageType) => { // If we have a user message stored in the ref, save both messages @@ -156,25 +159,43 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setMessages, } = useChat({ id: snap.activeChatId, - api: `${BASE_PATH}/api/ai/sql/generate-v3`, + api: useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/generate-v4` + : `${BASE_PATH}/api/ai/sql/generate-v3`, maxSteps: 5, // [Alaister] typecast is needed here because valtio returns readonly arrays // and useChat expects a mutable array initialMessages: snap.activeChat?.messages as unknown as MessageType[] | undefined, - body: { - includeSchemaMetadata, - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: currentSchema, - table: currentTable?.name, + experimental_prepareRequestBody: ({ messages }) => { + // [Joshen] Specifically limiting the chat history that get's sent to reduce the + // size of the context that goes into the model. This should always be an odd number + // as much as possible so that the first message is always the user's + const MAX_CHAT_HISTORY = 5 + + return JSON.stringify({ + messages: messages.slice(-MAX_CHAT_HISTORY), + aiOptInLevel, + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: currentSchema, + table: currentTable?.name, + includeSchemaMetadata: !useBedrockAssistant + ? !IS_PLATFORM || aiOptInLevel !== 'disabled' + : undefined, + }) }, - onError: handleError, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = await constructHeaders() + const existingHeaders = new Headers(init?.headers) + for (const [key, value] of headers.entries()) { + existingHeaders.set(key, value) + } + return fetch(input, { ...init, headers: existingHeaders }) + }, + onError: onErrorChat, onFinish: handleChatFinish, }) - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - const updateMessage = useCallback( ({ messageId, @@ -207,24 +228,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const hasMessages = chatMessages.length > 0 - const sendMessageToAssistant = async (content: string) => { - const payload = { role: 'user', createdAt: new Date(), content } as MessageType - const headerData = await constructHeaders() + const sendMessageToAssistant = (content: string) => { + const payload = { role: 'user', createdAt: new Date(), content, id: uuidv4() } as MessageType snap.clearSqlSnippets() // Store the user message in the ref before appending lastUserMessageRef.current = payload - const authorizationHeader = headerData.get('Authorization') - - append( - payload, - authorizationHeader - ? { - headers: { Authorization: authorizationHeader }, - } - : undefined - ) + append(payload) setValue('') @@ -247,30 +258,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { } } - const confirmOptInToShareSchemaData = async () => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!selectedOrganization?.slug) return console.error('Organization slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - - const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags - : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] - - updateOrganization( - { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - toast.success('Successfully opted-in') - setIsConfirmOptInModalOpen(false) - }, - } - ) - } - const handleClearMessages = () => { snap.clearMessages() setMessages([]) @@ -336,9 +323,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { The Assistant is in Alpha and your prompts might be rate limited.{' '} - {includeSchemaMetadata - ? 'Project metadata is being shared to improve Assistant responses.' - : 'Project metadata is not being shared. Opt in to improve Assistant responses.'} + {aiOptInLevel === 'schema_and_log_and_data' && + 'Schema, logs, and query data are being shared to improve Assistant responses.'} + {aiOptInLevel === 'schema_and_log' && + 'Schema and logs are being shared to improve Assistant responses.'} + {aiOptInLevel === 'schema' && + 'Only schema metadata is being shared to improve Assistant responses.'} + {aiOptInLevel === 'disabled' && + 'Project metadata is not being shared. Opt in to improve Assistant responses.'}
@@ -375,16 +367,30 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
- {!includeSchemaMetadata && selectedOrganization && ( + {showMetadataWarning && ( {!isHipaaProjectDisallowed && ( + + + + + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx index 0a60ef1055f..92e83becffb 100644 --- a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx @@ -1,12 +1,13 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react' import { useState } from 'react' + import { Button, CodeBlock, CodeBlockProps, cn } from 'ui' interface CollapsibleCodeBlockProps extends CodeBlockProps { onRemove?: () => void } -const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => { +export const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => { const [isExpanded, setIsExpanded] = useState(false) const codeString = (props.value || props.children) as string @@ -57,5 +58,3 @@ const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps)
) } - -export default CollapsibleCodeBlock diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx new file mode 100644 index 00000000000..8d1a94ec670 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -0,0 +1,138 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Message } from 'ai/react' +import { useRouter } from 'next/router' +import { DragEvent, PropsWithChildren, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useProfile } from 'lib/profile' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { Badge } from 'ui' +import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock' +import { identifyQueryType } from './AIAssistant.utils' +import { findResultForManualId } from './Message.utils' + +interface DisplayBlockRendererProps { + messageId: string + toolCallId: string + manualId?: string + initialArgs: { + sql: string + label?: string + view?: 'table' | 'chart' + xAxis?: string + yAxis?: string + runQuery?: boolean + } + messageParts: Readonly | undefined + isLoading: boolean + onResults: (args: { messageId: string; resultId?: string; results: any[] }) => void +} + +export const DisplayBlockRenderer = ({ + messageId, + toolCallId, + manualId, + initialArgs, + messageParts, + isLoading, + onResults, +}: PropsWithChildren) => { + const router = useRouter() + const { ref } = useParams() + const { profile } = useProfile() + const org = useSelectedOrganization() + const snap = useAiAssistantStateSnapshot() + + const { mutate: sendEvent } = useSendEventMutation() + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + }) + + const [chartConfig, setChartConfig] = useState(() => ({ + ...DEFAULT_CHART_CONFIG, + view: initialArgs.view === 'chart' ? 'chart' : 'table', + xKey: initialArgs.xAxis ?? '', + yKey: initialArgs.yAxis ?? '', + })) + + const isChart = initialArgs.view === 'chart' + const resultId = manualId || toolCallId + const liveResultData = useMemo( + () => (manualId ? findResultForManualId(messageParts, manualId) : undefined), + [messageParts, manualId] + ) + const cachedResults = useMemo( + () => snap.getCachedSQLResults({ messageId, snippetId: resultId }), + [snap, messageId, resultId] + ) + const displayData = liveResultData ?? cachedResults + const isDraggableToReports = canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]') + const label = initialArgs.label || 'SQL Results' + const sqlQuery = initialArgs.sql + + const handleRunQuery = (queryType: 'select' | 'mutation') => { + sendEvent({ + action: 'assistant_suggestion_run_query_clicked', + properties: { + queryType, + ...(queryType === 'mutation' ? { category: identifyQueryType(sqlQuery) ?? 'unknown' } : {}), + }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + + const handleUpdateChartConfig = ({ + chartConfig: updatedValues, + }: { + chartConfig: Partial + }) => { + setChartConfig((prev) => ({ ...prev, ...updatedValues })) + } + + const handleDragStart = (e: DragEvent) => { + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ label, sql: sqlQuery, config: chartConfig }) + ) + } + + return ( +
+ + + NEW + +

Drag to add this chart into your custom report

+
+ ) : undefined + } + onResults={(results) => onResults({ messageId, resultId, results })} + onRunQuery={handleRunQuery} + onUpdateChartConfig={handleUpdateChartConfig} + onDragStart={handleDragStart} + /> +
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 8398c86c170..771626f60ea 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -1,11 +1,21 @@ +import { Message as VercelMessage } from 'ai/react' import { User } from 'lucide-react' -import { createContext, PropsWithChildren, useMemo } from 'react' +import { createContext, PropsWithChildren, ReactNode, useMemo } from 'react' import ReactMarkdown from 'react-markdown' import { Components } from 'react-markdown/lib/ast-to-react' import remarkGfm from 'remark-gfm' -import { AiIconAnimation, cn, markdownComponents, WarningIcon } from 'ui' -import { Heading3, InlineCode, Link, ListItem, MarkdownPre, OrderedList } from './MessageMarkdown' +import { cn, markdownComponents, WarningIcon } from 'ui' +import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' +import { DisplayBlockRenderer } from './DisplayBlockRenderer' +import { + Heading3, + Hyperlink, + InlineCode, + ListItem, + MarkdownPre, + OrderedList, +} from './MessageMarkdown' interface MessageContextType { isLoading: boolean @@ -18,16 +28,16 @@ const baseMarkdownComponents: Partial = { li: ListItem, h3: Heading3, code: InlineCode, - a: Link, + a: Hyperlink, + img: ({ src }) => [Image: {src}], } interface MessageProps { id: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string + message: VercelMessage isLoading: boolean readOnly?: boolean - action?: React.ReactNode + action?: ReactNode variant?: 'default' | 'warning' onResults: ({ messageId, @@ -42,15 +52,13 @@ interface MessageProps { export const Message = function Message({ id, - role, - content, + message, isLoading, readOnly, action = null, variant = 'default', onResults, }: PropsWithChildren) { - const isUser = role === 'user' const allMarkdownComponents: Partial = useMemo( () => ({ ...markdownComponents, @@ -61,16 +69,26 @@ export const Message = function Message({ ), }), - [] + [id, onResults] ) - if (!content) return null + if (!message) { + console.error(`Message component received undefined message prop for id: ${id}`) + return null + } + + const { role, content, parts } = message + const isUser = role === 'user' + + const shouldUsePartsRendering = parts && parts.length > 0 + + const hasTextContent = content && content.trim().length > 0 return (
- {isUser ? ( + {isUser && (
- ) : ( - )} - - {content} - + +
+ {shouldUsePartsRendering ? ( + (() => { + const shownLoadingTools = new Set() + return parts.map( + (part: NonNullable[number], index: number) => { + switch (part.type) { + case 'text': + return ( + li]:pl-4 [&_ol>li]:my-0 [&_li>p]:mt-0 space-y-5 [&>*>code]:text-xs [&>*>*>code]:text-xs [&_li]:space-y-4', + isUser && 'text-foreground font-semibold' + )} + remarkPlugins={[remarkGfm]} + components={allMarkdownComponents} + > + {part.text} + + ) + + case 'tool-invocation': { + const { toolCallId, toolName, args, state } = part.toolInvocation + if (state === 'call' || state === 'partial-call') { + if (shownLoadingTools.has(toolName)) { + // Already shown loading for this toolName in this step + return null + } + shownLoadingTools.add(toolName) + return ( +
+ {`Calling ${toolName}...`} +
+ ) + } + // Only render the result UI for known tools when state is 'result' + switch (toolName) { + case 'display_query': { + return ( + + ) + } + case 'display_edge_function': { + return ( +
+ +
+ ) + } + default: + // For unknown tools, just show nothing for result + return null + } + } + case 'reasoning': + case 'source': + case 'file': + return null + default: + return null + } + } + ) + })() + ) : hasTextContent ? ( + + {content} + + ) : ( + Assistant is thinking... + )} +
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts new file mode 100644 index 00000000000..d8e94c8f983 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts @@ -0,0 +1,78 @@ +import { Message } from 'ai/react' + +type MessagePart = NonNullable[number] + +const extractDataFromSafetyMessage = (text: string): string | null => { + const openingTags = [...text.matchAll(//gi)] + if (openingTags.length < 2) return null + + const closingTagMatch = text.match(/<\/untrusted-data-[a-z0-9-]+>/i) + if (!closingTagMatch) return null + + const secondOpeningEnd = openingTags[1].index! + openingTags[1][0].length + const closingStart = text.indexOf(closingTagMatch[0]) + const content = text.substring(secondOpeningEnd, closingStart) + + return content.replace(/\\n/g, '').replace(/\\"/g, '"').replace(/\n/g, '').trim() +} + +// Helper function to find result data directly from parts array +export const findResultForManualId = ( + parts: Readonly | undefined, + manualId: string +): any[] | undefined => { + if (!parts) return undefined + + const invocationPart = parts.find( + (part: MessagePart) => + part.type === 'tool-invocation' && + 'toolInvocation' in part && + part.toolInvocation.state === 'result' && + 'result' in part.toolInvocation && + part.toolInvocation.result?.manualToolCallId === manualId + ) + + if ( + invocationPart && + 'toolInvocation' in invocationPart && + 'result' in invocationPart.toolInvocation && + invocationPart.toolInvocation.result?.content?.[0]?.text + ) { + try { + const rawText = invocationPart.toolInvocation.result.content[0].text + + const extractedData = extractDataFromSafetyMessage(rawText) || rawText + + let parsedData = JSON.parse(extractedData.trim()) + return Array.isArray(parsedData) ? parsedData : undefined + } catch (error) { + console.error('Failed to parse tool invocation result data for manualId:', manualId, error) + return undefined + } + } + return undefined +} + +// [Joshen] From https://github.com/remarkjs/react-markdown/blob/fda7fa560bec901a6103e195f9b1979dab543b17/lib/index.js#L425 +export function defaultUrlTransform(value: string) { + const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i + const colon = value.indexOf(':') + const questionMark = value.indexOf('?') + const numberSign = value.indexOf('#') + const slash = value.indexOf('/') + + if ( + // If there is no protocol, it’s relative. + colon === -1 || + // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol. + (slash !== -1 && colon > slash) || + (questionMark !== -1 && colon > questionMark) || + (numberSign !== -1 && colon > numberSign) || + // It is a protocol, it should be allowed. + safeProtocol.test(value.slice(0, colon)) + ) { + return value + } + + return '' +} diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx index 9b482072b50..8575b46fd3a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { DragEvent, @@ -10,23 +11,38 @@ import { useRef, } from 'react' -import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' +import Link from 'next/link' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { Dashboards } from 'types' -import { Badge, cn, CodeBlock, CodeBlockLang } from 'ui' +import { + Badge, + Button, + cn, + CodeBlock, + CodeBlockLang, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogTitle, + DialogTrigger, +} from 'ui' import { DebouncedComponent } from '../DebouncedComponent' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' import { QueryBlock } from '../QueryBlock/QueryBlock' import { AssistantSnippetProps } from './AIAssistant.types' import { identifyQueryType } from './AIAssistant.utils' -import CollapsibleCodeBlock from './CollapsibleCodeBlock' +import { CollapsibleCodeBlock } from './CollapsibleCodeBlock' import { MessageContext } from './Message' +import { defaultUrlTransform } from './Message.utils' export const OrderedList = memo(({ children }: { children: ReactNode }) => (
    {children}
@@ -50,17 +66,63 @@ export const InlineCode = memo( ) InlineCode.displayName = 'InlineCode' -export const Link = memo(({ href, children }: { href?: string; children: ReactNode }) => ( - - {children} - -)) -Link.displayName = 'Link' +export const Hyperlink = memo(({ href, children }: { href?: string; children: ReactNode }) => { + const isExternalURL = !href?.startsWith('https://supabase.com/dashboard') + const safeUrl = defaultUrlTransform(href ?? '') + const isSafeUrl = safeUrl.length > 0 + + if (!isSafeUrl) { + return {children} + } + + return ( + + + + {children} + + + + + Verify the link before navigating + + + +

+ This link will take you to the following URL: +

+

{safeUrl}

+

Are you sure you want to head there?

+
+ + + + + + + + + +
+
+ ) +}) +Hyperlink.displayName = 'Hyperlink' const MemoizedQueryBlock = memo( ({ @@ -243,7 +305,7 @@ export const MarkdownPre = ({ } return ( -
+
{language === 'edge' ? ( { - const snap = useAppStateSnapshot() - const selectedOrganization = useSelectedOrganization() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const selectedProject = useSelectedProject() - - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(selectedProject?.ref!) - - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM - - return ( - snap.setShowAiSettingsModal(false)} - > - -
-

Schemas metadata to be shared with OpenAI

- 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - disabled={(IS_PLATFORM && !isOptedInToAI) || isHipaaProjectDisallowed} - selectedSchemas={selectedSchemas} - onSelectSchemas={setSelectedSchemas} - /> -

- Metadata includes table names, column names and their corresponding data types in the - request. This will generate queries that are more relevant to your project. -

-
- {IS_PLATFORM && (!isOptedInToAI || isHipaaProjectDisallowed) && selectedOrganization && ( - - - - {isHipaaProjectDisallowed - ? 'Sending data to OpenAI is disabled for HIPAA projects' - : 'Your organization does not allow sending anonymous data to OpenAI'} - - - This option is only available if your organization has opted-in to sending anonymous - data to OpenAI and non-HIPAA projects. You may configure your opt-in preferences - through your organization's settings. - - - - - - )} -
-
- ) -} - -export default AISettingsModal diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx index 5f600070348..549e51a2ad7 100644 --- a/apps/studio/components/ui/ActionCard.tsx +++ b/apps/studio/components/ui/ActionCard.tsx @@ -1,10 +1,11 @@ +import { JSX, ReactNode } from 'react' import { Badge, Card, cn } from 'ui' export const ActionCard = (card: { icon: JSX.Element title: string bgColor?: string - description?: string + description?: string | ReactNode isBeta?: boolean className?: string onClick?: () => void @@ -28,9 +29,13 @@ export const ActionCard = (card: { > {card.icon}
-
+

{card.title}

-
{card.description}
+ {typeof card.description === 'string' ? ( +
{card.description}
+ ) : ( + card.description + )}
diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8b2fceca792..e15ee1d9eed 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -39,13 +39,20 @@ const ChartHeader = ({ {title} {docsUrl && ( - - - + + + + + + + + Read docs + + )}
) diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 0c36f49f72e..a1d501fd655 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -28,13 +28,9 @@ import { } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' -import { - calculateTotalChartAggregate, - CustomLabel, - CustomTooltip, - type MultiAttribute, -} from './ComposedChart.utils' +import { calculateTotalChartAggregate, CustomLabel, CustomTooltip } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' +import { MultiAttribute } from './ComposedChart.utils' import { ChartHighlight } from './useChartHighlight' import { formatBytes } from 'lib/helpers' @@ -58,6 +54,7 @@ export interface ComposedChartProps extends CommonChartProps { chartStyle?: string onChartStyleChange?: (style: string) => void updateDateRange: any + titleTooltip?: string hideYAxis?: boolean hideHighlightedValue?: boolean syncId?: string @@ -114,6 +111,22 @@ export default function ComposedChart({ const { Container } = useChartSize(size) + const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) + + const formatTimestamp = (ts: unknown) => { + if (typeof ts !== 'number' && typeof ts !== 'string') { + return '' + } + + // Timestamps from auth logs can be in microseconds + if (typeof ts === 'number' && ts > 1e14) { + return day(ts / 1000).format(customDateFormat) + } + + // dayjs can handle ISO strings and millisecond numbers + return day(ts).format(customDateFormat) + } + // Default props const _XAxisProps = XAxisProps || { interval: data.length - 2, @@ -127,18 +140,19 @@ export default function ComposedChart({ width: 0, } - const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) - function getHeaderLabel() { if (!xAxisIsDate) { if (!focusDataIndex) return highlightedLabel - return data[focusDataIndex]?.timestamp + return data[focusDataIndex]?.[xAxisKey] } return ( (focusDataIndex !== null && data && data[focusDataIndex] !== undefined && - day(data[focusDataIndex].timestamp).format(customDateFormat)) || + (() => { + const ts = data[focusDataIndex][xAxisKey] + return formatTimestamp(ts) + })()) || highlightedLabel ) } @@ -149,13 +163,45 @@ export default function ComposedChart({ color: CHART_COLORS.REFERENCE_LINE, } + const chartData = + data && !!data[0] + ? Object.entries(data[0]) + ?.map(([key, value]) => ({ + name: key, + value: value, + })) + .filter( + (att) => + att.name !== 'timestamp' && + att.name !== 'period_start' && + att.name !== maxAttribute?.attribute && + attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false) + ) + .map((att, index) => { + const attribute = attributes.find((attr) => attr.attribute === att.name) + return { + ...att, + color: attribute?.color + ? resolvedTheme?.includes('dark') + ? attribute.color.dark + : attribute.color.light + : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], + } + }) + : [] + const lastDataPoint = !!data[data.length - 1] ? Object.entries(data[data.length - 1]) .map(([key, value]) => ({ dataKey: key, value: value as number, })) - .filter((entry) => entry.dataKey !== 'timestamp') + .filter( + (entry) => + entry.dataKey !== 'timestamp' && + entry.dataKey !== 'period_start' && + attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false) + ) : undefined const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line') @@ -184,32 +230,17 @@ export default function ComposedChart({ chartHighlight?.coordinates.right && chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right - const chartData = - data && !!data[0] - ? Object.entries(data[0]) - ?.map(([key, value], index) => ({ - name: key, - value: value, - color: STACKED_CHART_COLORS[index - (1 % STACKED_CHART_COLORS.length)], - })) - .filter( - (att) => - att.name !== 'timestamp' && - att.name !== maxAttribute?.attribute && - !referenceLines.map((a) => a.attribute).includes(att.name) - ) - : [] - const stackedAttributes = chartData.filter((att) => !att.name.includes('max')) const isPercentage = format === '%' const isRamChart = chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) const isDiskSpaceChart = chartData?.some((att: any) => att.name.toLowerCase().includes('disk_space_') ) - const isDiskSizeChart = chartData?.some((att: any) => att.name.toLowerCase().includes('disk_fs_')) + const isDBSizeChart = chartData?.some((att: any) => + att.name.toLowerCase().includes('pg_database_size') + ) const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_')) - const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDiskSizeChart || isNetworkChart - + const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart //* // Set the y-axis domain // to the highest value in the chart data for percentage charts @@ -387,6 +418,7 @@ export default function ComposedChart({ y={line.value} strokeWidth={1} {...line} + color={line.color?.dark} strokeDasharray={line.strokeDasharray ?? '3 3'} label={undefined} > @@ -418,13 +450,11 @@ export default function ComposedChart({ className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs" style={{ marginLeft: YAxisProps?.width }} > - - {xAxisIsDate ? day(data[0]?.timestamp).format(customDateFormat) : data[0]?.timestamp} - + {xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]} {xAxisIsDate - ? day(data[data?.length - 1]?.timestamp).format(customDateFormat) - : data[data?.length - 1]?.timestamp} + ? formatTimestamp(data[data.length - 1]?.[xAxisKey]) + : data[data.length - 1]?.[xAxisKey]}
)} diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 95444d52230..831f4b6153a 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -31,13 +31,19 @@ export interface ReportAttributes { hideHighlightedValue?: boolean } -type Provider = 'infra-monitoring' | 'daily-stats' | 'reference-line' | 'combine' +export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs' export type MultiAttribute = { attribute: string provider: Provider label?: string - color?: string + color?: { + light: string + dark: string + } + statusCode?: string + grantType?: string + providerType?: string stackId?: string format?: string description?: string @@ -65,6 +71,7 @@ export type MultiAttribute = { strokeDasharray?: string className?: string hide?: boolean + enabled?: boolean } interface CustomIconProps { @@ -167,7 +174,7 @@ const CustomTooltip = ({ return (
{getIcon(entry.color, isMax)} - + {attribute?.label || entry.name} @@ -194,9 +201,12 @@ const CustomTooltip = ({ >

{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}

- {payload.reverse().map((entry: any, index: number) => ( - - ))} + {payload + .reverse() + .filter((entry: any) => entry.value !== 0) + .map((entry: any, index: number) => ( + + ))} {active && showTotal && (
Total @@ -270,7 +280,7 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom {getIcon(entry.name, entry.color)} diff --git a/apps/studio/components/ui/Charts/LogChartHandler.tsx b/apps/studio/components/ui/Charts/LogChartHandler.tsx new file mode 100644 index 00000000000..2db174a9d19 --- /dev/null +++ b/apps/studio/components/ui/Charts/LogChartHandler.tsx @@ -0,0 +1,268 @@ +import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' +import { Loader2 } from 'lucide-react' +import { cn, WarningIcon } from 'ui' + +import Panel from 'components/ui/Panel' +import ComposedChart from './ComposedChart' + +import { AnalyticsInterval, DataPoint } from 'data/analytics/constants' +import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' +import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' +import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' +import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { useChartHighlight } from './useChartHighlight' +import { getMockDataForAttribute } from 'data/reports/auth-charts' + +import type { ChartData } from './Charts.types' +import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { MultiAttribute } from './ComposedChart.utils' + +interface LogChartHandlerProps { + id?: string + label: string + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: string + customDateFormat?: string + defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine' + hideChartType?: boolean + data?: ChartData + isLoading?: boolean + format?: string + highlightedValue?: string | number + className?: string + showTooltip?: boolean + showLegend?: boolean + showTotal?: boolean + showMaxValue?: boolean + updateDateRange: UpdateDateRange + valuePrecision?: number + isVisible?: boolean + titleTooltip?: string + docsUrl?: string +} + +/** + * Wrapper component that handles intersection observer logic for lazy loading + */ +const LazyChartWrapper = ({ children }: PropsWithChildren) => { + const [isVisible, setIsVisible] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true) + observer.disconnect() + } + }, + { + rootMargin: '150px 0px', // Start loading before the component enters viewport + threshold: 0, + } + ) + + const currentRef = ref.current + if (currentRef) { + observer.observe(currentRef) + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef) + } + } + }, []) + + return
{React.cloneElement(children as React.ReactElement, { isVisible })}
+} + +/** + * Controls chart display state. Optionally fetches static chart data if data is not provided. + * + * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render. + * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided. + * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown. + * + * Provided data must be in the expected chart format. + */ +const LogChartHandler = ({ + label, + attributes, + customDateFormat, + children = null, + defaultChartStyle = 'bar', + hideChartType = false, + data, + isLoading, + format, + highlightedValue, + className, + showTooltip, + showLegend, + showMaxValue, + showTotal, + updateDateRange, + valuePrecision, + titleTooltip, + id, + ...otherProps +}: PropsWithChildren) => { + const [chartStyle, setChartStyle] = useState(defaultChartStyle) + const chartHighlight = useChartHighlight() + + if (isLoading) { + return ( + + +

Loading data for {label}

+
+ ) + } + + if (!data) { + return ( +
+ +

Unable to load data for {label}

+
+ ) + } + + // Rest of the component remains similar, but pass all attributes to charts + return ( + + +
{children}
+ +
+
+ ) +} + +export const useAttributeQueries = ( + attributes: MultiAttribute[], + ref: string | string[] | undefined, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + databaseIdentifier: string | undefined, + data: ChartData | undefined, + isVisible: boolean +) => { + const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' + + const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') + const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') + const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') + const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') + + const infraQueries = useInfraMonitoringQueries( + infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + const dailyStatsQueries = useProjectDailyStatsQueries( + dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + + let infraIdx = 0 + let dailyStatsIdx = 0 + return attributes + .filter((attr) => attr.provider !== 'logs') + .map((attr) => { + if (attr.provider === 'infra-monitoring') { + return { + ...infraQueries[infraIdx++], + data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, + } + } else if (attr.provider === 'daily-stats') { + return { + ...dailyStatsQueries[dailyStatsIdx++], + data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, + } + } else if (attr.provider === 'mock') { + const mockData = getMockDataForAttribute(attr.attribute) + return { + isLoading: false, + data: { ...mockData, provider: 'mock', attribute: attr.attribute }, + } + } else if (attr.provider === 'reference-line') { + let value = attr.value || 0 + return { + data: { + data: [], + attribute: attr.attribute, + total: value, + maximum: value, + totalGrouped: { [attr.attribute]: value }, + provider: 'reference-line', + }, + isLoading: false, + isError: false, + } + } else { + return { + isLoading: false, + data: undefined, + } + } + }) +} + +export default function LazyLogChartHandler(props: LogChartHandlerProps) { + return ( + + + + ) +} diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 9ac56ba9df6..a45df0d6180 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -7,7 +7,6 @@ type CopyButtonBaseProps = { iconOnly?: boolean copyLabel?: string copiedLabel?: string - onCopy?: () => Promise | string } type CopyButtonWithText = CopyButtonBaseProps & { diff --git a/apps/studio/components/ui/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts new file mode 100644 index 00000000000..da947462782 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.constants.ts @@ -0,0 +1,50 @@ +import { addDays, addHours, endOfDay, startOfDay } from 'date-fns' + +import { DatePreset } from './DataTable.types' + +export const ARRAY_DELIMITER = ',' +export const SLIDER_DELIMITER = '-' +export const SPACE_DELIMITER = '_' +export const RANGE_DELIMITER = '-' +export const SORT_DELIMITER = '.' + +export const LEVELS = ['success', 'warning', 'error', 'info'] as const + +export const presets = [ + { + label: 'Today', + from: startOfDay(new Date()), + to: endOfDay(new Date()), + shortcut: 'd', // day + }, + { + label: 'Yesterday', + from: startOfDay(addDays(new Date(), -1)), + to: endOfDay(addDays(new Date(), -1)), + shortcut: 'y', + }, + { + label: 'Last hour', + from: addHours(new Date(), -1), + to: new Date(), + shortcut: 'h', + }, + { + label: 'Last 7 days', + from: startOfDay(addDays(new Date(), -7)), + to: endOfDay(new Date()), + shortcut: 'w', + }, + { + label: 'Last 14 days', + from: startOfDay(addDays(new Date(), -14)), + to: endOfDay(new Date()), + shortcut: 'b', // bi-weekly + }, + { + label: 'Last 30 days', + from: startOfDay(addDays(new Date(), -30)), + to: endOfDay(new Date()), + shortcut: 'm', + }, +] satisfies DatePreset[] diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts new file mode 100644 index 00000000000..9ee5cada849 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -0,0 +1,61 @@ +// TODO: we could type the value(!) especially when using enums +export type Option = { + label: string + value: string | boolean | number | undefined +} + +export type DatePreset = { + label: string + from: Date + to: Date + shortcut: string +} + +export type Input = { + type: 'input' + options?: Option[] +} + +export type Checkbox = { + type: 'checkbox' + component?: (props: Option) => JSX.Element | null + options?: Option[] +} + +export type Slider = { + type: 'slider' + min: number + max: number + // if options is undefined, we will provide all the steps between min and max + options?: Option[] +} + +export type Timerange = { + type: 'timerange' + options?: Option[] // required for TS + presets?: DatePreset[] +} + +export type Base = { + label: string + value: keyof TData + /** + * Defines if the accordion in the filter bar is open by default + */ + defaultOpen?: boolean + /** + * Defines if the command input is disabled for this field + */ + commandDisabled?: boolean +} + +export type DataTableCheckboxFilterField = Base & Checkbox +export type DataTableSliderFilterField = Base & Slider +export type DataTableInputFilterField = Base & Input +export type DataTableTimerangeFilterField = Base & Timerange + +export type DataTableFilterField = + | DataTableCheckboxFilterField + | DataTableSliderFilterField + | DataTableInputFilterField + | DataTableTimerangeFilterField diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts new file mode 100644 index 00000000000..12997306dab --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts @@ -0,0 +1,129 @@ +import { FilterFn } from '@tanstack/react-table' +import { isAfter, isBefore, isSameDay } from 'date-fns' + +import { LEVELS } from './DataTable.constants' + +export function formatCompactNumber(value: number) { + if (value >= 100 && value < 1000) { + return value.toString() // Keep the number as is if it's in the hundreds + } else if (value >= 1000 && value < 1000000) { + return (value / 1000).toFixed(1) + 'k' // Convert to 'k' for thousands + } else if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' // Convert to 'M' for millions + } else { + return value.toString() // Optionally handle numbers less than 100 if needed + } +} + +export function isArrayOfNumbers(arr: any): arr is number[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'number') +} + +export function isArrayOfDates(arr: any): arr is Date[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => item instanceof Date) +} + +export function isArrayOfStrings(arr: any): arr is string[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'string') +} + +export function isArrayOfBooleans(arr: any): arr is boolean[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'boolean') +} + +export const inDateRange: FilterFn = (row, columnId, value) => { + const date = new Date(row.getValue(columnId)) + const [start, end] = value as Date[] + + if (isNaN(date.getTime())) return false + + // if no end date, check if it's the same day + if (!end) return isSameDay(date, start) + + return isAfter(date, start) && isBefore(date, end) +} + +inDateRange.autoRemove = (val: any) => !Array.isArray(val) || !val.length || !isArrayOfDates(val) + +export const arrSome: FilterFn = (row, columnId, filterValue) => { + if (!Array.isArray(filterValue)) return false + return filterValue.some((val) => row.getValue(columnId) === val) +} + +arrSome.autoRemove = (val: any) => !Array.isArray(val) || !val?.length + +export function getLevelColor( + value: (typeof LEVELS)[number] +): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case 'success': + return { + text: 'text-muted', + bg: 'bg-muted', + border: 'border-muted', + } + case 'warning': + return { + text: 'text-warning', + bg: 'bg-warning', + border: 'border-warning', + } + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive', + border: 'border-destructive', + } + case 'info': + default: + return { + text: 'text-info', + bg: 'bg-info', + border: 'border-info', + } + } +} + +export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case '1': + case 'info': + return { + text: 'text-blue-500', + bg: '', + border: 'border-blue-200 dark:border-blue-800', + } + case '2': + case 'success': + return { + text: 'text-foreground-lighter', + bg: '', + border: 'border-green-200 dark:border-green-800', + } + case '4': + case 'warning': + case 'redirect': + return { + text: 'text-warning-600 dark:text-warning', + bg: 'bg-warning-300 dark:bg-waning-200', + border: 'border border-warning-400/50 dark:border-warning-400/50', + } + case '5': + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive-300 dark:bg-destructive-300/50', + border: 'border border-destructive-400/50 dark:border-destructive-400/50', + } + default: + return { + text: 'text-foreground-lighter', + bg: '', + border: '', + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx new file mode 100644 index 00000000000..7bd07547b55 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx @@ -0,0 +1,54 @@ +import { type Column } from '@tanstack/react-table' +import { ChevronDown, ChevronUp } from 'lucide-react' + +import { Button, cn, type ButtonProps } from 'ui' + +interface DataTableColumnHeaderProps extends ButtonProps { + column: Column + title: string +} + +export const DataTableColumnHeader = ({ + column, + title, + className, + ...props +}: DataTableColumnHeaderProps) => { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx new file mode 100644 index 00000000000..c0038b7c3dd --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx @@ -0,0 +1,17 @@ +import { cn } from 'ui' +import { LEVELS } from '../DataTable.constants' +import { getLevelColor } from '../DataTable.utils' + +export const DataTableColumnLevelIndicator = ({ + value, + className, +}: { + value: (typeof LEVELS)[number] + className?: string +}) => { + return ( +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx new file mode 100644 index 00000000000..175e3a477a8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx @@ -0,0 +1,35 @@ +import { Minus } from 'lucide-react' + +import { cn } from 'ui' +import { getStatusColor } from '../DataTable.utils' + +export const DataTableColumnStatusCode = ({ + value, + level, + className, +}: { + value?: number | string + level?: string + className?: string +}) => { + const colors = getStatusColor(level) + if (!value) { + return + } + + return ( +
+
+ {value} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx new file mode 100644 index 00000000000..94cca02970c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -0,0 +1,120 @@ +import { Search } from 'lucide-react' +import { useState } from 'react' + +import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui' +import type { DataTableCheckboxFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterCheckbox({ + value: _value, + options, + component, +}: DataTableCheckboxFilterField) { + const value = _value as string + const [inputValue, setInputValue] = useState('') + const { table, columnFilters, isLoading, isLoadingCounts, getFacetedUniqueValues } = + useDataTable() + + const column = table.getColumn(value) + // REMINDER: avoid using column?.getFilterValue() + const filterValue = columnFilters.find((i) => i.id === value)?.value + const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues() + + const Component = component + + // filter out the options based on the input value + const filterOptions = options?.filter( + (option) => inputValue === '' || option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + + // CHECK: it could be filterValue or searchValue + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + // REMINDER: if no options are defined, while fetching data, we should show a skeleton + if (isLoading && !filterOptions?.length) + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) + + if (!filterOptions?.length) return null + + return ( +
+ {options && options.length > 4 ? ( + } + containerClassName="h-9 rounded" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + /> + ) : null} + {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */} +
+ {filterOptions + // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default + // .sort((a, b) => a.label.localeCompare(b.label)) + .map((option, index) => { + const checked = filters.includes(option.value) + + return ( +
+ { + const newValue = checked + ? [...(filters || []), option.value] + : filters?.filter((value) => option.value !== value) + column?.setFilterValue(newValue?.length ? newValue : undefined) + }} + /> + +
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx new file mode 100644 index 00000000000..066f881d895 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -0,0 +1,404 @@ +import { formatDistanceToNow } from 'date-fns' +import { LoaderCircle, Search, X } from 'lucide-react' +import { ParserBuilder } from 'nuqs' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + CommandSeparator_Shadcn_ as CommandSeparator, + Separator, +} from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { Kbd } from '../primitives/Kbd' +import { useDataTable } from '../providers/DataTableProvider' +import { + columnFiltersParser, + getFieldOptions, + getFilterValue, + getWordByCaretPosition, + replaceInputByFieldType, +} from './DataTableFilters.utils' + +// FIXME: there is an issue on cmdk if I wanna only set a single slider value... + +interface DataTableFilterCommandProps { + // TODO: maybe use generics for the parser + searchParamsParser: Record> + placeholder?: string +} + +export function DataTableFilterCommand({ + searchParamsParser, + placeholder = 'Search data table...', +}: DataTableFilterCommandProps) { + const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const columnFilters = table.getState().columnFilters + const inputRef = useRef(null) + const [open, setOpen] = useState(false) + const [currentWord, setCurrentWord] = useState('') + const filterFields = useMemo( + () => _filterFields?.filter((i) => !i.commandDisabled), + [_filterFields] + ) + const columnParser = useMemo( + () => columnFiltersParser({ searchParamsParser, filterFields }), + [searchParamsParser, filterFields] + ) + const [inputValue, setInputValue] = useState(columnParser.serialize(columnFilters)) + const [lastSearches, setLastSearches] = useLocalStorage< + { + search: string + timestamp: number + }[] + >('data-table-command', []) + + const trimmedInputValue = inputValue.trim() + + // [Joshen] Temporarily disabling as this conflicts with our current CMD K behaviour + // useHotKey(() => setOpen((open) => !open), 'k') + + useEffect(() => { + // TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing + if (currentWord !== '' && open) return + // reset + if (currentWord !== '' && !open) setCurrentWord('') + // avoid recursion + if (trimmedInputValue === '' && !open) return + + const searchParams = columnParser.parse(inputValue) + + const currentFilters = table.getState().columnFilters + const currentEnabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return !field?.commandDisabled + }) + const currentDisabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return field?.commandDisabled + }) + + const commandDisabledFilterKeys = currentDisabledFilters.reduce( + (prev, curr) => { + prev[curr.id] = curr.value + return prev + }, + {} as Record + ) + + for (const key of Object.keys(searchParams)) { + const value = searchParams[key as keyof typeof searchParams] + table.getColumn(key)?.setFilterValue(value) + } + const currentFiltersToReset = currentEnabledFilters.filter((filter) => { + return !(filter.id in searchParams) + }) + for (const filter of currentFiltersToReset) { + table.getColumn(filter.id)?.setFilterValue(undefined) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, open, currentWord]) + + useEffect(() => { + // REMINDER: only update the input value if the command is closed (avoids jumps while open) + if (!open) { + setInputValue(columnParser.serialize(columnFilters)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters, filterFields, open]) + + useEffect(() => { + if (open) { + inputRef?.current?.focus() + } + }, [open]) + + return ( +
+ + + div]:border-none bg', + open ? 'visible' : 'hidden' + )} + filter={(value, search, keywords) => + getFilterValue({ value, search, keywords, currentWord }) + } + > + { + if (e.key === 'Escape') inputRef?.current?.blur() + }} + onBlur={() => { + setOpen(false) + // FIXME: doesnt reflect the jumps + // FIXME: will save non-existing searches + // TODO: extract into function + const search = inputValue.trim() + if (!search) return + const timestamp = Date.now() + const searchIndex = lastSearches.findIndex((item) => item.search === search) + if (searchIndex !== -1) { + lastSearches[searchIndex].timestamp = timestamp + setLastSearches(lastSearches) + return + } + setLastSearches([...lastSearches, { search, timestamp }]) + return + }} + onInput={(e) => { + const caretPosition = e.currentTarget?.selectionStart || -1 + const value = e.currentTarget?.value || '' + const word = getWordByCaretPosition({ value, caretPosition }) + setCurrentWord(word) + }} + placeholder={placeholder} + className="text-foreground" + /> +
+
+ {/* default height is 300px but in case of more, we'd like to tease the user */} + + + {filterFields.map((field) => { + if (typeof field.value !== 'string') return null + if (inputValue.includes(`${field.value}:`)) return null + // TBD: should we handle this in the component? + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => { + if (currentWord.trim() === '') { + const input = `${prev}${value}` + return `${input}:` + } + // lots of cheat + const isStarting = currentWord === prev + const prefix = isStarting ? '' : ' ' + const input = prev.replace(`${prefix}${currentWord}`, `${prefix}${value}`) + return `${input}:` + }) + setCurrentWord(`${value}:`) + }} + className="group" + > + {field.value} + + + ) + })} + + + + {filterFields?.map((field) => { + if (typeof field.value !== 'string') return null + if (!currentWord.includes(`${field.value}:`)) return null + + const column = table.getColumn(field.value) + const facetedValue = + getFacetedUniqueValues?.(table, field.value) || column?.getFacetedUniqueValues() + + const options = getFieldOptions({ field }) + + return options.map((optionValue) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => + replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, + }) + ) + setCurrentWord('') + }} + > + {`${optionValue}`} + {facetedValue?.has(optionValue) ? ( + + {formatCompactNumber(facetedValue.get(optionValue) || 0)} + + ) : null} + + ) + }) + })} + + + + {lastSearches + ?.sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 5) + .map((item) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + const search = value.replace('suggestion:', '') + setInputValue(`${search} `) + setCurrentWord('') + }} + className="group" + > + {item.search} + + {formatDistanceToNow(item.timestamp, { + addSuffix: true, + })} + + + + ) + })} + + No results found. + +
+
+ + Use ↑ ↓ to navigate + + + Enter to query + + + Esc to close + + + + Union: regions:a,b + + + Range: p95:59-340 + +
+ {lastSearches.length ? ( + + ) : null} +
+
+
+
+
+ ) +} + +function CommandItemSuggestions({ field }: { field: DataTableFilterField }) { + const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable() + const value = field.value as string + switch (field.type) { + case 'checkbox': { + return ( + + {getFacetedUniqueValues + ? Array.from(getFacetedUniqueValues(table, value)?.keys() || []) + .map((value) => `[${value}]`) + .join(' ') + : field.options?.map(({ value }) => `[${value}]`).join(' ')} + + ) + } + case 'slider': { + const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max] + return ( + + [{min} - {max}] + + ) + } + case 'input': { + return ( + + [{`${String(field.value)}`} input] + + ) + } + default: { + return null + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx new file mode 100644 index 00000000000..c5030af0153 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx @@ -0,0 +1,74 @@ +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, +} from 'ui' + +import { DataTableFilterCheckbox } from './DataTableFilterCheckbox' +import { DataTableFilterInput } from './DataTableFilterInput' +import { DataTableFilterResetButton } from './DataTableFilterResetButton' +import { DataTableFilterSlider } from './DataTableFilterSlider' +import { DataTableFilterTimerange } from './DataTableFilterTimerange' + +import { useDataTable } from '../providers/DataTableProvider' + +// FIXME: use @container (especially for the slider element) to restructure elements + +// TODO: only pass the columns to generate the filters! +// https://tanstack.com/table/v8/docs/framework/react/examples/filters + +export function DataTableFilterControls() { + const { filterFields } = useDataTable() + return ( + defaultOpen) + ?.map(({ value }) => value as string)} + > + {filterFields?.map((field) => { + const value = field.value as string + return ( + + +
+
+

{field.label}

+ {value !== field.label.toLowerCase() && !field.commandDisabled ? ( +

+ {value} +

+ ) : null} +
+ +
+
+ + {/* REMINDER: avoid the focus state to be cut due to overflow-hidden */} + {/* REMINDER: need to move within here because of accordion height animation */} +
+ {(() => { + switch (field.type) { + case 'checkbox': { + return + } + case 'slider': { + return + } + case 'input': { + return + } + case 'timerange': { + return + } + } + })()} +
+
+
+ ) + })} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx new file mode 100644 index 00000000000..835d77cc54e --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -0,0 +1,75 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import { FilterIcon } from 'lucide-react' +import { useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { useMediaQuery } from '../hooks/useMediaQuery' +import { Kbd } from '../primitives/Kbd' +import { DataTableFilterControls } from './DataTableFilterControls' + +export function DataTableFilterControlsDrawer() { + const triggerButtonRef = useRef(null) + const isMobile = useMediaQuery('(max-width: 640px)') + + useHotKey(() => { + triggerButtonRef.current?.click() + }, 'b') + + return ( + + + + + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+ + + + Filters + Adjust your table filters + + +
+ +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx new file mode 100644 index 00000000000..a41cc5d9656 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx @@ -0,0 +1,52 @@ +import { Search } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableInputFilterField } from '../DataTable.types' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'string' ? filterValue : null +} + +export function DataTableFilterInput({ value: _value }: DataTableInputFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + const newValue = debouncedInput?.trim() === '' ? null : debouncedInput + if (debouncedInput === null) return + column?.setFilterValue(newValue) + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.trim() !== filters) { + setInput(filters) + } + }, [filters]) + + return ( +
+ + } + containerClassName="h-9 rounded" + name={value} + id={value} + value={input || ''} + onChange={(e) => setInput(e.target.value)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx new file mode 100644 index 00000000000..2279eb48b17 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx @@ -0,0 +1,35 @@ +import { X } from 'lucide-react' + +import { Button } from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterResetButton({ value: _value }: DataTableFilterField) { + const { columnFilters, table } = useDataTable() + const value = _value as string + const column = table.getColumn(value) + const filterValue = columnFilters.find((f) => f.id === value)?.value + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + if (filters.length === 0) return null + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx new file mode 100644 index 00000000000..8882c6fe91d --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableSliderFilterField } from '../DataTable.types' +import { isArrayOfNumbers } from '../DataTable.utils' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { Slider } from '../primitives/Slider' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'number' + ? [filterValue, filterValue] + : Array.isArray(filterValue) && isArrayOfNumbers(filterValue) + ? filterValue.length === 1 + ? [filterValue[0], filterValue[0]] + : filterValue + : null +} + +// TODO: discuss if we even need the `defaultMin` and `defaultMax` +export function DataTableFilterSlider({ + value: _value, + min: defaultMin, + max: defaultMax, +}: DataTableSliderFilterField) { + const value = _value as string + const { table, columnFilters, getFacetedMinMaxValues } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + const [min, max] = getFacetedMinMaxValues?.(table, value) || + column?.getFacetedMinMaxValues() || [defaultMin, defaultMax] + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + if (debouncedInput?.length === 2) { + column?.setFilterValue(debouncedInput) + } + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.length !== 2) { + } else if (!filters) { + setInput(null) + } else if (debouncedInput[0] !== filters[0] || debouncedInput[1] !== filters[1]) { + setInput(filters) + } + }, [filters]) + + return ( +
+
+
+ + setInput((prev) => [Number(e.target.value), prev?.[1] || max])} + /> +
+
+ + setInput((prev) => [prev?.[0] || min, Number(e.target.value)])} + /> +
+
+ setInput(values)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx new file mode 100644 index 00000000000..9981ed31e61 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import type { DateRange } from 'react-day-picker' + +import type { DataTableTimerangeFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' +import { DatePickerWithRange } from '../DatePickerWithRange' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterTimerange({ + value: _value, + presets, +}: DataTableTimerangeFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + + const date: DateRange | undefined = useMemo( + () => + filterValue instanceof Date + ? { from: filterValue, to: undefined } + : Array.isArray(filterValue) && isArrayOfDates(filterValue) + ? { from: filterValue?.[0], to: filterValue?.[1] } + : undefined, + [filterValue] + ) + + const setDate = (date: DateRange | undefined) => { + if (!date) return // TODO: remove from search params if columnFilter is removed + if (date.from && !date.to) { + column?.setFilterValue([date.from]) + } + if (date.to && date.from) { + column?.setFilterValue([date.from, date.to]) + } + } + + return +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts new file mode 100644 index 00000000000..c65b6106b07 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts @@ -0,0 +1,259 @@ +import { ColumnFiltersState } from '@tanstack/react-table' +import { ParserBuilder } from 'nuqs' + +import { ARRAY_DELIMITER, RANGE_DELIMITER, SLIDER_DELIMITER } from '../DataTable.constants' +import type { DataTableFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' + +/** + * Extracts the word from the given string at the specified caret position. + */ +export function getWordByCaretPosition({ + value, + caretPosition, +}: { + value: string + caretPosition: number +}) { + let start = caretPosition + let end = caretPosition + + while (start > 0 && value[start - 1] !== ' ') start-- + while (end < value.length && value[end] !== ' ') end++ + + const word = value.substring(start, end) + return word +} + +export function replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, +}: { + prev: string + currentWord: string + optionValue?: string | number | boolean | undefined // FIXME: use DataTableFilterField["options"][number]; + value: string + field: DataTableFilterField +}) { + switch (field.type) { + case 'checkbox': { + if (currentWord.includes(ARRAY_DELIMITER)) { + const words = currentWord.split(ARRAY_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER)) + return `${input.trim()} ` + } + } + case 'slider': { + if (currentWord.includes(SLIDER_DELIMITER)) { + const words = currentWord.split(SLIDER_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER)) + return `${input.trim()} ` + } + } + case 'timerange': { + if (currentWord.includes(RANGE_DELIMITER)) { + const words = currentWord.split(RANGE_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(RANGE_DELIMITER)) + return `${input.trim()} ` + } + } + default: { + const input = prev.replace(currentWord, value) + return `${input.trim()} ` + } + } +} + +export function getFieldOptions({ field }: { field: DataTableFilterField }) { + switch (field.type) { + case 'slider': { + return field.options?.length + ? field.options + .map(({ value }) => value) + .sort((a, b) => Number(a) - Number(b)) + .filter(notEmpty) + : Array.from({ length: field.max - field.min + 1 }, (_, i) => field.min + i) || [] + } + default: { + return field.options?.map(({ value }) => value).filter(notEmpty) || [] + } + } +} + +export function getFilterValue({ + value, + search, + currentWord, +}: { + value: string + search: string + keywords?: string[] | undefined + currentWord: string +}): number { + /** + * @example value "suggestion:public:true regions,ams,gru,fra" + */ + if (value.startsWith('suggestion:')) { + const rawValue = value.toLowerCase().replace('suggestion:', '') + if (rawValue.includes(search)) return 1 + return 0 + } + + /** */ + if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1 + + /** + * @example checkbox [filter, query] = ["regions", "ams,gru,fra"] + * @example slider [filter, query] = ["p95", "0-3000"] + * @example input [filter, query] = ["name", "api"] + */ + const [filter, query] = currentWord.toLowerCase().split(':') + if (query && value.startsWith(`${filter}:`)) { + if (query.includes(ARRAY_DELIMITER)) { + /** + * array of n elements + * @example queries = ["ams", "gru", "fra"] + */ + const queries = query.split(ARRAY_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (queries.some((item, i) => item === rawValue && i !== queries.length - 1)) return 0 + if (queries.some((item) => rawValue.includes(item))) return 1 + } + if (query.includes(SLIDER_DELIMITER)) { + /** + * range between 2 elements + * @example queries = ["0", "3000"] + */ + const queries = query.split(SLIDER_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + + const rawValueAsNumber = Number.parseInt(rawValue) + const queryAsNumber = Number.parseInt(queries[0]) + + if (queryAsNumber < rawValueAsNumber) { + if (rawValue.includes(queries[1])) return 1 + return 0 + } + return 0 + } + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (rawValue.includes(query)) return 1 + } + return 0 +} + +export function getFieldValueByType({ + field, + value, +}: { + field?: DataTableFilterField + value: unknown +}) { + if (!field) return null + + switch (field.type) { + case 'slider': { + if (Array.isArray(value)) { + return value.join(SLIDER_DELIMITER) + } + return value + } + case 'checkbox': { + if (Array.isArray(value)) { + return value.join(ARRAY_DELIMITER) + } + // REMINER: inversed logic + if (typeof value === 'string') { + return value.split(ARRAY_DELIMITER) + } + return value + } + case 'timerange': { + if (Array.isArray(value)) { + if (isArrayOfDates(value)) { + return value.map((date) => date.getTime()).join(RANGE_DELIMITER) + } + return value.join(RANGE_DELIMITER) + } + if (value instanceof Date) { + return value.getTime() + } + return value + } + default: { + return value + } + } +} + +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +} + +export function columnFiltersParser({ + searchParamsParser, + filterFields, +}: { + searchParamsParser: Record> + filterFields: DataTableFilterField[] +}) { + return { + parse: (inputValue: string) => { + // Use regex to properly extract field:value pairs + // This properly handles spaces within values + const filterPairs: Record = {} + + // This regex properly extracts field:value pairs where: + // 1. Values can contain spaces (both normal spaces and URL-encoded + characters) + // 2. It finds word characters followed by a colon as the field name + // 3. It then captures everything (including spaces and + characters) until + // it finds another field:value pattern or the end of string + // This solves the issue with values like "edge function" or "edge+function" in URLs + const regex = /(\w+):([^]*?)(?=\s+\w+:|$)/g + let match + + console.log('DataTableFilterCommand parsing input:', inputValue) + + while ((match = regex.exec(inputValue)) !== null) { + const [_, fieldName, fieldValue] = match + if (fieldName && fieldValue) { + filterPairs[fieldName] = fieldValue.trim() + console.log(`Parsed filter pair: ${fieldName} = ${fieldValue.trim()}`) + } + } + + const searchParams = Object.entries(filterPairs).reduce( + (prev, [key, value]) => { + const parser = searchParamsParser[key] + if (!parser) return prev + + prev[key] = parser.parse(value) + return prev + }, + {} as Record + ) + + return searchParams + }, + serialize: (columnFilters: ColumnFiltersState) => { + const values = columnFilters.reduce((prev, curr) => { + const { commandDisabled } = filterFields?.find((field) => curr.id === field.value) || { + commandDisabled: true, + } // if column filter is not found, disable the command by default + const parser = searchParamsParser[curr.id] + + if (commandDisabled || !parser) return prev + + return `${prev}${curr.id}:${parser.serialize(curr.value)} ` + }, '') + + return values + }, + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx new file mode 100644 index 00000000000..5222d38f14c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx @@ -0,0 +1,35 @@ +import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react' +import { cn } from 'ui' + +export const DataTableHeaderLayout = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + setTopBarHeight: (height: number) => void + }> +>(({ setTopBarHeight, ...props }, ref) => { + const topBarRef = useRef(null) + + useEffect(() => { + const observer = new ResizeObserver(() => { + const rect = topBarRef.current?.getBoundingClientRect() + if (rect) { + setTopBarHeight(rect.height) + } + }) + + const topBar = topBarRef.current + if (!topBar) return + + observer.observe(topBar) + return () => observer.unobserve(topBar) + }, [topBarRef]) + + return ( +
+ ) +}) +DataTableHeaderLayout.displayName = 'DataTableHeaderLayout' diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx new file mode 100644 index 00000000000..8dec475c052 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -0,0 +1,224 @@ +import { type FetchNextPageOptions } from '@tanstack/react-query' +import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' +import { LoaderCircle } from 'lucide-react' +import { useQueryState } from 'nuqs' +import { Fragment, memo, ReactNode, UIEvent, useCallback, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { useDataTable } from './providers/DataTableProvider' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table' + +// TODO: add a possible chartGroupBy +export interface DataTableInfiniteProps { + columns: ColumnDef[] + defaultColumnVisibility?: VisibilityState + totalRows?: number + filterRows?: number + totalRowsFetched?: number + isFetching?: boolean + isLoading?: boolean + hasNextPage?: boolean + fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise + renderLiveRow?: (props?: { row: Row }) => ReactNode + setColumnOrder: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: VisibilityState) => void + + // [Joshen] See if we can type this properly + searchParamsParser: any +} + +// [Joshen] JFYI this component is NOT virtualized and hence will struggle handling many data points +export function DataTableInfinite({ + columns, + defaultColumnVisibility = {}, + fetchNextPage, + hasNextPage, + totalRows = 0, + filterRows = 0, + totalRowsFetched = 0, + renderLiveRow, + setColumnOrder, + setColumnVisibility, + searchParamsParser, +}: DataTableInfiniteProps) { + const { table, isLoading, isFetching } = useDataTable() + const tableRef = useRef(null) + + const headerGroups = table.getHeaderGroups() + const headers = headerGroups[0].headers + const rows = table.getRowModel().rows ?? [] + + const onScroll = useCallback( + (e: UIEvent) => { + const onPageBottom = + Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= + e.currentTarget.scrollHeight + + if (onPageBottom && !isFetching && totalRows > totalRowsFetched) { + fetchNextPage() + } + }, + [fetchNextPage, isFetching, totalRows, totalRowsFetched] + ) + + useHotKey(() => { + setColumnOrder([]) + setColumnVisibility(defaultColumnVisibility) + }, 'u') + + return ( + + + + {headers.map((header) => { + const sort = header.column.getIsSorted() + const canResize = header.column.getCanResize() + const onResize = header.getResizeHandler() + const headerClassName = (header.column.columnDef.meta as any)?.headerClassName + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {canResize && ( +
header.column.resetSize()} + onMouseDown={onResize} + onTouchStart={onResize} + className={cn( + 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center', + 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border' + )} + /> + )} + + ) + })} + + + + {rows.length ? ( + rows.map((row) => ( + // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 + + {renderLiveRow?.({ row: row as any })} + + + )) + ) : ( + + {renderLiveRow?.()} + + + {isLoading ? 'Retrieving logs...' : 'No results'} + + + + )} + + + {hasNextPage || isFetching || isLoading ? ( +
+ +

+ Showing{' '} + + {formatCompactNumber(totalRowsFetched)} + {' '} + of {formatCompactNumber(totalRows)}{' '} + rows +

+
+ ) : ( +

+ No more data to load ( + {formatCompactNumber(filterRows)} of{' '} + {formatCompactNumber(totalRows)}{' '} + rows) +

+ )} +
+
+
+
+ ) +} + +/** + * REMINDER: this is the heaviest component in the table if lots of rows + * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height) + * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader + */ + +function DataTableRow({ + row, + table, + selected, + searchParamsParser, +}: { + row: Row + table: TTable + selected?: boolean + searchParamsParser: any +}) { + useQueryState('live', searchParamsParser.live) + const rowClassName = (table.options.meta as any)?.getRowClassName?.(row) + const cells = row.getVisibleCells() + + return ( + row.toggleSelected()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + row.toggleSelected() + } + }} + className={cn(rowClassName)} + > + {cells.map((cell) => { + const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) +} + +// [Joshen] Using MemoizedRow will cause the column visibility to break as the rows aren't getting re-rendered +const MemoizedRow = memo( + DataTableRow, + (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected +) as typeof DataTableRow diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx new file mode 100644 index 00000000000..06a80799f08 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -0,0 +1,37 @@ +import { X } from 'lucide-react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableResetButton() { + const { table } = useDataTable() + useHotKey(table.resetColumnFilters, 'Escape') + + return ( + + + + + + +

+ Reset filters with{' '} + + ⌘ + Esc + +

+
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx new file mode 100644 index 00000000000..922d38bc3ee --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -0,0 +1,135 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { ReactNode, useCallback, useEffect, useMemo } from 'react' + +import { + Button, + cn, + Separator, + Skeleton, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export interface DataTableSheetDetailsProps { + title?: ReactNode + titleClassName?: string + children?: ReactNode +} + +export function DataTableSheetDetails({ + title, + titleClassName, + children, +}: DataTableSheetDetailsProps) { + const { table, rowSelection, isLoading } = useDataTable() + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + const selectedRow = useMemo(() => { + if (isLoading && !selectedRowKey) return + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [selectedRowKey, isLoading]) + + const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRow?.id) + + const nextId = useMemo(() => table.getCoreRowModel().flatRows[index + 1]?.id, [index, isLoading]) + + const prevId = useMemo(() => table.getCoreRowModel().flatRows[index - 1]?.id, [index, isLoading]) + + const onPrev = useCallback(() => { + if (prevId) table.setRowSelection({ [prevId]: true }) + }, [prevId, isLoading]) + + const onNext = useCallback(() => { + if (nextId) table.setRowSelection({ [nextId]: true }) + }, [nextId, isLoading]) + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!selectedRowKey) return + + // REMINDER: prevent dropdown navigation inside of sheet to change row selection + const activeElement = document.activeElement + const isMenuActive = activeElement?.closest('[role="menu"]') + + if (isMenuActive) return + + if (e.key === 'ArrowUp') { + e.preventDefault() + onPrev() + } + if (e.key === 'ArrowDown') { + e.preventDefault() + onNext() + } + } + + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [selectedRowKey, onNext, onPrev]) + + return ( +
+
+
+ {isLoading && !selectedRowKey ? : title} +
+
+ + + +
+
+ + {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx new file mode 100644 index 00000000000..d1cec2acbfe --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx @@ -0,0 +1,186 @@ +import { Table } from '@tanstack/react-table' +import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns' +import { + CalendarClock, + CalendarDays, + CalendarSearch, + ChevronLeft, + ChevronRight, + Copy, + Equal, + Search, +} from 'lucide-react' +import { ComponentPropsWithRef } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +interface DataTableSheetRowActionProps> + extends ComponentPropsWithRef { + fieldValue: TFields['value'] + filterFields: TFields[] + value: string | number + table: Table +} + +export function DataTableSheetRowAction>({ + fieldValue, + filterFields, + value, + children, + className, + table, + onKeyDown, + ...props +}: DataTableSheetRowActionProps) { + const { copy, isCopied } = useCopyToClipboard() + const field = filterFields.find((field) => field.value === fieldValue) + const column = table.getColumn(fieldValue.toString()) + + if (!field || !column) return null + + function renderOptions() { + if (!field) return null + switch (field.type) { + case 'checkbox': + return ( + { + const filterValue = column?.getFilterValue() as undefined | Array + const newValue = filterValue?.includes(value) + ? filterValue + : [...(filterValue || []), value] + + column?.setFilterValue(newValue) + }} + className="flex items-center gap-2" + > + + Include + + ) + case 'input': + return ( + column?.setFilterValue(value)} + className="flex items-center gap-2" + > + + Include + + ) + case 'slider': + return ( + + column?.setFilterValue([0, value])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Less or equal than + + column?.setFilterValue([value, 5000])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Greater or equal than + + column?.setFilterValue([value])} + className="flex items-center gap-2" + > + + Equal to + + + ) + case 'timerange': + const date = new Date(value) + return ( + + column?.setFilterValue([date])} + className="flex items-center gap-2" + > + + Exact timestamp + + { + const start = startOfHour(date) + const end = endOfHour(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same hour + + { + const start = startOfDay(date) + const end = endOfDay(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same day + + + ) + default: + return null + } + } + + return ( + + { + if (e.key === 'ArrowDown') { + // REMINDER: default behavior is to open the dropdown menu + // But because we use it to navigate between rows, we need to prevent it + // and only use "Enter" to select the option + e.preventDefault() + } + onKeyDown?.(e) + }} + {...props} + > + {children} + {isCopied ? ( +
Value copied
+ ) : null} +
+ + {renderOptions()} + + copy(String(value), { timeout: 1000 })} + className="flex items-center gap-2" + > + + Copy value + + +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx new file mode 100644 index 00000000000..5898566d139 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx @@ -0,0 +1,52 @@ +import { CSSProperties, ReactNode, useMemo } from 'react' + +import { cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableSideBarLayoutProps { + children: ReactNode + className?: string + topBarHeight?: number +} + +export function DataTableSideBarLayout({ + children, + className, + topBarHeight = 0, +}: DataTableSideBarLayoutProps) { + const { table } = useDataTable() + + /** + * https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance + * Instead of calling `column.getSize()` on every render for every header + * and especially every data cell (very expensive), + * we will calculate all column sizes at once at the root table level in a useMemo + * and pass the column sizes down as CSS variables to the element. + */ + const columnSizeVars = useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: string } = {} + for (let i = 0; i < headers.length; i++) { + const header = headers[i]! + // REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns") + colSizes[`--header-${header.id.replace('.', '-')}-size`] = `${header.getSize()}px` + colSizes[`--col-${header.column.id.replace('.', '-')}-size`] = `${header.column.getSize()}px` + } + return colSizes + }, [ + // TODO: check if we need this + table.getState().columnSizingInfo, + table.getState().columnSizing, + table.getState().columnVisibility, + ]) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..906e89dee00 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx @@ -0,0 +1,82 @@ +import { PanelLeftClose } from 'lucide-react' +import { ReactNode, useMemo } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' +import { DataTableResetButton } from './DataTableResetButton' +import { DataTableViewOptions } from './DataTableViewOptions' +import { Kbd } from './primitives/Kbd' +import { useControls } from './providers/ControlsProvider' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableToolbarProps { + renderActions?: () => ReactNode +} + +export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { + const { table, isLoading, columnFilters } = useDataTable() + const { open, setOpen } = useControls() + const filters = table.getState().columnFilters + + useHotKey(() => setOpen((prev) => !prev), 'b') + + const rows = useMemo( + () => ({ + total: table.getCoreRowModel().rows.length, + filtered: table.getFilteredRowModel().rows.length, + }), + [isLoading, columnFilters] + ) + + return ( +
+
+ + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+
+ +
+
+

+ {formatCompactNumber(rows.filtered)} of{' '} + {formatCompactNumber(rows.total)} row(s){' '} + filtered +

+

+ {formatCompactNumber(rows.filtered)}{' '} + row(s) +

+
+
+
+ {filters.length ? : null} + {renderActions?.()} + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..24fc9a6641a --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,95 @@ +import { GripVertical, Settings2 } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { + Button, + Checkbox_Shadcn_, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { Sortable, SortableDragHandle, SortableItem } from './primitives/Sortable' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableViewOptions() { + const { table, enableColumnOrdering } = useDataTable() + const [open, setOpen] = useState(false) + const [drag, setDrag] = useState(false) + const [search, setSearch] = useState('') + + const columnOrder = table.getState().columnOrder + + const sortedColumns = useMemo( + () => + table.getAllColumns().sort((a, b) => { + return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id) + }), + [columnOrder] + ) + + return ( + + + + + +
+
+ +
+
+ +
+ + +
+ + +
+
+ + ) +} + +function DatePresets({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + return ( +
+

Date Range

+
+ {presets.map(({ label, shortcut, from, to }) => { + const isActive = selected?.from === from && selected?.to === to + return ( + + ) + })} +
+
+ ) +} + +function DatePresetsSelect({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + function findPreset(from?: Date, to?: Date) { + return presets.find((p) => p.from === from && p.to === to)?.shortcut + } + const [value, setValue] = useState(findPreset(selected?.from, selected?.to)) + + useEffect(() => { + const preset = findPreset(selected?.from, selected?.to) + if (preset === value) return + setValue(preset) + }, [selected, presets]) + + return ( + + ) +} + +// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times +function CustomDateRange({ + selected, + onSelect, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void +}) { + const [dateFrom, setDateFrom] = useState(selected?.from) + const [dateTo, setDateTo] = useState(selected?.to) + const debounceDateFrom = useDebounce(dateFrom, 1000) + const debounceDateTo = useDebounce(dateTo, 1000) + + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return '' + const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + return utcDate.toISOString().slice(0, 16) + } + + useEffect(() => { + onSelect({ from: debounceDateFrom, to: debounceDateTo }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceDateFrom, debounceDateTo]) + + return ( +
+

Custom Range

+
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateFrom(newDate) + } + }} + disabled={!selected?.from} + /> +
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateTo(newDate) + } + }} + disabled={!selected?.to} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx new file mode 100644 index 00000000000..d5be1d62a3d --- /dev/null +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -0,0 +1,28 @@ +import { cn } from 'ui' +import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' +import { DataTableResetButton } from './DataTableResetButton' +import { useDataTable } from './providers/DataTableProvider' + +export function FilterSideBar() { + const { table } = useDataTable() + + return ( +
+
+
+

Filters

+
{table.getState().columnFilters.length ? : null}
+
+
+
+ +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx new file mode 100644 index 00000000000..ebb1c3f04e2 --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveButton.tsx @@ -0,0 +1,71 @@ +import type { FetchPreviousPageOptions } from '@tanstack/react-query' +import { CirclePause, CirclePlay } from 'lucide-react' +import { useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +const REFRESH_INTERVAL = 10_000 + +interface LiveButtonProps { + searchParamsParser: any + fetchPreviousPage?: (options?: FetchPreviousPageOptions | undefined) => Promise +} + +export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) { + const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser) + const { table } = useDataTable() + useHotKey(handleClick, 'j') + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + async function fetchData() { + if (live) { + await fetchPreviousPage?.() + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL) + } else { + clearTimeout(timeoutId) + } + } + + fetchData() + + return () => { + clearTimeout(timeoutId) + } + }, [live, fetchPreviousPage]) + + // REMINDER: make sure to reset live when date is set + // TODO: test properly + useEffect(() => { + if ((date || sort) && live) { + setSearch((prev) => ({ ...prev, live: null })) + } + }, [date, sort]) + + function handleClick() { + setSearch((prev) => ({ + ...prev, + live: !prev.live, + date: null, + sort: null, + })) + table.getColumn('date')?.setFilterValue(undefined) + table.resetSorting() + } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx new file mode 100644 index 00000000000..76c63acd8be --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveRow.tsx @@ -0,0 +1,18 @@ +import { TableCell, TableRow } from 'ui' +import { DataTableColumnLevelIndicator } from './DataTableColumn/DataTableColumnLevelIndicator' + +export function LiveRow({ colSpan }: { colSpan: number }) { + return ( + + + + + + Live Mode + + + ) +} diff --git a/apps/studio/components/ui/DataTable/RefreshButton.tsx b/apps/studio/components/ui/DataTable/RefreshButton.tsx new file mode 100644 index 00000000000..2adee376572 --- /dev/null +++ b/apps/studio/components/ui/DataTable/RefreshButton.tsx @@ -0,0 +1,26 @@ +import { LoaderCircle, RefreshCcw } from 'lucide-react' +import { Button } from 'ui' + +interface RefreshButtonProps { + isLoading: boolean + onRefresh: () => void +} + +export const RefreshButton = ({ isLoading, onRefresh }: RefreshButtonProps) => { + return ( + - + {additionalOptions.length > 0 && ( diff --git a/apps/studio/components/ui/DotGrid.tsx b/apps/studio/components/ui/DotGrid.tsx index 6e0fdbdccf1..03b52f87c06 100644 --- a/apps/studio/components/ui/DotGrid.tsx +++ b/apps/studio/components/ui/DotGrid.tsx @@ -6,7 +6,7 @@ interface DotGridProps { count: number } -const DotGrid = ({ rows, columns, count }: DotGridProps) => { +export const DotGrid = ({ rows, columns, count }: DotGridProps) => { const container = { hidden: { opacity: 1 }, visible: { @@ -65,5 +65,3 @@ const DotGrid = ({ rows, columns, count }: DotGridProps) => { ) } - -export default DotGrid diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index acfb5394781..ee70fab8c8f 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -12,9 +12,10 @@ import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useAppStateSnapshot } from 'state/app-state' @@ -55,8 +56,8 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { const { profile } = useProfile() const snapV2 = useSqlEditorV2StateSnapshot() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM + const { includeSchemaMetadata } = useOrgAiOptInLevel() + const useBedrockAssistant = useFlag('useBedrockAssistant') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState() @@ -236,7 +237,10 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { try { setIsSaving(true) - const { title: name } = await generateSqlTitle({ sql: currentValue }) + const { title: name } = await generateSqlTitle({ + sql: currentValue, + useBedrockAssistant, + }) const snippet = createSqlSnippetSkeletonV2({ id: uuidv4(), name, @@ -276,7 +280,11 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { language="pgsql" value={currentValue} onChange={handleChange} - aiEndpoint={`${BASE_PATH}/api/ai/sql/complete`} + aiEndpoint={ + useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/complete-v2` + : `${BASE_PATH}/api/ai/sql/complete` + } aiMetadata={{ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/ui/GroupsTelemetry.tsx b/apps/studio/components/ui/GroupsTelemetry.tsx index 1c05ec91cb5..d138179ec2e 100644 --- a/apps/studio/components/ui/GroupsTelemetry.tsx +++ b/apps/studio/components/ui/GroupsTelemetry.tsx @@ -8,7 +8,6 @@ import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identi import { useSendGroupsResetMutation } from 'data/telemetry/send-groups-reset-mutation' import { usePrevious } from 'hooks/deprecated' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useAppStateSnapshot } from 'state/app-state' import { IS_PLATFORM } from 'lib/constants' const getAnonId = async (id: string) => { diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx new file mode 100644 index 00000000000..be2b35973bc --- /dev/null +++ b/apps/studio/components/ui/InfoPill.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' +import { Badge } from 'ui/src/components/shadcn/ui/badge' +import { ExternalLink } from 'lucide-react' + +interface Link { + url: string + label: string +} + +interface InfoPillProps { + label: string + icon: React.ReactNode + title: string + description: string + links?: Link[] +} + +export const InfoPill: React.FC = ({ label, icon, title, description, links }) => { + return ( + + + + {icon} {label} + + + +
+ {React.cloneElement(icon as React.ReactElement, { + className: 'w-5 h-5', + strokeWidth: 1.5, + })} +
+
+

{title}

+

{description}

+ {links && links.length > 0 && ( +
+ {links.map((link, index) => ( + + + {link.label} + + ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx index 0c17e4a676f..a24bc09ef89 100644 --- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -1,63 +1,68 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { toast } from 'sonner' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation' import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' -import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' +import Panel from '../Panel' export const ToggleLegacyApiKeysPanel = () => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() const [isConfirmOpen, setIsConfirmOpen] = useState(false) const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = - useLegacyAPIKeysStatusQuery({ projectRef: project!.ref }) + useLegacyAPIKeysStatusQuery({ projectRef }) const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') + const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {} + if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) { return null } return (
- - - - {legacyAPIKeysStatusData.enabled - ? 'Disabling your legacy API keys may cause your applications to break.' - : 'Re-enabling your legacy API keys may expose your applications to security risks.'} - - - {legacyAPIKeysStatusData.enabled - ? 'Make sure you are no longer using your legacy API keys before proceeding.' - : "Make sure you've tested your RLS policies."} - -
- setIsConfirmOpen(true)} - disabled={!canUpdateAPIKeys} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateAPIKeys - ? 'You need additional permissions to enable or disable JWT-based API keys' - : undefined, - }, - }} - > - {legacyAPIKeysStatusData.enabled - ? 'Disable JWT-based API keys' - : 'Re-enable JWT-based API keys'} - -
-
+ + +
+
+

+ {isLegacyKeysEnabled ? 'Disable legacy API keys' : 'Re-enabling legacy API keys'} +

+

+ {isLegacyKeysEnabled + ? 'Make sure you are no longer using your legacy API keys before proceeding.' + : 'Ensure that your RLS policies are in place prior to re-enabling legacy keys.'} +

+
+
+ setIsConfirmOpen(true)} + disabled={!canUpdateAPIKeys} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateAPIKeys + ? 'You need additional permissions to enable or disable JWT-based API keys' + : undefined, + }, + }} + > + {legacyAPIKeysStatusData.enabled + ? 'Disable JWT-based API keys' + : 'Re-enable JWT-based API keys'} + +
+
+
+
void legacyAPIKeysStatusData: { enabled: boolean } }) => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() + const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {} const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = useToggleLegacyAPIKeysMutation() @@ -86,7 +92,7 @@ const ToggleApiKeysModal = ({ const enabled = !legacyAPIKeysStatusData?.enabled toggleLegacyAPIKey( - { projectRef: project!.ref, enabled }, + { projectRef, enabled }, { onSuccess: () => { toast.success( @@ -102,27 +108,37 @@ const ToggleApiKeysModal = ({ return ( onClose()} onConfirm={onToggleLegacyAPIKeysEnabled} - title={ - legacyAPIKeysStatusData.enabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys' - } - confirmString={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} - confirmLabel={`Yes, ${legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} anon and service_role`} - confirmPlaceholder={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} + title={isLegacyKeysEnabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys'} + confirmString={isLegacyKeysEnabled ? 'disable' : 're-enable'} + confirmLabel={`Confirm to ${isLegacyKeysEnabled ? 'disable' : 're-enable'} anon and service_role`} + confirmPlaceholder={isLegacyKeysEnabled ? 'disable' : 're-enable'} loading={isTogglingLegacyAPIKey} - variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'default'} + variant={isLegacyKeysEnabled ? 'destructive' : 'default'} alert={ - legacyAPIKeysStatusData.enabled + isLegacyKeysEnabled ? { - title: 'Disabling can cause downtime!', - description: `If you disable your anon and service_role keys while they are in use, your applications will stop functioning. All API endpoints will receive HTTP 401 Unauthorized. Make sure you are no longer using them before proceeding.`, + title: 'Ensure legacy keys are no longer in use before disabling', + description: ( + + Disabling anon and service_role keys while they are in + use will cause downtime for your application. Ensure they are no longer in use + before proceeding. + + ), } : { - title: 'Prefer publishable and secret keys', - description: - 'While re-enabling anon and service_role keys makes sense in some cases, a better and more secure alternative is the publishable or secret key. Consider using those before proceeding!', + title: 'Publishable and secret keys are preferred', + description: ( + + Re-enabling anon and service_role keys may be + appropriate in certain cases, but using a publishable and secret key is more + secure. We recommend against re-enabling legacy API keys + + ), } } /> diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 99a885bec36..afa9fc56408 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -424,7 +424,7 @@ export const QueryBlock = ({ diff --git a/apps/studio/csp.js b/apps/studio/csp.js new file mode 100644 index 00000000000..f4ddabfcc76 --- /dev/null +++ b/apps/studio/csp.js @@ -0,0 +1,187 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL + ? new URL(process.env.NEXT_PUBLIC_API_URL).origin + : '' +const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : '' +const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL + ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin + : '' +const SUPABASE_PROJECTS_URL = 'https://*.supabase.co' +const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co' + +// construct the URL for the Websocket Local URLs +let SUPABASE_LOCAL_PROJECTS_URL_WS = '' +if (SUPABASE_URL) { + const url = new URL(SUPABASE_URL) + const wsUrl = `${url.hostname}:${url.port}` + SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}` +} + +// Needed to test docs search in local dev +const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL + ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin + : '' + +// Needed to test docs content API in local dev +const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL + ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin + : '' + +const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' +const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' +const SUPABASE_COM_URL = 'https://supabase.com' +const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com' +const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com' +const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com' +const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com' +const CONFIGCAT_URL = 'https://cdn-global.configcat.com' +const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT ?? '') + ? 'https://configcat.supabase.green' + : 'https://configcat.supabase.com' +const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' +const STRIPE_JS_URL = 'https://js.stripe.com' +const STRIPE_NETWORK_URL = 'https://*.stripe.network' +const CLOUDFLARE_URL = 'https://www.cloudflare.com' +const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' +const VERCEL_URL = 'https://vercel.com' +const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' +const GITHUB_API_URL = 'https://api.github.com' +const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com' +const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com' +const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com' + +// This is a custom domain for Stape, which isused for GTM servers +const STAPE_URL = 'https://ss.supabase.com' + +const VERCEL_LIVE_URL = 'https://vercel.live' +const SENTRY_URL = + 'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io' +const SUPABASE_ASSETS_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? 'https://frontend-assets.supabase.green' + : 'https://frontend-assets.supabase.com' + +const USERCENTRICS_URLS = 'https://*.usercentrics.eu' +const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' + +// used by vercel live preview +const PUSHER_URL = 'https://*.pusher.com' +const PUSHER_URL_WS = 'wss://*.pusher.com' + +module.exports.getCSP = function getCSP() { + const DEFAULT_SRC_URLS = [ + API_URL, + SUPABASE_URL, + GOTRUE_URL, + SUPABASE_LOCAL_PROJECTS_URL_WS, + SUPABASE_PROJECTS_URL, + SUPABASE_PROJECTS_URL_WS, + HCAPTCHA_SUBDOMAINS_URL, + CONFIGCAT_URL, + CONFIGCAT_PROXY_URL, + STRIPE_SUBDOMAINS_URL, + STRIPE_NETWORK_URL, + CLOUDFLARE_URL, + ONE_ONE_ONE_ONE_URL, + VERCEL_INSIGHTS_URL, + GITHUB_API_URL, + GITHUB_USER_CONTENT_URL, + SUPABASE_ASSETS_URL, + USERCENTRICS_URLS, + STAPE_URL, + ] + const SCRIPT_SRC_URLS = [ + CLOUDFLARE_CDN_URL, + HCAPTCHA_JS_URL, + STRIPE_JS_URL, + SUPABASE_ASSETS_URL, + STAPE_URL, + ] + const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL] + const IMG_SRC_URLS = [ + SUPABASE_URL, + SUPABASE_COM_URL, + SUPABASE_PROJECTS_URL, + GITHUB_USER_AVATAR_URL, + GOOGLE_USER_AVATAR_URL, + SUPABASE_ASSETS_URL, + USERCENTRICS_APP_URL, + STAPE_URL, + ] + const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] + const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] + + const isDevOrStaging = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + + const defaultSrcDirective = [ + `default-src 'self'`, + ...DEFAULT_SRC_URLS, + ...(isDevOrStaging + ? [ + SUPABASE_STAGING_PROJECTS_URL, + SUPABASE_STAGING_PROJECTS_URL_WS, + VERCEL_LIVE_URL, + SUPABASE_DOCS_PROJECT_URL, + SUPABASE_CONTENT_API_URL, + ] + : []), + PUSHER_URL_WS, + SENTRY_URL, + ].join(' ') + + const imgSrcDirective = [ + `img-src 'self'`, + `blob:`, + `data:`, + ...IMG_SRC_URLS, + ...(isDevOrStaging ? [SUPABASE_STAGING_PROJECTS_URL, VERCEL_URL] : []), + ].join(' ') + + const scriptSrcDirective = [ + `script-src 'self'`, + `'unsafe-eval'`, + `'unsafe-inline'`, + ...SCRIPT_SRC_URLS, + VERCEL_LIVE_URL, + PUSHER_URL, + ].join(' ') + + const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ') + + const styleSrcDirective = [ + `style-src 'self'`, + `'unsafe-inline'`, + ...STYLE_SRC_URLS, + VERCEL_LIVE_URL, + ].join(' ') + + const fontSrcDirective = [`font-src 'self'`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ') + + const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ') + + const cspDirectives = [ + defaultSrcDirective, + imgSrcDirective, + scriptSrcDirective, + frameSrcDirective, + styleSrcDirective, + fontSrcDirective, + workerSrcDirective, + `object-src 'none'`, + `base-uri 'self'`, + `form-action 'self'`, + `frame-ancestors 'none'`, + `block-all-mixed-content`, + ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && + process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' + ? [`upgrade-insecure-requests`] + : []), + ] + + const csp = cspDirectives.join('; ') + ';' + + // Replace newline characters and spaces + return csp.replace(/\s{2,}/g, ' ').trim() +} diff --git a/apps/studio/data/ai/sql-cron-mutation.ts b/apps/studio/data/ai/sql-cron-mutation.ts new file mode 100644 index 00000000000..8257a8a9993 --- /dev/null +++ b/apps/studio/data/ai/sql-cron-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { constructHeaders, fetchHandler } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' + +export type SqlCronGenerateResponse = string + +export type SqlCronGenerateVariables = { + prompt: string + useBedrockAssistant?: boolean +} + +export async function generateSqlCron({ prompt, useBedrockAssistant }: SqlCronGenerateVariables) { + const url = useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/cron-v2` + : `${BASE_PATH}/api/ai/sql/cron` + + const headers = await constructHeaders({ 'Content-Type': 'application/json' }) + const response = await fetchHandler(url, { + headers, + method: 'POST', + body: JSON.stringify({ prompt }), + }) + + let body: any + + try { + body = await response.json() + } catch {} + + if (!response.ok) { + throw new ResponseError(body?.message, response.status) + } + + return body as SqlCronGenerateResponse +} + +type SqlCronGenerateData = Awaited> + +export const useSqlCronGenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => generateSqlCron(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate cron expression: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index 6b6868698f4..ba28a87eeb7 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -12,11 +12,16 @@ export type SqlTitleGenerateResponse = { export type SqlTitleGenerateVariables = { sql: string + useBedrockAssistant?: boolean } -export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) { +export async function generateSqlTitle({ sql, useBedrockAssistant }: SqlTitleGenerateVariables) { + const url = useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/title-v2` + : `${BASE_PATH}/api/ai/sql/title` + const headers = await constructHeaders({ 'Content-Type': 'application/json' }) - const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/title`, { + const response = await fetchHandler(url, { headers, method: 'POST', body: JSON.stringify({ diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index b76d50f85f9..9b8d65e071d 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -63,6 +63,9 @@ export const useProjectSettingsV2Query = ( ) } +/** + * @deprecated Use api-keys-query instead! + */ export const getAPIKeys = (settings?: ProjectSettings) => { const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') diff --git a/apps/studio/data/content-api/docs-error-codes-query.ts b/apps/studio/data/content-api/docs-error-codes-query.ts new file mode 100644 index 00000000000..381c655222b --- /dev/null +++ b/apps/studio/data/content-api/docs-error-codes-query.ts @@ -0,0 +1,43 @@ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query' +import { graphql } from 'data/graphql' +import { executeGraphQL } from 'data/graphql/execute' +import { Service } from 'data/graphql/graphql' +import { contentApiKeys } from './keys' + +const ErrorCodeQuery = graphql(` + query ErrorCodeQuery($code: String!, $service: Service) { + errors(code: $code, service: $service) { + nodes { + code + service + message + } + } + } +`) + +interface Variables { + code: string + service?: Service +} + +async function getErrorCodeDescriptions({ code, service }: Variables, signal?: AbortSignal) { + return await executeGraphQL(ErrorCodeQuery, { variables: { code, service }, signal }) +} + +type ErrorCodeDescriptionsData = Awaited> +type ErrorCodeDescriptionsError = unknown + +export const useErrorCodesQuery = ( + variables: Variables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery( + contentApiKeys.errorCodes(variables), + ({ signal }) => getErrorCodeDescriptions(variables, signal), + { enabled, ...options } + ) +} diff --git a/apps/studio/data/content-api/keys.ts b/apps/studio/data/content-api/keys.ts new file mode 100644 index 00000000000..80d437700a0 --- /dev/null +++ b/apps/studio/data/content-api/keys.ts @@ -0,0 +1,4 @@ +export const contentApiKeys = { + errorCodes: ({ code, service }: { code: string; service?: string }) => + ['content-api', 'error-codes', { code, service }] as const, +} diff --git a/apps/studio/data/database-indexes/index-delete-mutation.ts b/apps/studio/data/database-indexes/index-delete-mutation.ts index 13bd3e835da..47dea94fe6d 100644 --- a/apps/studio/data/database-indexes/index-delete-mutation.ts +++ b/apps/studio/data/database-indexes/index-delete-mutation.ts @@ -9,14 +9,16 @@ export type DatabaseIndexDeleteVariables = { projectRef: string connectionString?: string | null name: string + schema: string } export async function deleteDatabaseIndex({ projectRef, connectionString, name, + schema, }: DatabaseIndexDeleteVariables) { - const sql = `drop index if exists "${name}"` + const sql = `drop index if exists "${schema}"."${name}"` const { result } = await executeSql({ projectRef, diff --git a/apps/studio/data/feedback/support-ticket-send.ts b/apps/studio/data/feedback/support-ticket-send.ts index 45ed494472e..137215c244f 100644 --- a/apps/studio/data/feedback/support-ticket-send.ts +++ b/apps/studio/data/feedback/support-ticket-send.ts @@ -52,7 +52,7 @@ export async function sendSupportTicket({ }, }) - if (error) handleError(error) + if (error) handleError(error, { alwaysCapture: true }) return data } diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index d3849c3e83e..9c369ee5504 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -128,8 +128,18 @@ export const { OPTIONS: options, } = client -export const handleError = (error: unknown): never => { +type HandleErrorOptions = { + alwaysCapture?: boolean +} + +export const handleError = ( + error: unknown, + options: HandleErrorOptions = { alwaysCapture: false } +): never => { if (error && typeof error === 'object') { + if (options.alwaysCapture) { + Sentry.captureException(error) + } const errorMessage = 'msg' in error && typeof error.msg === 'string' ? error.msg diff --git a/apps/studio/data/graphql/execute.ts b/apps/studio/data/graphql/execute.ts new file mode 100644 index 00000000000..3305e1ba5d6 --- /dev/null +++ b/apps/studio/data/graphql/execute.ts @@ -0,0 +1,36 @@ +import { handleError } from 'data/fetchers' +import type { TypedDocumentString } from './graphql' + +const CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL! + +export async function executeGraphQL( + query: TypedDocumentString, + { variables, signal }: { variables?: TVariables; signal?: AbortSignal } +) { + try { + const response = await fetch(CONTENT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + signal, + }) + + if (!response.ok) { + throw new Error('Failed network response from Content API') + } + + const { data, errors } = await response.json() + if (errors) { + throw errors + } + + return data as TResult + } catch (err) { + handleError(err) + } +} diff --git a/apps/studio/data/graphql/fragment-masking.ts b/apps/studio/data/graphql/fragment-masking.ts new file mode 100644 index 00000000000..494960811fa --- /dev/null +++ b/apps/studio/data/graphql/fragment-masking.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +import { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core' +import { Incremental, TypedDocumentString } from './graphql' + +export type FragmentType> = + TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null +// return nullable if `fragmentType` is nullable or undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> | null | undefined +): Array | null | undefined +// return readonly array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray +// return readonly array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | Array>> + | ReadonlyArray>> + | null + | undefined +): TType | Array | ReadonlyArray | null | undefined { + return fragmentType as any +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf, +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType +} +export function isFragmentReady( + queryNode: TypedDocumentString, + fragmentNode: TypedDocumentString, + data: FragmentType, any>> | null | undefined +): data is FragmentType { + const deferredFields = queryNode.__meta__?.deferredFields as Record + const fragName = fragmentNode.__meta__?.fragmentName as string | undefined + + if (!deferredFields || !fragName) return true + + const fields = deferredFields[fragName] ?? [] + return fields.length > 0 && fields.every((field) => data && field in data) +} diff --git a/apps/studio/data/graphql/gql.ts b/apps/studio/data/graphql/gql.ts new file mode 100644 index 00000000000..1f4ff76f6e6 --- /dev/null +++ b/apps/studio/data/graphql/gql.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +import * as types from './graphql' + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n': typeof types.ErrorCodeQueryDocument +} +const documents: Documents = { + '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n': + types.ErrorCodeQueryDocument, +} + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n' +): typeof import('./graphql').ErrorCodeQueryDocument + +export function graphql(source: string) { + return (documents as any)[source] ?? {} +} diff --git a/apps/studio/data/graphql/graphql.ts b/apps/studio/data/graphql/graphql.ts new file mode 100644 index 00000000000..99d32bef6b9 --- /dev/null +++ b/apps/studio/data/graphql/graphql.ts @@ -0,0 +1,266 @@ +/* eslint-disable */ +import { DocumentTypeDecoration } from '@graphql-typed-document-node/core' +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = { [K in keyof T]: T[K] } +export type MakeOptional = Omit & { [SubKey in K]?: Maybe } +export type MakeMaybe = Omit & { [SubKey in K]: Maybe } +export type MakeEmpty = { + [_ in K]?: never +} +export type Incremental = + | T + | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never } +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string } + String: { input: string; output: string } + Boolean: { input: boolean; output: boolean } + Int: { input: number; output: number } + Float: { input: number; output: number } +} + +/** A reference document containing a description of a Supabase CLI command */ +export type CliCommandReference = SearchResult & { + __typename?: 'CLICommandReference' + /** The content of the reference document, as text */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** A reference document containing a description of a function from a Supabase client library */ +export type ClientLibraryFunctionReference = SearchResult & { + __typename?: 'ClientLibraryFunctionReference' + /** The content of the reference document, as text */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The programming language for which the function is written */ + language: Language + /** The name of the function or method */ + methodName?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** An error returned by a Supabase service */ +export type Error = { + __typename?: 'Error' + /** The unique code identifying the error. The code is stable, and can be used for string matching during error handling. */ + code: Scalars['String']['output'] + /** The HTTP status code returned with this error. */ + httpStatusCode?: Maybe + /** A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead. */ + message?: Maybe + /** The Supabase service that returns this error. */ + service: Service +} + +/** A collection of Errors */ +export type ErrorCollection = { + __typename?: 'ErrorCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** Pagination information */ + pageInfo: PageInfo + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of Errors */ +export type ErrorEdge = { + __typename?: 'ErrorEdge' + /** A cursor for use in pagination */ + cursor: Scalars['String']['output'] + /** The Error at the end of the edge */ + node: Error +} + +/** A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature. */ +export type Guide = SearchResult & { + __typename?: 'Guide' + /** The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide. */ + subsections?: Maybe + /** The title of the document */ + title?: Maybe +} + +export enum Language { + Csharp = 'CSHARP', + Dart = 'DART', + Javascript = 'JAVASCRIPT', + Kotlin = 'KOTLIN', + Python = 'PYTHON', + Swift = 'SWIFT', +} + +/** Pagination information for a collection */ +export type PageInfo = { + __typename?: 'PageInfo' + /** Cursor pointing to the end of the current page */ + endCursor?: Maybe + /** Whether there are more items after the current page */ + hasNextPage: Scalars['Boolean']['output'] + /** Whether there are more items before the current page */ + hasPreviousPage: Scalars['Boolean']['output'] + /** Cursor pointing to the start of the current page */ + startCursor?: Maybe +} + +export type RootQueryType = { + __typename?: 'RootQueryType' + /** Get the details of an error code returned from a Supabase service */ + error?: Maybe + /** Get error codes that can potentially be returned by Supabase services */ + errors?: Maybe + /** Get the GraphQL schema for this endpoint */ + schema: Scalars['String']['output'] + /** Search the Supabase docs for content matching a query string */ + searchDocs?: Maybe +} + +export type RootQueryTypeErrorArgs = { + code: Scalars['String']['input'] + service: Service +} + +export type RootQueryTypeErrorsArgs = { + after?: InputMaybe + before?: InputMaybe + code?: InputMaybe + first?: InputMaybe + last?: InputMaybe + service?: InputMaybe +} + +export type RootQueryTypeSearchDocsArgs = { + limit?: InputMaybe + query: Scalars['String']['input'] +} + +/** Document that matches a search query */ +export type SearchResult = { + /** The full content of the matching result */ + content?: Maybe + /** The URL of the matching result */ + href?: Maybe + /** The title of the matching result */ + title?: Maybe +} + +/** A collection of search results containing content from Supabase docs */ +export type SearchResultCollection = { + __typename?: 'SearchResultCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of SearchResults */ +export type SearchResultEdge = { + __typename?: 'SearchResultEdge' + /** The SearchResult at the end of the edge */ + node: SearchResult +} + +export enum Service { + Auth = 'AUTH', + Realtime = 'REALTIME', + Storage = 'STORAGE', +} + +/** A content chunk taken from a larger document in the Supabase docs */ +export type Subsection = { + __typename?: 'Subsection' + /** The content of the subsection */ + content?: Maybe + /** The URL of the subsection */ + href?: Maybe + /** The title of the subsection */ + title?: Maybe +} + +/** A collection of content chunks from a larger document in the Supabase docs. */ +export type SubsectionCollection = { + __typename?: 'SubsectionCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of Subsections */ +export type SubsectionEdge = { + __typename?: 'SubsectionEdge' + /** The Subsection at the end of the edge */ + node: Subsection +} + +/** A document describing how to troubleshoot an issue when using Supabase */ +export type TroubleshootingGuide = SearchResult & { + __typename?: 'TroubleshootingGuide' + /** The full content of the troubleshooting guide */ + content?: Maybe + /** The URL of the troubleshooting guide */ + href?: Maybe + /** The title of the troubleshooting guide */ + title?: Maybe +} + +export type ErrorCodeQueryQueryVariables = Exact<{ + code: Scalars['String']['input'] + service?: InputMaybe +}> + +export type ErrorCodeQueryQuery = { + __typename?: 'RootQueryType' + errors?: { + __typename?: 'ErrorCollection' + nodes: Array<{ __typename?: 'Error'; code: string; service: Service; message?: string | null }> + } | null +} + +export class TypedDocumentString + extends String + implements DocumentTypeDecoration +{ + __apiType?: DocumentTypeDecoration['__apiType'] + private value: string + public __meta__?: Record | undefined + + constructor(value: string, __meta__?: Record | undefined) { + super(value) + this.value = value + this.__meta__ = __meta__ + } + + toString(): string & DocumentTypeDecoration { + return this.value + } +} + +export const ErrorCodeQueryDocument = new TypedDocumentString(` + query ErrorCodeQuery($code: String!, $service: Service) { + errors(code: $code, service: $service) { + nodes { + code + service + message + } + } +} + `) as unknown as TypedDocumentString diff --git a/apps/studio/data/graphql/index.ts b/apps/studio/data/graphql/index.ts new file mode 100644 index 00000000000..f9bc8e591d0 --- /dev/null +++ b/apps/studio/data/graphql/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking' +export * from './gql' diff --git a/apps/studio/data/integrations/github-branches-query.ts b/apps/studio/data/integrations/github-branches-query.ts index 18a49ece0a0..342238466c5 100644 --- a/apps/studio/data/integrations/github-branches-query.ts +++ b/apps/studio/data/integrations/github-branches-query.ts @@ -19,7 +19,7 @@ export async function getGitHubBranches( }) if (error) handleError(error) - return data as Record[] + return data } export type GitHubBranchesData = Awaited> diff --git a/apps/studio/data/integrations/integrations.types.ts b/apps/studio/data/integrations/integrations.types.ts index cc5c7f3ccce..a453f581771 100644 --- a/apps/studio/data/integrations/integrations.types.ts +++ b/apps/studio/data/integrations/integrations.types.ts @@ -259,7 +259,7 @@ export type IntegrationConnectionsCreateVariables = { export type GitHubConnectionCreateVariables = { organizationId: number - connection: components['schemas']['CreateGitHubConnectionsBody'] + connection: components['schemas']['CreateGitHubConnectionBody'] } export type EnvironmentTargets = 'production' | 'preview' | 'development' diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..91ad4e4d66e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { JWTAlgorithm } from './jwt-signing-keys-query' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyCreateVariables { + projectRef?: string + algorithm: JWTAlgorithm + status: 'in_use' | 'standby' +} + +export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys', { + params: { + path: { ref: payload.projectRef }, + }, + body: { + algorithm: payload.algorithm, + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyCreateData = Awaited> + +export const useJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts new file mode 100644 index 00000000000..d39179436c9 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyDeleteVariables { + projectRef?: string + keyId: string +} + +export async function deleteJWTSigningKey(payload: JWTSigningKeyDeleteVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyDeleteData = Awaited> + +export const useJWTSigningKeyDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts new file mode 100644 index 00000000000..a406194980e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyUpdateVariables { + projectRef?: string + keyId: string + status: 'in_use' | 'standby' | 'previously_used' | 'revoked' +} + +export async function updateJWTSigningKey(payload: JWTSigningKeyUpdateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await patch('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + body: { + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyUpdateData = Awaited> + +export const useJWTSigningKeyUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts new file mode 100644 index 00000000000..83cf2a5addc --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts @@ -0,0 +1,43 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +export type JWTSigningKey = components['schemas']['SigningKeyResponse'] + +export type JWTAlgorithm = components['schemas']['SigningKeyResponse']['algorithm'] + +interface JWTSigningKeysVariables { + projectRef?: string +} + +async function getJWTSigningKeys({ projectRef }: JWTSigningKeysVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type JWTSigningKeysData = Awaited> + +export const useJWTSigningKeysQuery = ( + { projectRef }: JWTSigningKeysVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.list(projectRef), + ({ signal }) => getJWTSigningKeys({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/jwt-signing-keys/keys.ts b/apps/studio/data/jwt-signing-keys/keys.ts new file mode 100644 index 00000000000..4d2c9a01a63 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/keys.ts @@ -0,0 +1,4 @@ +export const jwtSigningKeysKeys = { + list: (projectRef?: string) => ['projects', projectRef, 'jwt-signing-keys'] as const, + legacy: (projectRef?: string) => ['projects', projectRef, 'legacy-jwt-signing-key'] as const, +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..3ec3c15fc10 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyCreateVariables { + projectRef?: string +} + +export async function createLegacyJWTSigningKey(payload: LegacyJWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + }, + }) + + if (error) handleError(error) + return data +} + +type LegacyJWTSigningKeyCreateData = Awaited> + +export const useLegacyJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >((vars) => createLegacyJWTSigningKey(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.legacy(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to enable use of JWT signing keys with legacy JWT secret: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts new file mode 100644 index 00000000000..069b7504776 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' + +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyVariables { + projectRef?: string +} + +async function getLegacyJWTSigningKey( + { projectRef }: LegacyJWTSigningKeyVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type LegacyJWTSigningKeyData = Awaited> + +export const useLegacyJWTSigningKeyQuery = ( + { projectRef }: LegacyJWTSigningKeyVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.legacy(projectRef), + ({ signal }) => getLegacyJWTSigningKey({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts new file mode 100644 index 00000000000..2a02591a244 --- /dev/null +++ b/apps/studio/data/logs/keys.ts @@ -0,0 +1,37 @@ +import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' + +export const logsKeys = { + unifiedLogsInfinite: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'logs-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, + unifiedLogsCount: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'count-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, + unifiedLogsChart: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'chart-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, +} diff --git a/apps/studio/data/logs/unified-logs-chart-query.ts b/apps/studio/data/logs/unified-logs-chart-query.ts new file mode 100644 index 00000000000..295078c2c44 --- /dev/null +++ b/apps/studio/data/logs/unified-logs-chart-query.ts @@ -0,0 +1,156 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { getLogsChartQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { handleError, post } from 'data/fetchers' +import { ExecuteSqlError } from 'data/sql/execute-sql-query' +import { logsKeys } from './keys' +import { UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables } from './unified-logs-infinite-query' + +export async function getUnifiedLogsChart( + { projectRef, search }: UnifiedLogsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getUnifiedLogsChart') + } + + // Use a default date range (last hour) if no date range is selected + let dateStart: string + let dateEnd: string + let startTime: Date + let endTime: Date + + if (search.date && search.date.length === 2) { + startTime = new Date(search.date[0]) + endTime = new Date(search.date[1]) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } else { + // Default to last hour + endTime = new Date() + startTime = new Date(endTime.getTime() - 60 * 60 * 1000) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } + + // Get SQL query from utility function (with dynamic bucketing) + const sql = getLogsChartQuery(search) + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { sql, iso_timestamp_start: dateStart, iso_timestamp_end: dateEnd }, + signal, + }) + + if (error) handleError(error) + + const chartData: Array<{ + timestamp: number + success: number + warning: number + error: number + }> = [] + + const dataByTimestamp = new Map< + number, + { + timestamp: number + success: number + warning: number + error: number + } + >() + + if (data?.result) { + data.result.forEach((row: any) => { + // The API returns timestamps in microseconds (needs to be converted to milliseconds for JS Date) + const microseconds = Number(row.time_bucket) + const milliseconds = Math.floor(microseconds / 1000) + + // Create chart data point + const dataPoint = { + timestamp: milliseconds, // Convert to milliseconds for the chart + success: Number(row.success) || 0, + warning: Number(row.warning) || 0, + error: Number(row.error) || 0, + } + + // Filter levels if needed + const levelFilter = search.level + if (levelFilter && levelFilter.length > 0) { + // Reset levels not in the filter + if (!levelFilter.includes('success')) dataPoint.success = 0 + if (!levelFilter.includes('warning')) dataPoint.warning = 0 + if (!levelFilter.includes('error')) dataPoint.error = 0 + } + + dataByTimestamp.set(milliseconds, dataPoint) + }) + } + + // Determine bucket size based on the truncation level in the SQL query + // We need to fill in missing data points + const startTimeMs = startTime.getTime() + const endTimeMs = endTime.getTime() + + // Calculate appropriate bucket size from the time range + const timeRangeHours = (endTimeMs - startTimeMs) / (1000 * 60 * 60) + + let bucketSizeMs: number + if (timeRangeHours > 72) { + // Day-level bucketing (for ranges > 3 days) + bucketSizeMs = 24 * 60 * 60 * 1000 + } else if (timeRangeHours > 12) { + // Hour-level bucketing (for ranges > 12 hours) + bucketSizeMs = 60 * 60 * 1000 + } else { + // Minute-level bucketing (for shorter ranges) + bucketSizeMs = 60 * 1000 + } + + // Fill in any missing buckets + for (let t = startTimeMs; t <= endTimeMs; t += bucketSizeMs) { + // Round to the nearest bucket boundary + const bucketTime = Math.floor(t / bucketSizeMs) * bucketSizeMs + + if (!dataByTimestamp.has(bucketTime)) { + // Create empty data point for this bucket + dataByTimestamp.set(bucketTime, { + timestamp: bucketTime, + success: 0, + warning: 0, + error: 0, + }) + } + } + + // Convert map to array + for (const dataPoint of dataByTimestamp.values()) { + chartData.push(dataPoint) + } + + // Sort by timestamp + chartData.sort((a, b) => a.timestamp - b.timestamp) + + return chartData +} + +export type UnifiedLogsChartData = Awaited> +export type UnifiedLogsChartError = ExecuteSqlError + +export const useUnifiedLogsChartQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logsKeys.unifiedLogsChart(projectRef, search), + ({ signal }) => getUnifiedLogsChart({ projectRef, search }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) diff --git a/apps/studio/data/logs/unified-logs-count-query.ts b/apps/studio/data/logs/unified-logs-count-query.ts new file mode 100644 index 00000000000..7bd0d1dca2f --- /dev/null +++ b/apps/studio/data/logs/unified-logs-count-query.ts @@ -0,0 +1,94 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { getLogsCountQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { FacetMetadataSchema } from 'components/interfaces/UnifiedLogs/UnifiedLogs.schema' +import { handleError, post } from 'data/fetchers' +import { ExecuteSqlError } from 'data/sql/execute-sql-query' +import { logsKeys } from './keys' +import { + getUnifiedLogsISOStartEnd, + UNIFIED_LOGS_QUERY_OPTIONS, + UnifiedLogsVariables, +} from './unified-logs-infinite-query' + +export async function getUnifiedLogsCount( + { projectRef, search }: UnifiedLogsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getUnifiedLogsCount') + } + + const sql = getLogsCountQuery(search) + const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, sql }, + signal, + }) + + if (error) handleError(error) + + // Process count results into facets structure + const facets: Record = {} + const countsByDimension: Record> = {} + let totalRowCount = 0 + + // Group by dimension + if (data?.result) { + data.result.forEach((row: any) => { + const dimension = row.dimension + const value = row.value + const count = Number(row.count || 0) + + // Set total count if this is the total dimension + if (dimension === 'total' && value === 'all') { + totalRowCount = count + } + + // Initialize dimension map if not exists + if (!countsByDimension[dimension]) { + countsByDimension[dimension] = new Map() + } + + // Add count to the dimension map + countsByDimension[dimension].set(value, count) + }) + } + + // Convert dimension maps to facets structure + Object.entries(countsByDimension).forEach(([dimension, countsMap]) => { + // Skip the 'total' dimension as it's not a facet + if (dimension === 'total') return + + const dimensionTotal = Array.from(countsMap.values()).reduce((sum, count) => sum + count, 0) + + facets[dimension] = { + total: dimensionTotal, + rows: Array.from(countsMap.entries()).map(([value, count]) => ({ value, total: count })), + } + }) + + return { totalRowCount, facets } +} + +export type UnifiedLogsCountData = Awaited> +export type UnifiedLogsCountError = ExecuteSqlError + +export const useUnifiedLogsCountQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logsKeys.unifiedLogsCount(projectRef, search), + ({ signal }) => getUnifiedLogsCount({ projectRef, search }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts new file mode 100644 index 00000000000..ee7a1cd677f --- /dev/null +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -0,0 +1,176 @@ +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' + +import { getUnifiedLogsQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { + PageParam, + QuerySearchParamsType, +} from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' +import { handleError, post } from 'data/fetchers' +import { ResponseError } from 'types' +import { logsKeys } from './keys' + +const LOGS_PAGE_LIMIT = 50 +type LogLevel = 'success' | 'warning' | 'error' + +export const UNIFIED_LOGS_QUERY_OPTIONS = { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchInterval: 0, + staleTime: 1000 * 60 * 5, // 5 minutes, +} + +export type UnifiedLogsData = any +export type UnifiedLogsError = ResponseError +export type UnifiedLogsVariables = { projectRef?: string; search: QuerySearchParamsType } + +export const getUnifiedLogsISOStartEnd = (search: QuerySearchParamsType) => { + // Extract date range from search or use default (last hour) + let isoTimestampStart: string + let isoTimestampEnd: string + + if (search.date && search.date.length === 2) { + isoTimestampStart = new Date(search.date[0]).toISOString() + isoTimestampEnd = new Date(search.date[1]).toISOString() + } else { + const now = new Date() + isoTimestampEnd = now.toISOString() + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + isoTimestampStart = oneHourAgo.toISOString() + } + + return { isoTimestampStart, isoTimestampEnd } +} + +async function getUnifiedLogs( + { projectRef, search, pageParam }: UnifiedLogsVariables & { pageParam: PageParam }, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') + throw new Error('projectRef is required for getUnifiedLogs') + + /** + * [Joshen] RE infinite loading pagination logic for unified logs, these all really should live in the API + * but for now we're doing these on the FE to move quickly while figuring out what data we need before we + * migrate this logic to the BE. Just thought to leave a small explanation on the logic here: + * + * We're leveraging on the log's timestamp to essentially fetch the next page + * Given that the logs are ordered descending (latest logs come first, and we're fetching older logs as we scroll down) + * Hence why the cursor is basically the last row's timestamp from the latest page + * + * iso_timestamp_start will always be the current timestamp + * iso_timestamp_end will default to the last hour for the first page, followed by the last row's timestamp from + * the previous page. + * + * However, just note that this isn't a perfect solution as there's always the edge case where by there's multiple rows + * with identical timestamps, hence why FE will need a de-duping logic (in UnifiedLogs.tsx) unless we can figure a cleaner + * solution when we move all this logic to the BE (e.g using composite columns for the cursor like timestamp + id) + * + */ + + const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) + const sql = `${getUnifiedLogsQuery(search)} ORDER BY timestamp DESC, id DESC LIMIT ${LOGS_PAGE_LIMIT}` + + const cursorValue = pageParam?.cursor + const cursorDirection = pageParam?.direction + + let timestampStart: string + let timestampEnd: string + + if (cursorDirection === 'prev') { + // Live mode: fetch logs newer than the cursor + timestampStart = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() + : isoTimestampStart + timestampEnd = new Date().toISOString() + } else if (cursorDirection === 'next') { + // Regular pagination: fetch logs older than the cursor + timestampStart = isoTimestampStart + timestampEnd = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() + : isoTimestampEnd + } else { + timestampStart = isoTimestampStart + timestampEnd = isoTimestampEnd + } + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: timestampEnd, sql }, + signal, + }) + + if (error) handleError(error) + + const resultData = data?.result ?? [] + + const result = resultData.map((row: any) => { + // Create a date object for display purposes + const date = new Date(Number(row.timestamp) / 1000) + + return { + id: row.id, + date, + timestamp: row.timestamp, + level: row.level as LogLevel, + status: row.status || 200, + method: row.method, + host: row.host, + pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || '', + event_message: row.event_message || row.body || '', + headers: + typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {}, + regions: row.region ? [row.region] : [], + log_type: row.log_type || '', + latency: row.latency || 0, + log_count: row.log_count || null, + logs: row.logs || [], + auth_user: row.auth_user || null, + } + }) + + const firstRow = result.length > 0 ? result[0] : null + const lastRow = result.length > 0 ? result[result.length - 1] : null + const hasMore = result.length >= LOGS_PAGE_LIMIT - 1 + + const nextCursor = lastRow ? lastRow.timestamp : null + // FIXED: Always provide prevCursor like DataTableDemo does + // This ensures live mode never breaks the infinite query chain + // DataTableDemo uses milliseconds, but our timestamps are in microseconds + const prevCursor = result.length > 0 ? firstRow!.timestamp : new Date().getTime() * 1000 + + return { + data: result, + nextCursor: hasMore ? nextCursor : null, + prevCursor, + } +} + +export const useUnifiedLogsInfiniteQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions = {} +) => { + return useInfiniteQuery( + logsKeys.unifiedLogsInfinite(projectRef, search), + ({ signal, pageParam }) => { + return getUnifiedLogs({ projectRef, search, pageParam }, signal) + }, + { + enabled: enabled && typeof projectRef !== 'undefined', + getPreviousPageParam: (firstPage) => { + if (!firstPage.prevCursor) return null + const result = { cursor: firstPage.prevCursor, direction: 'prev' } as PageParam + return result + }, + getNextPageParam(lastPage) { + if (!lastPage.nextCursor || lastPage.data.length === 0) return null + return { cursor: lastPage.nextCursor, direction: 'next' } as PageParam + }, + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) +} diff --git a/apps/studio/data/organizations/organization-billing-subscription-preview.ts b/apps/studio/data/organizations/organization-billing-subscription-preview.ts index 0f0adef3bb0..beb079b520e 100644 --- a/apps/studio/data/organizations/organization-billing-subscription-preview.ts +++ b/apps/studio/data/organizations/organization-billing-subscription-preview.ts @@ -43,7 +43,6 @@ export type OrganizationBillingSubscriptionPreviewResponse = { ref: string }[] billed_via_partner?: boolean - pending_subscription_flow?: boolean } export async function previewOrganizationBillingSubscription({ diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 25b29c20ea4..16d42a31571 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,7 +10,6 @@ type OrganizationProjectClaimVariables = { } async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) { - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', { params: { path: { slug, token } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index f620074a041..169043d7fdc 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -51,7 +51,6 @@ async function getOrganizationProjectClaim( ) { if (!slug || !token) throw new Error('Slug and token are required') - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, { params: { path: { slug, token } }, signal, diff --git a/apps/studio/data/organizations/organizations-query.ts b/apps/studio/data/organizations/organizations-query.ts index f9c9e17eb0d..85f32ab775c 100644 --- a/apps/studio/data/organizations/organizations-query.ts +++ b/apps/studio/data/organizations/organizations-query.ts @@ -6,9 +6,9 @@ import { useProfile } from 'lib/profile' import type { Organization, ResponseError } from 'types' import { organizationKeys } from './keys' -export function castOrganizationResponseToOrganization( - org: components['schemas']['OrganizationResponse'] -): Organization { +export type OrganizationBase = components['schemas']['OrganizationResponse'] + +export function castOrganizationResponseToOrganization(org: OrganizationBase): Organization { return { ...org, billing_email: org.billing_email ?? 'Unknown', diff --git a/apps/studio/data/projects/new-project.constants.ts b/apps/studio/data/projects/new-project.constants.ts index 98240ddd5dd..58f55cf9faa 100644 --- a/apps/studio/data/projects/new-project.constants.ts +++ b/apps/studio/data/projects/new-project.constants.ts @@ -31,7 +31,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.01344, priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, small: { label: 'Small', @@ -39,7 +39,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0206, priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, medium: { label: 'Medium', @@ -47,7 +47,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0822, priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, large: { label: 'Large', @@ -55,7 +55,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.1517, priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, xlarge: { label: 'XL', @@ -63,7 +63,7 @@ export const instanceSizeSpecs: Record< cpu: '4-core', priceHourly: 0.2877, priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '2xlarge': { label: '2XL', @@ -71,7 +71,7 @@ export const instanceSizeSpecs: Record< cpu: '8-core', priceHourly: 0.562, priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '4xlarge': { label: '4XL', @@ -79,7 +79,7 @@ export const instanceSizeSpecs: Record< cpu: '16-core', priceHourly: 1.32, priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '8xlarge': { label: '8XL', @@ -87,7 +87,7 @@ export const instanceSizeSpecs: Record< cpu: '32-core', priceHourly: 2.562, priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '12xlarge': { label: '12XL', @@ -95,7 +95,7 @@ export const instanceSizeSpecs: Record< cpu: '48-core', priceHourly: 3.836, priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '16xlarge': { label: '16XL', @@ -103,7 +103,7 @@ export const instanceSizeSpecs: Record< cpu: '64-core', priceHourly: 5.12, priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge': { label: '24XL', @@ -111,7 +111,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 9.73, priceMonthly: 7100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_optimized_cpu': { label: '24XL - Optimized CPU', @@ -119,7 +119,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 8.9, priceMonthly: 6500, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_optimized_memory': { label: '24XL - Optimized Memory', @@ -127,7 +127,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 13.84, priceMonthly: 10100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_high_memory': { label: '24XL - High Memory', @@ -135,7 +135,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 21.91, priceMonthly: 16000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge': { label: '48XL', @@ -143,7 +143,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 19.47, priceMonthly: 14200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_optimized_cpu': { label: '48XL - Optimized CPU', @@ -151,7 +151,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 17.8, priceMonthly: 13000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_optimized_memory': { label: '48XL - Optimized Memory', @@ -159,7 +159,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 27.68, priceMonthly: 20200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_high_memory': { label: '48XL - High Memory', @@ -167,6 +167,6 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 43.84, priceMonthly: 32000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, } diff --git a/apps/studio/data/projects/project-create-mutation.ts b/apps/studio/data/projects/project-create-mutation.ts index ceaefb80bb3..1e6e4c3e313 100644 --- a/apps/studio/data/projects/project-create-mutation.ts +++ b/apps/studio/data/projects/project-create-mutation.ts @@ -21,7 +21,7 @@ const WHITELIST_ERRORS = [ export type ProjectCreateVariables = { name: string - organizationId: number + organizationSlug: string dbPass: string dbRegion: string dbSql?: string @@ -38,7 +38,7 @@ export type ProjectCreateVariables = { export async function createProject({ name, - organizationId, + organizationSlug, dbPass, dbRegion, dbSql, @@ -53,7 +53,7 @@ export async function createProject({ }: ProjectCreateVariables) { const body: components['schemas']['CreateProjectBody'] = { cloud_provider: cloudProvider, - org_id: organizationId, + organization_slug: organizationSlug, name, db_pass: dbPass, db_region: dbRegion, diff --git a/apps/studio/data/reports/auth-charts.ts b/apps/studio/data/reports/auth-charts.ts new file mode 100644 index 00000000000..7978511209c --- /dev/null +++ b/apps/studio/data/reports/auth-charts.ts @@ -0,0 +1,1197 @@ +export const getAuthReportAttributes = (isFreePlan: boolean) => [ + { + id: 'active-users', + label: 'Active Users', + valuePrecision: 0, + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + attributes: [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ], + }, + { + id: 'sign-in-attempts', + label: 'Sign In Attempts by Type', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of sign in attempts by grant type.', + attributes: [ + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Password', + grantType: 'password', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'PKCE', + grantType: 'pkce', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Refresh Token', + grantType: 'refresh_token', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'ID Token', + grantType: 'id_token', + enabled: true, + }, + ], + }, + { + id: 'signups', + label: 'Sign Ups', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of sign ups.', + attributes: [ + { + attribute: 'TotalSignUps', + provider: 'logs', + label: 'Sign Ups', + enabled: true, + }, + ], + }, + { + id: 'auth-errors', + label: 'Auth Errors', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of auth errors by status code.', + attributes: [ + { + attribute: 'ErrorsByStatus', + provider: 'logs', + label: 'Auth Errors', + }, + ], + }, + { + id: 'password-reset-requests', + label: 'Password Reset Requests', + valuePrecision: 0, + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + attributes: [ + { + attribute: 'PasswordResetRequests', + provider: 'logs', + label: 'Password Reset Requests', + enabled: true, + }, + ], + }, + { + id: 'sign-in-latency', + label: 'Sign In Latency', + valuePrecision: 2, + hide: true, // Jordi: Hidden until we can fix the query + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average latency for sign in operations by grant type.', + attributes: [ + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'Password', + grantType: 'password', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'PKCE', + grantType: 'pkce', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'Refresh Token', + grantType: 'refresh_token', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'ID Token', + grantType: 'id_token', + enabled: true, + }, + ], + }, + { + id: 'sign-up-latency', + label: 'Sign Up Latency', + valuePrecision: 2, + hide: true, // Jordi: Hidden until we can fix the query + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average latency for sign up operations by provider.', + attributes: [ + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Email', + providerType: 'email', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Google', + providerType: 'google', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'GitHub', + providerType: 'github', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Apple', + providerType: 'apple', + enabled: true, + }, + ], + }, +] + +/** + * ================================================ + * Mock data below + * TODO: Remove once we have real data + * ================================================ + * */ + +// Time points for all mock data +const DEFAULT_TIME_POINTS = [ + '2025-05-05T13:47:23Z', + '2025-05-05T14:47:23Z', + '2025-05-05T15:47:23Z', + '2025-05-05T16:47:23Z', + '2025-05-05T17:47:23Z', + '2025-05-05T18:47:23Z', + '2025-05-05T19:47:23Z', + '2025-05-05T20:47:23Z', + '2025-05-05T21:47:23Z', + '2025-05-05T22:47:23Z', + '2025-05-05T23:47:23Z', + '2025-05-06T00:47:23Z', + '2025-05-06T01:47:23Z', + '2025-05-06T02:47:23Z', + '2025-05-06T03:47:23Z', + '2025-05-06T04:47:23Z', + '2025-05-06T05:47:23Z', + '2025-05-06T06:47:23Z', + '2025-05-06T07:47:23Z', + '2025-05-06T08:47:23Z', + '2025-05-06T09:47:23Z', + '2025-05-06T10:47:23Z', + '2025-05-06T11:47:23Z', + '2025-05-06T12:47:23Z', + '2025-05-06T13:47:23Z', +] + +// Helper to generate random values with some "realistic" patterns +const generatePatternedValues = ( + length: number, + base: number = 5, + variance: number = 5, + spikeProbability: number = 0.1, + spikeMultiplier: number = 5 +): number[] => { + return Array(length) + .fill(0) + .map(() => { + const hasSpike = Math.random() < spikeProbability + const baseValue = base + Math.floor(Math.random() * variance) + return hasSpike ? baseValue * spikeMultiplier : baseValue + }) +} + +export const getAuthRequestsMockData = (provider: string) => { + let values: number[] = [] + + if (provider === 'Email') { + values = [3, 4, 4, 5, 4, 3, 5, 6, 4, 3, 5, 5, 4, 3, 4, 5, 6, 7, 5, 15, 6, 9, 17, 8, 4] + } else if (provider === 'Google') { + values = [2, 1, 3, 0, 5, 2, 0, 0, 1, 2, 0, 0, 1, 2, 1, 0, 0, 0, 1, 1, 2, 4, 0, 4, 12] + } else if (provider === 'GitHub') { + values = [0, 0, 8, 0, 3, 0, 0, 1, 0, 0, 3, 0, 0, 0, 1, 0, 8, 4, 2, 0, 2, 1, 3, 6, 10] + } else { + values = Array(25).fill(0) + } + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + return { + data, + yAxisLimit: 100, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Add new mock data generator for provider-specific sessions +export const getProviderSessionsMockData = (provider: string) => { + let values: number[] = [] + + // Generate higher base values for sessions compared to signups + if (provider === 'EmailSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 15, 8, 0.1, 2) + } else if (provider === 'GoogleSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 12, 6, 0.1, 2.5) + } else if (provider === 'GitHubSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 10, 5, 0.1, 2.2) + } else { + // For other providers, generate lower values + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 5, 3, 0.1, 2) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Add new mock data generator for provider-specific churn rates +export const getProviderChurnMockData = (provider: string) => { + let values: number[] = [] + + // Generate realistic churn rates (typically between 2-15%) + if (provider === 'EmailChurn') { + // Email typically has higher churn + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 2, 10, 0.1, 5.2).map((v) => + Math.min(v, 20) + ) + } else if (provider === 'GoogleChurn') { + // Google typically has lower churn due to account persistence + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 8, 2, 0.1, 1.3).map((v) => + Math.min(v, 15) + ) + } else if (provider === 'GitHubChurn') { + // GitHub has moderate churn + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 10, 2.5, 0.1, 1.2).map((v) => + Math.min(v, 18) + ) + } else { + // Other providers have varying churn rates + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 9, 3, 0.1, 1.4).map((v) => + Math.min(v, 20) + ) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 20, // Max 20% churn rate + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Add new mock data generator for churn rates +export const getChurnRateMockData = (attribute: string) => { + let values: number[] = [] + + // Overall churn rate with some variance + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 0.6, 2, 0.15, 2.6) + .map((v) => Math.max(0, v - 0.25)) // Allow some values to go to 0 + .map((v) => Math.min(v, 3.2)) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [attribute]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 1.5, // Max 1.5% churn rate + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for new charts +export const getActiveUsersMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + // Generate more realistic pattern for Daily Active Users + // Create 30 days of data for a better view + const thirtyDays = 30 + const thirtyDayDates = Array(thirtyDays) + .fill(0) + .map((_, i) => { + const date = new Date() + date.setDate(date.getDate() - (thirtyDays - i - 1)) + return date.toISOString() + }) + + // Base values that reflect a gradual growth trend + const baseValue = 200 + const dailyGrowth = 1.5 // Small daily growth factor + + if (metric === 'DAU') { + values = Array(thirtyDays) + .fill(0) + .map((_, i) => { + const dayOfWeek = new Date(thirtyDayDates[i]).getDay() + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6 + + // Base value with growth trend + let value = baseValue + i * dailyGrowth + + // Weekday/weekend pattern (weekends have ~20% less activity) + if (isWeekend) { + value = value * 0.8 + } + + // Add some randomness + const randomFactor = 0.9 + Math.random() * 0.2 // ±10% + value = value * randomFactor + + return Math.round(value) + }) + } else { + // For WAU and MAU (not used in this case) + values = Array(thirtyDays).fill(0) + } + + const data = thirtyDayDates.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getUserTypesMockData = (type: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (type === 'NewUsers') { + values = generatePatternedValues(length, 40, 20, 0.15, 3) + } else if (type === 'ReturningUsers') { + values = generatePatternedValues(length, 200, 50, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [type]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getAuthSessionsMockData = (sessionType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (sessionType === 'TotalSessions') { + values = generatePatternedValues(length, 1200, 300, 0.1, 1.3) + } else if (sessionType === 'WebSessions') { + values = generatePatternedValues(length, 800, 200, 0.1, 1.4) + } else if (sessionType === 'MobileSessions') { + values = generatePatternedValues(length, 400, 150, 0.12, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [sessionType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getSessionDurationMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // Session duration in minutes following a relatively stable pattern + const values = generatePatternedValues(length, 15, 5, 0.05, 1.5) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for authentication flows +export const getPasswordResetMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'PasswordResetRequests') { + values = generatePatternedValues(length, 25, 15, 0.1, 2) + } else if (metric === 'PasswordResetCompleted') { + values = generatePatternedValues(length, 18, 10, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getEmailVerificationMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'VerificationSent') { + values = generatePatternedValues(length, 50, 20, 0.12, 1.8) + } else if (metric === 'VerificationCompleted') { + values = generatePatternedValues(length, 35, 15, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getVerificationRatesMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'EmailOpenRate') { + // Email open rates between 50-80% + values = generatePatternedValues(length, 65, 15, 0.05, 1.2).map((v) => Math.min(v, 100)) + } else if (metric === 'VerificationRate') { + // Verification completion rates between 60-90% + values = generatePatternedValues(length, 75, 15, 0.05, 1.1).map((v) => Math.min(v, 100)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getMfaUsageMockData = (method: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (method === 'MfaSMS') { + values = generatePatternedValues(length, 15, 8, 0.1, 1.5) + } else if (method === 'MfaAuthenticator') { + values = generatePatternedValues(length, 25, 10, 0.1, 1.4) + } else if (method === 'MfaEmail') { + values = generatePatternedValues(length, 10, 5, 0.1, 1.6) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [method]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getMfaAdoptionMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // MFA adoption between 15-35% + const values = generatePatternedValues(length, 25, 10, 0.05, 1.2).map((v) => Math.min(v, 100)) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for error and security charts +export const getAuthErrorsMockData = (errorType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (errorType === 'Status403') { + // 403 Forbidden - Relatively uncommon + values = generatePatternedValues(length, 1, 1, 0.05, 3) + } else if (errorType === 'Status422') { + // 422 Unprocessable Entity - More common validation errors + values = generatePatternedValues(length, 8, 5, 0.12, 2) + } else if (errorType === 'Status429') { + // 429 Too Many Requests - Rate limiting, can be spiky + values = generatePatternedValues(length, 3, 2, 0.15, 4) + } else if (errorType === 'Status500') { + // 500 Internal Server Error - Server errors, usually low but impactful + values = generatePatternedValues(length, 1, 1, 0.08, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [errorType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getProviderErrorRateMockData = (provider: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + // Error rates between 0.5-8% + if (provider === 'EmailErrorRate') { + values = generatePatternedValues(length, 2.5, 2, 0.15, 2).map((v) => Math.min(v, 10)) + } else if (provider === 'GoogleErrorRate') { + values = generatePatternedValues(length, 1.2, 1, 0.1, 2.5).map((v) => Math.min(v, 10)) + } else if (provider === 'GitHubErrorRate') { + values = generatePatternedValues(length, 1.5, 1.2, 0.12, 2.2).map((v) => Math.min(v, 10)) + } else if (provider === 'FacebookErrorRate') { + values = generatePatternedValues(length, 2, 1.5, 0.1, 2).map((v) => Math.min(v, 10)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 10, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getRateLimitingMockData = (eventType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (eventType === 'IPRateLimited') { + values = generatePatternedValues(length, 4, 3, 0.1, 4) + } else if (eventType === 'UserRateLimited') { + values = generatePatternedValues(length, 2, 2, 0.08, 3) + } else if (eventType === 'BruteForceAttempts') { + values = generatePatternedValues(length, 1, 1, 0.05, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [eventType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getAuthLatencyMockData = (operationType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (operationType === 'SignInLatency') { + values = generatePatternedValues(length, 180, 50, 0.1, 1.5) + } else if (operationType === 'SignUpLatency') { + values = generatePatternedValues(length, 250, 70, 0.1, 1.4) + } else if (operationType === 'TokenRefreshLatency') { + values = generatePatternedValues(length, 120, 40, 0.1, 1.6) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [operationType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(0)), + total, + } +} + +export const getSecurityEventsMockData = (eventType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (eventType === 'SuspiciousLogins') { + values = generatePatternedValues(length, 3, 2, 0.1, 3) + } else if (eventType === 'NewDeviceLogins') { + values = generatePatternedValues(length, 15, 8, 0.1, 1.5) + } else if (eventType === 'PasswordBreachDetections') { + values = generatePatternedValues(length, 1, 1, 0.05, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [eventType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getTokenUsageMockData = (metricType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metricType === 'TokenIssuance') { + values = generatePatternedValues(length, 100, 40, 0.1, 1.5) + } else if (metricType === 'TokenRefresh') { + values = generatePatternedValues(length, 400, 100, 0.1, 1.3) + } else if (metricType === 'TokenRevocation') { + values = generatePatternedValues(length, 30, 15, 0.1, 2) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metricType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Mock data generators for conversion metrics +export const getAuthFunnelMockData = (stage: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (stage === 'PageVisits') { + values = generatePatternedValues(length, 500, 200, 0.1, 1.5) + } else if (stage === 'SignupStarts') { + values = generatePatternedValues(length, 120, 50, 0.1, 1.4) + } else if (stage === 'SignupCompletes') { + values = generatePatternedValues(length, 80, 30, 0.1, 1.3) + } else if (stage === 'FirstLogins') { + values = generatePatternedValues(length, 75, 25, 0.1, 1.3) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [stage]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getConversionRatesMockData = (rateType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (rateType === 'VisitToSignupRate') { + // Visit to signup: 20-30% + values = generatePatternedValues(length, 25, 5, 0.1, 1.2).map((v) => Math.min(v, 40)) + } else if (rateType === 'SignupCompletionRate') { + // Signup completion: 60-80% + values = generatePatternedValues(length, 70, 10, 0.1, 1.1).map((v) => Math.min(v, 90)) + } else if (rateType === 'RetentionRate') { + // 7-day retention: 40-60% + values = generatePatternedValues(length, 50, 10, 0.1, 1.1).map((v) => Math.min(v, 75)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [rateType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getOnboardingCompletionMockData = (stage: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (stage === 'OnboardingStarts') { + values = generatePatternedValues(length, 70, 25, 0.1, 1.4) + } else if (stage === 'ProfileCompletions') { + values = generatePatternedValues(length, 50, 20, 0.1, 1.3) + } else if (stage === 'VerificationCompletions') { + values = generatePatternedValues(length, 40, 15, 0.1, 1.3) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [stage]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getOnboardingTimeMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // Onboarding time in minutes, generally 5-15 minutes + const values = generatePatternedValues(length, 10, 5, 0.1, 1.3) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Master mock data resolver - routes to the appropriate data generator based on attribute +export const getMockDataForAttribute = (attribute: string) => { + // Auth Requests (original chart) + if ( + [ + 'Email', + 'Google', + 'GitHub', + 'Phone', + 'SAML', + 'Apple', + 'Azure', + 'Bitbucket', + 'Discord', + 'Facebook', + 'Figma', + 'GitLab', + 'Kakao', + 'KeyCloak', + 'LinkedIn', + 'Notion', + 'Twitch', + 'Twitter', + 'Slack', + 'Spotify', + 'WorkOS', + 'Zoom', + ].includes(attribute) + ) { + return getAuthRequestsMockData(attribute) + } + + // Provider-specific sessions + if ( + [ + 'EmailSessions', + 'PhoneSessions', + 'SAMLSessions', + 'AppleSessions', + 'AzureSessions', + 'BitbucketSessions', + 'DiscordSessions', + 'FacebookSessions', + 'FigmaSessions', + 'GitHubSessions', + 'GitLabSessions', + 'GoogleSessions', + 'KakaoSessions', + 'KeyCloakSessions', + 'LinkedInSessions', + 'NotionSessions', + 'TwitchSessions', + 'TwitterSessions', + 'SlackSessions', + 'SpotifySessions', + 'WorkOSSessions', + 'ZoomSessions', + ].includes(attribute) + ) { + return getProviderSessionsMockData(attribute) + } + + // Provider-specific churn + if ( + [ + 'EmailChurn', + 'PhoneChurn', + 'SAMLChurn', + 'AppleChurn', + 'AzureChurn', + 'BitbucketChurn', + 'DiscordChurn', + 'FacebookChurn', + 'FigmaChurn', + 'GitHubChurn', + 'GitLabChurn', + 'GoogleChurn', + 'KakaoChurn', + 'KeyCloakChurn', + 'LinkedInChurn', + 'NotionChurn', + 'TwitchChurn', + 'TwitterChurn', + 'SlackChurn', + 'SpotifyChurn', + 'WorkOSChurn', + 'ZoomChurn', + ].includes(attribute) + ) { + return getProviderChurnMockData(attribute) + } + + // Overall churn rate + if (attribute === 'ChurnRate') { + return getChurnRateMockData(attribute) + } + + // Active Users charts + if (['DAU', 'WAU', 'MAU'].includes(attribute)) { + return getActiveUsersMockData(attribute) + } + + // User Types charts + if (['NewUsers', 'ReturningUsers'].includes(attribute)) { + return getUserTypesMockData(attribute) + } + + // Auth Sessions charts + if (['TotalSessions', 'WebSessions', 'MobileSessions'].includes(attribute)) { + return getAuthSessionsMockData(attribute) + } + + // Session Duration + if (attribute === 'AvgSessionDuration') { + return getSessionDurationMockData(attribute) + } + + // Password Reset metrics + if (['PasswordResetRequests', 'PasswordResetCompleted'].includes(attribute)) { + return getPasswordResetMockData(attribute) + } + + // Email Verification metrics + if (['VerificationSent', 'VerificationCompleted'].includes(attribute)) { + return getEmailVerificationMockData(attribute) + } + + // Verification Rates + if (['EmailOpenRate', 'VerificationRate'].includes(attribute)) { + return getVerificationRatesMockData(attribute) + } + + // MFA Usage + if (['MfaSMS', 'MfaAuthenticator', 'MfaEmail'].includes(attribute)) { + return getMfaUsageMockData(attribute) + } + + // MFA Adoption Rate + if (attribute === 'MfaAdoptionRate') { + return getMfaAdoptionMockData(attribute) + } + + // Auth Errors + if (['Status403', 'Status422', 'Status429', 'Status500'].includes(attribute)) { + return getAuthErrorsMockData(attribute) + } + + // Provider Error Rates + if ( + ['EmailErrorRate', 'GoogleErrorRate', 'GitHubErrorRate', 'FacebookErrorRate'].includes( + attribute + ) + ) { + return getProviderErrorRateMockData(attribute) + } + + // Rate Limiting + if (['IPRateLimited', 'UserRateLimited', 'BruteForceAttempts'].includes(attribute)) { + return getRateLimitingMockData(attribute) + } + + // Auth Latency + if (['SignInLatency', 'SignUpLatency', 'TokenRefreshLatency'].includes(attribute)) { + return getAuthLatencyMockData(attribute) + } + + // Security Events + if (['SuspiciousLogins', 'NewDeviceLogins', 'PasswordBreachDetections'].includes(attribute)) { + return getSecurityEventsMockData(attribute) + } + + // Token Usage + if (['TokenIssuance', 'TokenRefresh', 'TokenRevocation'].includes(attribute)) { + return getTokenUsageMockData(attribute) + } + + // Auth Funnel + if (['PageVisits', 'SignupStarts', 'SignupCompletes', 'FirstLogins'].includes(attribute)) { + return getAuthFunnelMockData(attribute) + } + + // Conversion Rates + if (['VisitToSignupRate', 'SignupCompletionRate', 'RetentionRate'].includes(attribute)) { + return getConversionRatesMockData(attribute) + } + + // Onboarding Completion + if (['OnboardingStarts', 'ProfileCompletions', 'VerificationCompletions'].includes(attribute)) { + return getOnboardingCompletionMockData(attribute) + } + + // Onboarding Time + if (attribute === 'TimeToCompleteOnboarding') { + return getOnboardingTimeMockData(attribute) + } + + // Default fallback + return { + data: DEFAULT_TIME_POINTS.map((time) => ({ period_start: time, [attribute]: 0 })), + yAxisLimit: 10, + format: '', + totalAverage: 0, + total: 0, + } +} diff --git a/apps/studio/data/reports/auth-report-query.ts b/apps/studio/data/reports/auth-report-query.ts new file mode 100644 index 00000000000..99f6ec0c352 --- /dev/null +++ b/apps/studio/data/reports/auth-report-query.ts @@ -0,0 +1,328 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { AnalyticsInterval } from 'data/analytics/constants' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' +import { getHttpStatusCodeInfo } from 'lib/http-status-codes' +import { analyticsIntervalToGranularity } from './report.utils' + +/** + * METRICS + * Each chart in the UI has a corresponding metric key. + */ + +const METRIC_KEYS = [ + 'ActiveUsers', + 'SignInAttempts', + 'PasswordResetRequests', + 'TotalSignUps', + 'SignInLatency', + 'SignUpLatency', + 'ErrorsByStatus', +] + +const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { + '400': { light: '#FFD54F', dark: '#FFF176' }, + '401': { light: '#FF8A65', dark: '#FFAB91' }, + '403': { light: '#FFB74D', dark: '#FFCC80' }, + '404': { light: '#90A4AE', dark: '#B0BEC5' }, + '409': { light: '#BA68C8', dark: '#CE93D8' }, + '410': { light: '#A1887F', dark: '#BCAAA4' }, + '422': { light: '#FF9800', dark: '#FFB74D' }, + '429': { light: '#E65100', dark: '#F57C00' }, + '500': { light: '#B71C1C', dark: '#D32F2F' }, + '502': { light: '#9575CD', dark: '#B39DDB' }, + '503': { light: '#0097A7', dark: '#4DD0E1' }, + '504': { light: '#C0CA33', dark: '#D4E157' }, + default: { light: '#757575', dark: '#9E9E9E' }, +} + +type MetricKey = (typeof METRIC_KEYS)[number] + +/** + * SQL + * Each metric has a corresponding SQL query. + */ + +const METRIC_SQL: Record string> = { + ActiveUsers: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --active-users + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") in ( + 'login', 'user_signedup', 'token_refreshed', 'user_modified', + 'user_recovery_requested', 'user_reauthenticate_requested' + ) + group by timestamp + order by timestamp desc + ` + }, + SignInAttempts: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --sign-in-attempts + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.grant_type") as grant_type, + count(*) as count + from auth_logs + where json_value(event_message, "$.path") = '/token' + group by timestamp, grant_type + order by timestamp desc, grant_type + ` + }, + PasswordResetRequests: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --password-reset-requests + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' + group by timestamp + order by timestamp desc + ` + }, + TotalSignUps: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --total-signups + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + SignInLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signin-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.grant_type") as grant_type, + count(*) as request_count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.path") = '/token' + group by timestamp, grant_type + order by timestamp desc, grant_type + ` + }, + SignUpLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signup-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.auth_event.traits.provider") as provider, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + and json_value(event_message, "$.status") = '200' + group by timestamp, provider + order by timestamp desc, provider + ` + }, + ErrorsByStatus: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --auth-errors-by-status +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + response.status_code +from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + cross join unnest(m.response) as response +where path like '%/auth%' + and response.status_code >= 400 and response.status_code <= 599 +group by timestamp, status_code +order by timestamp desc + ` + }, +} + +/** + * FORMATTERS. + * Metrics need to be formatted before being passed on to the UI charts. + */ + +function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { + const chartAttributes = attributes + if (!rawData) return { data: undefined, chartAttributes } + const result = rawData.result || [] + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[attributes[0].attribute] = p.count + }) + return point + }) + return { data, chartAttributes } +} + +const METRIC_FORMATTER: Record< + MetricKey, + ( + rawData: any, + attributes: MultiAttribute[], + logsMetric: string + ) => { data: any; chartAttributes: any } +> = { + ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignInAttempts: (rawData, attributes) => { + const chartAttributes = attributes.map((attr) => { + if (attr.attribute === 'SignInAttempts' && attr.grantType) { + return { ...attr, attribute: `${attr.attribute}_${attr.grantType}` } + } + return attr + }) + if (!rawData) return { data: undefined, chartAttributes } + const result = rawData.result || [] + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[`SignInAttempts_${p.grant_type}`] = p.count + }) + return point + }) + return { data, chartAttributes } + }, + PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes), + TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), + ErrorsByStatus: (rawData, attributes) => { + if (!rawData) return { data: undefined, chartAttributes: attributes } + const result = rawData.result || [] + + const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) + + const chartAttributes = statusCodes.map((statusCode) => { + const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) + const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default + + return { + attribute: `status_${statusCode}`, + label: `${statusCode} ${statusCodeInfo.label}`, + provider: 'logs', + enabled: true, + color: color, + statusCode: String(statusCode), + } + }) + + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[`status_${p.status_code}`] = p.count + }) + return point + }) + + return { data, chartAttributes } + }, +} + +/** + * REPORT QUERY. + * Fetching and state management for the report. + */ + +export function useAuthLogsReport({ + projectRef, + attributes, + startDate, + endDate, + interval, + enabled = true, +}: { + projectRef: string + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: AnalyticsInterval + enabled?: boolean +}) { + const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' + + const isAuthMetric = METRIC_KEYS.includes(logsMetric) + + const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : '' + + const { + data: rawData, + error, + isLoading, + } = useQuery( + ['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql], + async () => { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + }) + if (error) throw error + return data + }, + { + enabled: Boolean(projectRef && sql && enabled && isAuthMetric), + refetchOnWindowFocus: false, + } + ) + + // Use formatter if available + const formatter = + (isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter + const { data, chartAttributes } = formatter(rawData, attributes, logsMetric) + + return { + data, + attributes: chartAttributes, + isLoading, + error, + } +} diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 718534e7eca..0d3be727670 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -45,7 +45,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep showLegend: true, hideChartType: false, defaultChartStyle: 'line', - showMaxValue: true, + showMaxValue: false, showGrid: true, syncId: 'database-reports', valuePrecision: 2, @@ -59,36 +59,21 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'Used', tooltip: - 'Total RAM currently in use by the system and applications. High usage may indicate memory pressure.', + 'RAM in use by Postgres and the operating system. Sustained high usage may indicate memory pressure', }, { attribute: 'ram_usage_cache_and_buffers', provider: 'infra-monitoring', label: 'Cache + buffers', tooltip: - 'RAM used for caching and buffering disk operations. Higher values improve read performance.', + 'RAM used by the operating system page cache and PostgreSQL buffers to accelerate disk reads/writes', }, { attribute: 'ram_usage_free', provider: 'infra-monitoring', label: 'Free', tooltip: - 'Available unused RAM. A small amount is always reserved. High unused RAM present with erratic Used RAM may indicate unoptimized queries disrupting cache.', - }, - { - attribute: 'ram_usage_swap', - provider: 'infra-monitoring', - label: 'Swap', - omitFromTotal: false, // we can set this to true if we want to omit the swap from the total - tooltip: - 'Memory swapped to disk when RAM is full. An instance has 1GB of SWAP. High swap with high disk I/O signals memory stress.', - }, - { - attribute: 'ram_usage_total', - provider: 'infra-monitoring', - label: 'Max', - isMaxValue: true, - tooltip: 'Total RAM', + 'Unallocated memory available for use. A small portion is always reserved by the operating system', }, ], }, @@ -116,7 +101,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'System', format: '%', tooltip: - 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead.', + 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead', }, { attribute: 'cpu_usage_busy_user', @@ -124,7 +109,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'User', format: '%', tooltip: - 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries.', + 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries', }, { attribute: 'cpu_usage_busy_iowait', @@ -132,7 +117,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'IOwait', format: '%', tooltip: - 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks.', + 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks', }, { attribute: 'cpu_usage_busy_irqs', @@ -147,7 +132,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'Other', format: '%', tooltip: - 'CPU time spent on other tasks (e.g., background processes, software interrupts).', + 'CPU time spent on other tasks (e.g., background processes, software interrupts)', }, { attribute: 'cpu_usage_max', @@ -161,7 +146,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'disk-iops', - label: 'Disk IOps', + label: 'Disk Input/Output operations per second (IOPS)', syncId: 'database-reports', hide: false, showTooltip: true, @@ -180,16 +165,16 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep { attribute: 'disk_iops_write', provider: 'infra-monitoring', - label: 'IOps write/s', + label: 'write IOPS', tooltip: - 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity.', + 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity', }, { attribute: 'disk_iops_read', provider: 'infra-monitoring', - label: 'IOps read/s', + label: 'read IOPS', tooltip: - 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching.', + 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching', }, { attribute: 'disk_iops_max', @@ -225,7 +210,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'IO Usage', tooltip: - 'The actual number of IO operations per second that the database is currently using.', + 'The actual number of IO operations per second that the database is currently using', }, ], }, @@ -252,7 +237,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', format: 'bytes', label: 'System', - tooltip: 'Reserved space for the system to ensure your database runs smoothly.', + tooltip: 'Reserved space for the system to ensure your database runs smoothly', }, { attribute: 'disk_fs_used_wal', @@ -260,7 +245,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep format: 'bytes', label: 'WAL', tooltip: - 'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database.', + 'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database', }, { @@ -268,7 +253,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', format: 'bytes', label: 'Database', - tooltip: 'Disk usage by your database (tables, indexes, data, ...).', + tooltip: 'Disk usage by your database (tables, indexes, data, ...)', }, { attribute: 'disk_fs_size', @@ -306,43 +291,6 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }), ], }, - { - id: 'network-traffic', - label: 'Network Traffic', - syncId: 'database-reports', - valuePrecision: 1, - showTooltip: true, - showLegend: true, - showMaxValue: false, - showGrid: true, - YAxisProps: { - width: 65, - tickFormatter: (value: any) => formatBytes(value, 1), - }, - hideChartType: false, - defaultChartStyle: 'line', - hideHighlightedValue: true, - showTotal: false, - attributes: [ - { - attribute: 'network_receive_bytes', - provider: 'infra-monitoring', - label: 'Ingress', - manipulateValue: (value: number) => value * -1, - tooltip: - 'Data received by your database from clients. High values may indicate frequent queries, large data inserts, or many incoming connections.', - stackId: '1', - }, - { - attribute: 'network_transmit_bytes', - provider: 'infra-monitoring', - label: 'Egress', - tooltip: - 'Data sent from your database to clients. High values may indicate large query results or numerous outgoing connections.', - stackId: '2', - }, - ], - }, { id: 'client-connections', label: 'Database client connections', @@ -352,6 +300,8 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep showLegend: true, showMaxValue: true, hideChartType: false, + showGrid: true, + YAxisProps: { width: 30 }, defaultChartStyle: 'line', attributes: [ { @@ -359,39 +309,37 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'Postgres', tooltip: - 'Direct connections to the Postgres database from your application and external clients.', + 'Direct connections to the Postgres database from your application and external clients', }, { attribute: 'client_connections_authenticator', provider: 'infra-monitoring', label: 'PostgREST', - tooltip: 'Connections magaged by PostgREST to auto-generate RESTful API.', + tooltip: 'Connection pool managed by PostgREST', }, { attribute: 'client_connections_supabase_admin', provider: 'infra-monitoring', - label: 'Admin', + label: 'Reserved', tooltip: - 'Administrative connections used by various Supabase services for internal operations and maintenance tasks.', + 'Administrative connections used by various Supabase services for internal operations and maintenance tasks', }, { attribute: 'client_connections_supabase_auth_admin', provider: 'infra-monitoring', label: 'Auth', - tooltip: - 'Administrative connections used by Supabase Auth service for user management and authentication operations.', + tooltip: 'Connection pool managed by Supabase Auth', }, { attribute: 'client_connections_supabase_storage_admin', provider: 'infra-monitoring', label: 'Storage', - tooltip: - 'Administrative connections used by Supabase Storage service for file operations and bucket management.', + tooltip: 'Connection pool managed by Supabase Storage', }, { attribute: 'client_connections_other', provider: 'infra-monitoring', - label: 'Other', + label: 'Other roles', tooltip: "Miscellaneous database connections that don't fall into other categories.", }, { @@ -406,7 +354,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'pgbouncer-connections', - label: 'Pooler client connections', + label: 'Dedicated Pooler client connections', syncId: 'database-reports', valuePrecision: 0, hide: false, @@ -437,7 +385,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'supavisor-connections-active', - label: 'Supavisor client connections', + label: 'Shared Pooler (Supavisor) client connections', syncId: 'database-reports', valuePrecision: 0, hide: isFreePlan, diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts new file mode 100644 index 00000000000..e41bf7e759d --- /dev/null +++ b/apps/studio/data/reports/report.utils.ts @@ -0,0 +1,21 @@ +import { AnalyticsInterval } from 'data/analytics/constants' + +export type Granularity = 'minute' | 'hour' | 'day' | 'week' +export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { + switch (interval) { + case '1m': + return 'minute' + case '5m': + return 'minute' + case '10m': + return 'minute' + case '30m': + return 'minute' + case '1h': + return 'hour' + case '1d': + return 'day' + default: + return 'hour' + } +} diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts index 44df6615185..fc660901b76 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -74,7 +74,9 @@ export const useConfirmPendingSubscriptionChangeMutation = ({ queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), queryClient.invalidateQueries(organizationKeys.detail(slug)), queryClient.invalidateQueries(organizationKeys.list()), + queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)), ]) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index d899039b23c..d2806ab227b 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -68,6 +68,20 @@ export const useOrgSubscriptionUpdateMutation = ({ queryClient.invalidateQueries(organizationKeys.detail(slug)), queryClient.invalidateQueries(organizationKeys.list()), ]) + + if (variables.paymentMethod) { + queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { + if (!prev) return prev + return { + ...prev, + defaultPaymentMethodId: variables.paymentMethod, + data: prev.data.map((pm: any) => ({ + ...pm, + is_default: pm.id === variables.paymentMethod, + })), + } + }) + } } await onSuccess?.(data, variables, context) diff --git a/apps/studio/hooks/analytics/useLogsPreview.tsx b/apps/studio/hooks/analytics/useLogsPreview.tsx index 1e6d5a69ef6..27a62b248f2 100644 --- a/apps/studio/hooks/analytics/useLogsPreview.tsx +++ b/apps/studio/hooks/analytics/useLogsPreview.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' -import { useMemo, useState, useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' import { LogsTableName, @@ -22,8 +22,8 @@ import { genDefaultQuery, } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' -import { useLogsUrlState } from './useLogsUrlState' import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' +import { useLogsUrlState } from './useLogsUrlState' import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' interface LogsPreviewHook { @@ -83,12 +83,11 @@ function useLogsPreview({ const params: LogsEndpointParams = useMemo(() => { const currentSql = genDefaultQuery(table, mergedFilters, limit) return { - project: projectRef, iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd, sql: currentSql, } - }, [projectRef, timestampStart, timestampEnd, table, mergedFilters, limit]) + }, [timestampStart, timestampEnd, table, mergedFilters, limit]) const queryKey = useMemo(() => ['projects', projectRef, 'logs', params], [projectRef, params]) @@ -177,7 +176,6 @@ function useLogsPreview({ params: { path: { ref: projectRef }, query: { - project: projectRef, sql: countQuerySql, iso_timestamp_start: latestRefresh, iso_timestamp_end: timestampEnd, @@ -228,7 +226,6 @@ function useLogsPreview({ query: { iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd, - project: projectRef, sql: chartQuery, }, }, diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 77fec615d88..4e4f8b84aed 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { Dispatch, SetStateAction, useState, useEffect } from 'react' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { IS_PLATFORM } from 'common' import { @@ -37,7 +37,6 @@ const useLogsQuery = ( const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) const [params, setParams] = useState({ sql: initialParams?.sql || '', - project: projectRef, iso_timestamp_start: initialParams.iso_timestamp_start ? initialParams.iso_timestamp_start : defaultHelper.calcFrom(), @@ -50,17 +49,11 @@ const useLogsQuery = ( setParams((prev) => ({ ...prev, ...initialParams, - project: projectRef, sql: initialParams?.sql ?? prev.sql, iso_timestamp_start: initialParams.iso_timestamp_start ?? prev.iso_timestamp_start, iso_timestamp_end: initialParams.iso_timestamp_end ?? prev.iso_timestamp_end, })) - }, [ - projectRef, - initialParams.sql, - initialParams.iso_timestamp_start, - initialParams.iso_timestamp_end, - ]) + }, [initialParams.sql, initialParams.iso_timestamp_start, initialParams.iso_timestamp_end]) const _enabled = enabled && typeof projectRef !== 'undefined' && Boolean(params.sql) diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx index 291c17ecb47..6ca288c2122 100644 --- a/apps/studio/hooks/analytics/useSingleLog.tsx +++ b/apps/studio/hooks/analytics/useSingleLog.tsx @@ -31,7 +31,7 @@ function useSingleLog({ const table = queryType ? LOGS_TABLES[queryType] : undefined const sql = id && table ? genSingleLogQuery(table, id) : '' - const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql } + const params: LogsEndpointParams = { ...paramsToMerge, sql } const isWarehouseQuery = queryType === 'warehouse' // Warehouse queries are handled differently diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts new file mode 100644 index 00000000000..a0c293e93ae --- /dev/null +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -0,0 +1,137 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { LOCAL_STORAGE_KEYS } from 'common' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' +import { OPT_IN_TAGS } from 'lib/constants' +import type { ResponseError } from 'types' + +// Shared schema definition +export const AIOptInSchema = z.object({ + aiOptInLevel: z.enum(['disabled', 'schema', 'schema_and_log', 'schema_and_log_and_data'], { + required_error: 'AI Opt-in level selection is required', + }), +}) + +export type AIOptInFormValues = z.infer + +/** + * Hook to manage the AI Opt-In form state and submission logic. + * Optionally takes an onSuccess callback (e.g., to close a modal). + */ +export const useAIOptInForm = (onSuccessCallback?: () => void) => { + const queryClient = useQueryClient() + const selectedOrganization = useSelectedOrganization() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const useBedrockAssistant = useFlag('useBedrockAssistant') + + const [_, setUpdatedOptInSinceMCP] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, + false + ) + + // [Joshen] This is to prevent users from changing their opt in levels until the migration + // to clean up the existing opt in tags are completed. Once toggled on, users can then change their + // opt in levels again and we can clean this feature flag up + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const form = useForm({ + resolver: zodResolver(AIOptInSchema), + defaultValues: { + aiOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), + }, + }) + + const onSubmit = async (values: AIOptInFormValues) => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + if (!selectedOrganization?.slug) { + console.error('Organization slug is required') + return toast.error('Failed to update settings: Organization not found.') + } + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + + if (!useBedrockAssistant) { + const updatedOptInTags = values.aiOptInLevel === 'schema' ? [OPT_IN_TAGS.AI_SQL] : [] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) + } else { + let updatedOptInTags = existingOptInTags.filter( + (tag: string) => + tag !== OPT_IN_TAGS.AI_SQL && + tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && + tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') + ) + + if ( + values.aiOptInLevel === 'schema' || + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) + } + if ( + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) + } + if (values.aiOptInLevel === 'schema_and_log_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) + } + + updatedOptInTags = [...new Set(updatedOptInTags)] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) + } + } + + return { + form, + onSubmit, + isUpdating, + currentOptInLevel: !newOrgAiOptIn + ? 'disabled' + : getAiOptInLevel(selectedOrganization?.opt_in_tags), + } +} diff --git a/apps/studio/hooks/misc/useDisallowHipaa.ts b/apps/studio/hooks/misc/useDisallowHipaa.ts deleted file mode 100644 index 5c3cab91b62..00000000000 --- a/apps/studio/hooks/misc/useDisallowHipaa.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback } from 'react' - -import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' - -export function useDisallowHipaa() { - const selectedOrganization = useSelectedOrganization() - const project = useSelectedProject() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: project?.ref }) - - const disallowHipaa = useCallback( - (allowed: boolean) => { - return hasHipaaAddon && projectSettings?.is_sensitive ? false : allowed - }, - [hasHipaaAddon, projectSettings] - ) - - return disallowHipaa -} diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index ab898cac62c..eca1a2d618b 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -1,38 +1,66 @@ +import { z } from 'zod' + import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useDisallowHipaa } from 'hooks/misc/useDisallowHipaa' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { OPT_IN_TAGS } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' -/** - * Checks if the organization has opted into sending anonymous data to OpenAI. - * Also considers if the organization has the HIPAA addon. - * @returns boolean (false if either not opted in or has the HIPAA addon) - */ -export function useOrgOptedIntoAi() { - const selectedOrganization = useSelectedOrganization() - const selectedProject = useSelectedProject() - const optInTags = selectedOrganization?.opt_in_tags - const isOptedIntoAI = optInTags?.includes(OPT_IN_TAGS.AI_SQL) ?? false +export const aiOptInLevelSchema = z.enum([ + 'disabled', + 'schema', + 'schema_and_log', + 'schema_and_log_and_data', +]) - const disallowHipaa = useDisallowHipaa() - /* if we are in a project context and this has been called, - * ensure that we aren't letting HIPAA projects activate AI - * returns true if optedIntoAI and no project selected - * returns true if optedIntoAI and we are in a project and not HIPAA project - * returns false if opted out of AI - * returns false if optedIntoAI and we are in a HIPAA project - */ - return isOptedIntoAI && (!selectedProject || disallowHipaa(isOptedIntoAI)) +export type AiOptInLevel = z.infer + +export const getAiOptInLevel = (tags: string[] | undefined): AiOptInLevel => { + const hasSql = tags?.includes(OPT_IN_TAGS.AI_SQL) + const hasData = tags?.includes(OPT_IN_TAGS.AI_DATA) + const hasLog = tags?.includes(OPT_IN_TAGS.AI_LOG) + + if (hasData) { + return 'schema_and_log_and_data' + } else if (hasLog) { + return 'schema_and_log' + } else if (hasSql) { + return 'schema' + } else { + return 'disabled' + } } -export function useOrgOptedIntoAiAndHippaProject() { +/** + * Determines if the organization has opted into *any* level of AI features (schema or schema_and_log or schema_and_log_and_data). + * This is primarily for backward compatibility. + * @returns boolean (true if opted into schema or schema_and_log or schema_and_log_and_data, false otherwise) + */ +export function useOrgOptedIntoAi(): boolean { + const { aiOptInLevel } = useOrgAiOptInLevel() + return !IS_PLATFORM || aiOptInLevel !== 'disabled' +} + +/** + * Determines the organization's specific AI opt-in level and whether schema metadata should be included. + * @returns Object with aiOptInLevel and includeSchemaMetadata + */ +export function useOrgAiOptInLevel(): { + aiOptInLevel: AiOptInLevel + includeSchemaMetadata: boolean + isHipaaProjectDisallowed: boolean +} { const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + + // [Joshen] Default to disabled until migration to clean up existing opt in tags are completed + // Once toggled on, then we can default to their set opt in level and clean up feature flag const optInTags = selectedOrganization?.opt_in_tags - const isOptedIntoAI = optInTags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const level = !newOrgAiOptIn ? 'disabled' : getAiOptInLevel(optInTags) + const isOptedIntoAI = level !== 'disabled' const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) @@ -42,5 +70,17 @@ export function useOrgOptedIntoAiAndHippaProject() { const preventProjectFromUsingAI = hasHipaaAddon && isProjectSensitive - return { isOptedInToAI: isOptedIntoAI, isHipaaProjectDisallowed: preventProjectFromUsingAI } + // [Joshen] For CLI / self-host, we'd default to 'schema' as opt in level + const aiOptInLevel = !IS_PLATFORM + ? 'schema' + : (isOptedIntoAI && !selectedProject) || (isOptedIntoAI && !preventProjectFromUsingAI) + ? level + : 'disabled' + const includeSchemaMetadata = !IS_PLATFORM || aiOptInLevel !== 'disabled' + + return { + aiOptInLevel, + includeSchemaMetadata, + isHipaaProjectDisallowed: preventProjectFromUsingAI, + } } diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index af954876684..e16af5c23a7 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -34,11 +34,18 @@ export function useProjectByRef( }, [project, projects, ref]) } -export const useIsAwsNew = () => { +export const useIsAwsCloudProvider = () => { const project = useSelectedProject() - const isAwsNew = - project?.cloud_provider !== PROVIDERS.AWS.id && project?.cloud_provider !== PROVIDERS.FLY.id - return isAwsNew + const isAws = project?.cloud_provider === PROVIDERS.AWS.id + + return isAws +} + +export const useIsAwsK8sCloudProvider = () => { + const project = useSelectedProject() + const isAwsK8s = project?.cloud_provider === PROVIDERS.AWS_K8S.id + + return isAwsK8s } export const useIsOrioleDb = () => { diff --git a/apps/studio/hooks/ui/useCopyToClipboard.ts b/apps/studio/hooks/ui/useCopyToClipboard.ts new file mode 100644 index 00000000000..13601360c2e --- /dev/null +++ b/apps/studio/hooks/ui/useCopyToClipboard.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from 'react' +import { toast } from 'sonner' + +// [Joshen] This hook can replace all usage of copyToClipboard from lib/helpers +export function useCopyToClipboard() { + const [text, setText] = useState(null) + + const copy = useCallback( + async ( + text: string, + { timeout, withToast }: { timeout?: number; withToast?: boolean } = { + timeout: 3000, + withToast: false, + } + ) => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported') + return false + } + + try { + await navigator.clipboard.writeText(text) + setText(text) + + if (timeout) { + setTimeout(() => { + setText(null) + }, timeout) + } + + if (withToast) { + toast.success('Copied to clipboard') + } + + return true + } catch (error) { + console.warn('Copy failed', error) + setText(null) + return false + } + }, + [] + ) + + return { text, copy, isCopied: text !== null } +} diff --git a/apps/studio/hooks/ui/useHotKey.ts b/apps/studio/hooks/ui/useHotKey.ts new file mode 100644 index 00000000000..d0c381b9beb --- /dev/null +++ b/apps/studio/hooks/ui/useHotKey.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react' + +export function useHotKey(callback: () => void, key: string): void { + useEffect(() => { + function handler(e: KeyboardEvent) { + if (e.key === key && (e.metaKey || e.ctrlKey)) { + // e.preventDefault(); + callback() + } + } + + window.addEventListener('keydown', handler) + return () => { + window.removeEventListener('keydown', handler) + } + }, [key]) +} diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts new file mode 100644 index 00000000000..003d4bab529 --- /dev/null +++ b/apps/studio/hooks/useChartData.ts @@ -0,0 +1,188 @@ +/** + * useChartData + * + * A hook for fetching and processing data for a chart. + * This hook is responsible for all the data fetching, combining, and state management logic + * that was previously inside ComposedChartHandler. + * + * It takes all necessary parameters like project reference, date range, and attributes, + * and returns the final chart data, loading state, and derived attributes. + */ +import { useMemo } from 'react' +import { useRouter } from 'next/router' + +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' +import { useAuthLogsReport } from 'data/reports/auth-report-query' +import type { ChartData } from 'components/ui/Charts/Charts.types' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' +import { useAttributeQueries } from 'components/ui/Charts/LogChartHandler' + +export const useChartData = ({ + attributes, + startDate, + endDate, + interval, + data, + highlightedValue, +}: { + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: string + data?: ChartData + highlightedValue?: string | number +}) => { + const router = useRouter() + const { ref } = router.query + const state = useDatabaseSelectorStateSnapshot() + + const logsAttributes = attributes.filter((attr) => attr.provider === 'logs') + const nonLogsAttributes = attributes.filter((attr) => attr.provider !== 'logs') + + const { + data: logsData, + attributes: logsChartAttributes, + isLoading: isLogsLoading, + } = useAuthLogsReport({ + projectRef: ref as string, + attributes: logsAttributes, + startDate, + endDate, + interval: interval as AnalyticsInterval, + enabled: logsAttributes.length > 0, + }) + + const chartAttributes = useMemo( + () => nonLogsAttributes.concat(logsChartAttributes || []), + [nonLogsAttributes, logsChartAttributes] + ) + + const databaseIdentifier = state.selectedDatabaseId + + // Use the custom hook at the top level of the component + const attributeQueries = useAttributeQueries( + attributes, + ref, + startDate, + endDate, + interval as AnalyticsInterval, + databaseIdentifier, + data, + true + ) + + // Combine all the data into a single dataset + const combinedData = useMemo(() => { + if (data) return data + + const regularAttributeQueries = attributeQueries.filter( + (q) => q.data?.provider !== 'logs' && q.data?.provider !== 'reference-line' + ) + const isLoading = + (logsAttributes.length > 0 && isLogsLoading) || + regularAttributeQueries.some((query: any) => query.isLoading) + if (isLoading) { + return undefined + } + + const hasError = regularAttributeQueries.some((query: any) => !query.data) + if (hasError) { + return undefined + } + + // Get all unique timestamps from all datasets + const timestamps = new Set() + if (logsData) { + logsData.forEach((point: any) => { + if (point?.period_start) { + timestamps.add(point.period_start) + } + }) + } + regularAttributeQueries.forEach((query: any) => { + query.data?.data?.forEach((point: any) => { + if (point?.period_start) { + timestamps.add(point.period_start) + } + }) + }) + + const referenceLineQueries = attributeQueries.filter( + (q) => q.data?.provider === 'reference-line' + ) + + // Combine data points for each timestamp + const combined = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + + const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {} + Object.assign(point, logPoint) + + chartAttributes.forEach((attr) => { + if (!attr) return + if (attr.provider === 'logs') return + if (attr.provider === 'reference-line') return + if (attr.customValue !== undefined) { + point[attr.attribute] = attr.customValue + return + } + + const query = regularAttributeQueries.find((q) => q.data?.attribute === attr.attribute) + const matchingPoint = query?.data?.data?.find((p: any) => p.period_start === timestamp) + point[attr.attribute] = matchingPoint?.[attr.attribute] ?? 0 + }) + + // Add reference line values for each timestamp + referenceLineQueries.forEach((query: any) => { + const attr = query.data.attribute + const value = query.data.total + point[attr] = value + }) + + return point as DataPoint + }) + + return combined as DataPoint[] + }, [data, attributeQueries, attributes, chartAttributes, isLogsLoading, logsData, logsAttributes]) + + const loading = + (logsAttributes.length > 0 && isLogsLoading) || + attributeQueries.some((query: any) => query.isLoading) + + // Calculate highlighted value based on the first attribute's data + const _highlightedValue = useMemo(() => { + if (highlightedValue !== undefined) return highlightedValue + + const firstAttr = attributes[0] + const firstQuery = attributeQueries[0] + const firstData = firstQuery?.data + + if (!firstData) return undefined + + const shouldHighlightMaxValue = + firstAttr.provider === 'daily-stats' && + !firstAttr.attribute.includes('ingress') && + !firstAttr.attribute.includes('egress') && + 'maximum' in firstData + + const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData + + return shouldHighlightMaxValue + ? firstData.maximum + : firstAttr.provider === 'daily-stats' + ? firstData.total + : shouldHighlightTotalGroupedValue + ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] + : (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute] + }, [highlightedValue, attributes, attributeQueries]) + + return { + data: combinedData, + isLoading: loading, + chartAttributes, + highlightedValue: _highlightedValue, + } +} diff --git a/apps/studio/lib/ai/bedrock.ts b/apps/studio/lib/ai/bedrock.ts new file mode 100644 index 00000000000..b5fde1a08dd --- /dev/null +++ b/apps/studio/lib/ai/bedrock.ts @@ -0,0 +1,29 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' +import { createCredentialChain, fromNodeProviderChain } from '@aws-sdk/credential-providers' +import { awsCredentialsProvider } from '@vercel/functions/oidc' + +const credentialProvider = createCredentialChain( + // Vercel OIDC provider will be used for staging/production + awsCredentialsProvider({ + roleArn: process.env.AWS_BEDROCK_ROLE_ARN!, + }), + + // AWS profile will be used for local development + fromNodeProviderChain({ + profile: process.env.AWS_BEDROCK_PROFILE, + }) +) + +export const bedrock = createAmazonBedrock({ + credentialProvider, + region: process.env.AWS_BEDROCK_REGION, +}) + +export async function checkAwsCredentials() { + try { + const credentials = await credentialProvider() + return !!credentials + } catch (error) { + return false + } +} diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts new file mode 100644 index 00000000000..bb087a1e6a0 --- /dev/null +++ b/apps/studio/lib/ai/model.test.ts @@ -0,0 +1,65 @@ +import { openai } from '@ai-sdk/openai' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as bedrockModule from './bedrock' +import { bedrock } from './bedrock' +import { getModel, ModelErrorMessage, modelsByProvider } from './model' + +vi.mock('@ai-sdk/openai', () => ({ + openai: vi.fn(() => 'openai-model'), +})) + +vi.mock('./bedrock', () => ({ + bedrock: vi.fn(() => 'bedrock-model'), + checkAwsCredentials: vi.fn(), +})) + +describe('getModel', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.resetAllMocks() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('should return bedrock model when AWS credentials are available and AWS_BEDROCK_REGION is set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true) + process.env.AWS_BEDROCK_REGION = 'us-east-1' + + const { model, error } = await getModel() + + expect(model).toEqual('bedrock-model') + expect(bedrock).toHaveBeenCalledWith(modelsByProvider.bedrock) + expect(error).toBeUndefined() + }) + + it('should return error when AWS credentials are available but AWS_BEDROCK_REGION is not set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true) + delete process.env.AWS_BEDROCK_REGION + + const { error } = await getModel() + + expect(error).toEqual(new Error('AWS_BEDROCK_REGION is not set')) + }) + + it('should return OpenAI model when AWS credentials are not available but OPENAI_API_KEY is set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false) + process.env.OPENAI_API_KEY = 'test-key' + + const { model } = await getModel() + + expect(model).toEqual('openai-model') + expect(openai).toHaveBeenCalledWith(modelsByProvider.openai) + }) + + it('should return error when neither AWS credentials nor OPENAI_API_KEY is available', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false) + delete process.env.OPENAI_API_KEY + + const { error } = await getModel() + + expect(error).toEqual(new Error(ModelErrorMessage)) + }) +}) diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts new file mode 100644 index 00000000000..a419e5152d6 --- /dev/null +++ b/apps/studio/lib/ai/model.ts @@ -0,0 +1,53 @@ +import { openai } from '@ai-sdk/openai' +import { LanguageModel } from 'ai' +import { bedrock, checkAwsCredentials } from './bedrock' + +export const modelsByProvider = { + bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0', + openai: 'gpt-4.1-2025-04-14', +} + +export type ModelSuccess = { + model: LanguageModel + error?: never +} + +export type ModelError = { + model?: never + error: Error +} + +export type ModelResponse = ModelSuccess | ModelError + +export const ModelErrorMessage = + 'No valid AI model available. Please set up a local AWS profile to use Bedrock, or pass an OPENAI_API_KEY to use OpenAI.' + +/** + * Retrieves the appropriate AI model based on available credentials. + */ +export async function getModel(): Promise { + const hasAwsCredentials = await checkAwsCredentials() + const hasOpenAIKey = !!process.env.OPENAI_API_KEY + + if (hasAwsCredentials) { + if (!process.env.AWS_BEDROCK_REGION) { + return { + error: new Error('AWS_BEDROCK_REGION is not set'), + } + } + + return { + model: bedrock(modelsByProvider.bedrock), + } + } + + if (hasOpenAIKey) { + return { + model: openai(modelsByProvider.openai), + } + } + + return { + error: new Error(ModelErrorMessage), + } +} diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index a429c02ff6e..7113b142d08 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -41,6 +41,8 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const OPT_IN_TAGS = { AI_SQL: 'AI_SQL_GENERATOR_OPT_IN', + AI_DATA: 'AI_DATA_GENERATOR_OPT_IN', + AI_LOG: 'AI_LOG_GENERATOR_OPT_IN', } export const GB = 1024 * 1024 * 1024 diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index f240cc2bb0d..cfd69d3244e 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -41,8 +41,8 @@ export const PROVIDERS = { default_region: AWS_REGIONS_DEFAULT, regions: { ...AWS_REGIONS }, }, - AWS_NEW: { - id: 'AWS_NEW', + AWS_K8S: { + id: 'AWS_K8S', name: 'AWS (Revamped)', DEFAULT_SSH_KEY: 'supabase-app-instance', default_region: AWS_REGIONS_DEFAULT, diff --git a/apps/studio/lib/http-status-codes.test.ts b/apps/studio/lib/http-status-codes.test.ts new file mode 100644 index 00000000000..171f4950fe0 --- /dev/null +++ b/apps/studio/lib/http-status-codes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { getHttpStatusCodeInfo } from './http-status-codes' + +describe('getHttpStatusCodeInfo', () => { + it('should return the correct status code info', () => { + const statusCodeInfo = getHttpStatusCodeInfo(400) + expect(statusCodeInfo).toEqual({ + code: 400, + name: 'BAD_REQUEST', + message: 'The server cannot or will not process the request due to an apparent client error.', + label: 'Bad Request', + }) + }) + + it('should return unknown for an unknown status code', () => { + const statusCodeInfo = getHttpStatusCodeInfo(999) + expect(statusCodeInfo).toEqual({ + code: 999, + name: 'UNKNOWN', + message: 'Unknown status code', + label: 'Unknown', + }) + }) +}) diff --git a/apps/studio/lib/http-status-codes.ts b/apps/studio/lib/http-status-codes.ts new file mode 100644 index 00000000000..4e0d2042985 --- /dev/null +++ b/apps/studio/lib/http-status-codes.ts @@ -0,0 +1,30 @@ +import { status } from 'http-status' + +export function getHttpStatusCodeInfo(codeNumber: number): { + code: number + name: string + message: string + label: string +} { + type StatusCodeKey = keyof typeof status + + if (!(codeNumber in status)) { + return { + code: codeNumber, + name: 'UNKNOWN', + message: 'Unknown status code', + label: 'Unknown', + } + } + + const statusCodeLabel = status[codeNumber as StatusCodeKey] + const statusCodeMessage = status[`${codeNumber}_MESSAGE` as StatusCodeKey] + const statusCodeName = status[`${codeNumber}_NAME` as StatusCodeKey] + + return { + code: codeNumber, + name: statusCodeName as string, + label: statusCodeLabel as string, + message: statusCodeMessage as string, + } +} diff --git a/apps/studio/lib/profile.tsx b/apps/studio/lib/profile.tsx index 161cb88549f..dd6edd2f722 100644 --- a/apps/studio/lib/profile.tsx +++ b/apps/studio/lib/profile.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/nextjs' -import { useIsLoggedIn } from 'common' +import { useIsLoggedIn, useUser } from 'common' import { useRouter } from 'next/router' import { PropsWithChildren, createContext, useContext, useMemo } from 'react' import { toast } from 'sonner' @@ -30,6 +30,7 @@ export const ProfileContext = createContext({ }) export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { + const user = useUser() const isLoggedIn = useIsLoggedIn() const router = useRouter() const signOut = useSignOut() @@ -38,6 +39,16 @@ export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { const { mutate: createProfile, isLoading: isCreatingProfile } = useProfileCreateMutation({ onSuccess: () => { sendEvent({ action: 'sign_up', properties: { category: 'conversion' } }) + + if (user) { + // Send an event to GTM, will do nothing if GTM is not enabled + const thisWindow = window as any + thisWindow.dataLayer = thisWindow.dataLayer || [] + thisWindow.dataLayer.push({ + event: 'sign_up', + email: user.email, + }) + } }, onError: (error) => { if (error.code === 409) { diff --git a/apps/studio/lib/self-hosted.ts b/apps/studio/lib/self-hosted.ts index a87ca513c38..b8ab1b779b7 100644 --- a/apps/studio/lib/self-hosted.ts +++ b/apps/studio/lib/self-hosted.ts @@ -10,6 +10,6 @@ export async function queryPgMetaSelfHosted(sql: string, headersInit?: { [prop: if (response.error) { return { error: response.error as ResponseError } } else { - return { data: response.data } + return { data: response } } } diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 339d6116acd..37f16b6a41d 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -7,12 +7,20 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ + // These are using OpenAI, can be removed once Bedrock is default '/ai/sql/generate-v3', + '/ai/sql/complete', + '/ai/sql/cron', + '/ai/sql/title', + // These are using Bedrock + '/ai/sql/generate-v4', + '/ai/sql/complete-v2', + '/ai/sql/cron-v2', + '/ai/sql/title-v2', + '/ai/edge-function/complete-v2', + // Others '/ai/edge-function/complete', '/ai/onboarding/design', - '/ai/sql/complete', - '/ai/sql/title', - '/ai/sql/cron', '/ai/feedback/classify', '/get-ip-address', '/get-utc-time', diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 1e836d3a296..3803368a573 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -2,110 +2,11 @@ const { withSentryConfig } = require('@sentry/nextjs') const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }) +const { getCSP } = require('./csp') // Required for nextjs standalone build const path = require('path') -const API_URL = process.env.NEXT_PUBLIC_API_URL - ? new URL(process.env.NEXT_PUBLIC_API_URL).origin - : '' -const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : '' -const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL - ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin - : '' -const SUPABASE_PROJECTS_URL = 'https://*.supabase.co' -const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co' - -// construct the URL for the Websocket Local URLs -let SUPABASE_LOCAL_PROJECTS_URL_WS = '' -if (SUPABASE_URL) { - const url = new URL(SUPABASE_URL) - const wsUrl = `${url.hostname}:${url.port}` - SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}` -} - -// Needed to test docs search in local dev -const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL - ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin - : '' - -const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' -const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' -const SUPABASE_COM_URL = 'https://supabase.com' -const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com' -const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com' -const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com' -const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com' -const CONFIGCAT_URL = 'https://cdn-global.configcat.com' -const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) - ? 'https://configcat.supabase.green' - : 'https://configcat.supabase.com' -const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' -const STRIPE_JS_URL = 'https://js.stripe.com' -const STRIPE_NETWORK_URL = 'https://*.stripe.network' -const CLOUDFLARE_URL = 'https://www.cloudflare.com' -const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' -const VERCEL_URL = 'https://vercel.com' -const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' -const GITHUB_API_URL = 'https://api.github.com' -const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com' -const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com' -const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com' -const VERCEL_LIVE_URL = 'https://vercel.live' -const SENTRY_URL = - 'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io' -const SUPABASE_ASSETS_URL = - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? 'https://frontend-assets.supabase.green' - : 'https://frontend-assets.supabase.com' - -const USERCENTRICS_URLS = 'https://*.usercentrics.eu' -const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' - -// used by vercel live preview -const PUSHER_URL = 'https://*.pusher.com' -const PUSHER_URL_WS = 'wss://*.pusher.com' - -const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${CONFIGCAT_PROXY_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_URLS}` -const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL} ${SUPABASE_ASSETS_URL}` -const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}` -const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL} ${GOOGLE_USER_AVATAR_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_APP_URL}` -const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` -const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` - -const csp = [ - ...(process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? [ - `default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SENTRY_URL};`, - `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`, - `frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`, - `img-src 'self' blob: data: ${IMG_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${VERCEL_URL};`, - `style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS} ${VERCEL_LIVE_URL};`, - `font-src 'self' ${FONT_SRC_URLS} ${VERCEL_LIVE_URL};`, - `worker-src 'self' blob: data:;`, - ] - : [ - `default-src 'self' ${DEFAULT_SRC_URLS} ${PUSHER_URL_WS} ${SENTRY_URL};`, - `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`, - `frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`, - `img-src 'self' blob: data: ${IMG_SRC_URLS} ;`, - `style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS} ${VERCEL_LIVE_URL};`, - `font-src 'self' ${FONT_SRC_URLS} ${VERCEL_LIVE_URL};`, - `worker-src 'self' blob: data:;`, - ]), - `object-src 'none';`, - `base-uri 'self';`, - `form-action 'self';`, - `frame-ancestors 'none';`, - `block-all-mixed-content;`, - ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && - process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' - ? [`upgrade-insecure-requests;`] - : []), -].join(' ') - function getAssetPrefix() { // If not force enabled, but not production env, disable CDN if (process.env.FORCE_ASSET_CDN !== '1' && process.env.VERCEL_ENV !== 'production') { @@ -117,6 +18,11 @@ function getAssetPrefix() { return undefined } + const SUPABASE_ASSETS_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? 'https://frontend-assets.supabase.green' + : 'https://frontend-assets.supabase.com' + return `${SUPABASE_ASSETS_URL}/${process.env.SITE_NAME}/${process.env.VERCEL_GIT_COMMIT_SHA.substring(0, 12)}` } @@ -506,7 +412,8 @@ const nextConfig = { }, { key: 'Content-Security-Policy', - value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' ? csp : "frame-ancestors 'none';", + value: + process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' ? getCSP() : "frame-ancestors 'none';", }, { key: 'Referrer-Policy', diff --git a/apps/studio/package.json b/apps/studio/package.json index d9ddaacf3b0..f7e586bacd0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -20,13 +20,19 @@ "typecheck": "tsc --noEmit", "prettier:check": "prettier --check .", "prettier:write": "prettier --write .", - "build:deno-types": "tsx scripts/deno-types.ts" + "build:deno-types": "tsx scripts/deno-types.ts", + "build:graphql-types": "tsx scripts/download-graphql-schema.mts && pnpm graphql-codegen --config scripts/codegen.ts", + "build:graphql-types:watch": "pnpm graphql-codegen --config scripts/codegen.ts --watch" }, "dependencies": { - "@ai-sdk/openai": "^0.0.72", + "@ai-sdk/amazon-bedrock": "^2.2.9", + "@ai-sdk/openai": "^1.3.22", + "@ai-sdk/react": "^1.2.12", + "@aws-sdk/credential-providers": "^3.804.0", "@dagrejs/dagre": "^1.0.4", "@deno/eszip": "0.83.0", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@electric-sql/pglite": "^0.2.15", @@ -40,11 +46,16 @@ "@monaco-editor/react": "^4.6.0", "@next/bundle-analyzer": "15.3.1", "@number-flow/react": "^0.3.2", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-visually-hidden": "^1.1.3", "@sentry/nextjs": "^8.52.1", "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "@supabase/auth-js": "catalog:", + "@supabase/mcp-server-supabase": "^0.4.4", + "@supabase/mcp-utils": "^0.2.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", "@supabase/shared-types": "0.1.80", @@ -52,10 +63,13 @@ "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", + "@tanstack/react-table": "^8.21.3", "@uidotdev/usehooks": "^2.4.1", + "@vercel/flags": "^2.6.0", + "@vercel/functions": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", "@zip.js/zip.js": "^2.7.29", - "ai": "^3.4.33", + "ai": "^4.3.16", "ai-commands": "workspace:*", "awesome-debounce-promise": "^2.1.0", "common": "workspace:*", @@ -70,6 +84,7 @@ "framer-motion": "^11.11.17", "generate-password-browser": "^1.1.0", "html-to-image": "^1.10.8", + "http-status": "^2.1.0", "icons": "workspace:*", "idb": "^8.0.2", "immutability-helper": "^3.1.1", @@ -96,6 +111,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-data-grid": "7.0.0-beta.41", "react-datepicker": "^4.18.0", + "react-day-picker": "^8.8.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "catalog:", @@ -130,6 +146,8 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { + "@graphql-codegen/cli": "5.0.5", + "@graphql-typed-document-node/core": "^3.2.0", "@radix-ui/react-use-escape-keydown": "^1.0.3", "@supabase/postgres-meta": "^0.64.4", "@tailwindcss/container-queries": "^0.1.1", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f8f95bfbb55..bbf227f10cb 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -31,7 +31,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { FeatureFlagProvider, ThemeProvider, useThemeSandbox } from 'common' +import { FeatureFlagProvider, TelemetryTagManager, ThemeProvider, useThemeSandbox } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' import { RouteValidationWrapper } from 'components/interfaces/App' import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext' @@ -39,7 +39,6 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' -import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' @@ -109,6 +108,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { Supabase + {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}