diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index 53d62179b153f..3433857be1d86 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -1,11 +1,10 @@ import { noop } from 'lodash-es' import { Check, ChevronsUpDown } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button_Shadcn_ as Button, cn, Command_Shadcn_ as Command, - CommandEmpty_Shadcn_ as CommandEmpty, CommandGroup_Shadcn_ as CommandGroup, CommandInput_Shadcn_ as CommandInput, CommandItem_Shadcn_ as CommandItem, @@ -15,6 +14,8 @@ import { PopoverTrigger_Shadcn_ as PopoverTrigger, ScrollArea, } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useIntersectionObserver } from '~/hooks/useIntersectionObserver' export interface ComboBoxOption { id: string @@ -28,25 +29,56 @@ export function ComboBox({ name, options, selectedOption, + selectedDisplayName, onSelectOption = noop, className, + search = '', + hasNextPage = false, + isFetching = false, + isFetchingNextPage = false, + fetchNextPage, + setSearch = () => {}, + useCommandSearch = true, }: { isLoading: boolean disabled?: boolean name: string options: Opt[] selectedOption?: string + selectedDisplayName?: string onSelectOption?: (newValue: string) => void className?: string + search?: string + hasNextPage?: boolean + isFetching?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void + setSearch?: (value: string) => void + useCommandSearch?: boolean }) { const [open, setOpen] = useState(false) - const selectedOptionDisplayName = options.find( - (option) => option.value === selectedOption - )?.displayName + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + + useEffect(() => { + if (!isLoading && !isFetching && !isFetchingNextPage && hasNextPage && entry?.isIntersecting) { + fetchNextPage?.() + } + }, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage]) return ( - + { + setOpen(value) + if (!value) setSearch('') + }} + > - - - + + + setSearch('')} + /> - No {name} found. - 10 ? 'h-[280px]' : ''}> - {options.map((option) => ( - { - setOpen(false) - onSelectOption(selectedValue) - }} - className="cursor-pointer" - > - - {option.displayName} - - ))} - + {isLoading ? ( +
+ + +
+ ) : ( + <> + {search.length > 0 && options.length === 0 && ( +

+ No {name}s found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {options.map((option) => ( + { + setOpen(false) + onSelectOption(selectedValue) + }} + className="cursor-pointer" + > + + {option.displayName} + + ))} +
+ {hasNextPage && } + + + )} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index d384746540b0e..ff60b0eca6b37 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -3,14 +3,12 @@ import type { Branch, Org, - Project, Variable, } from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils' -import type { ProjectKeys, ProjectSettings } from '~/lib/fetch/projectApi' import { Check, Copy } from 'lucide-react' import Link from 'next/link' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import CopyToClipboard from 'react-copy-to-clipboard' import { withErrorBoundary } from 'react-error-boundary' import { proxy, useSnapshot } from 'valtio' @@ -31,11 +29,16 @@ import { toOrgProjectValue, } from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils' import { useCopy } from '~/hooks/useCopy' +import { useDebounce } from '~/hooks/useDebounce' import { useBranchesQuery } from '~/lib/fetch/branches' import { useOrganizationsQuery } from '~/lib/fetch/organizations' -import { type SupavisorConfigData, useSupavisorConfigQuery } from '~/lib/fetch/pooler' -import { useProjectSettingsQuery, useProjectKeysQuery } from '~/lib/fetch/projectApi' -import { isProjectPaused, useProjectsQuery } from '~/lib/fetch/projects' +import { useSupavisorConfigQuery, type SupavisorConfigData } from '~/lib/fetch/pooler' +import { useProjectKeysQuery, useProjectSettingsQuery } from '~/lib/fetch/projectApi' +import { + isProjectPaused, + ProjectInfoInfinite, + useProjectsInfiniteQuery, +} from '~/lib/fetch/projects-infinite' import { retrieve, storeOrRemoveNull } from '~/lib/storage' import { useOnLogout } from '~/lib/userAuth' @@ -67,8 +70,8 @@ type VariableDataState = const projectsStore = proxy({ selectedOrg: null as Org | null, - selectedProject: null as Project | null, - setSelectedOrgProject: (org: Org | null, project: Project | null) => { + selectedProject: null as ProjectInfoInfinite | null, + setSelectedOrgProject: (org: Org | null, project: ProjectInfoInfinite | null) => { projectsStore.selectedOrg = org storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_ORG, org?.id.toString()) @@ -90,6 +93,9 @@ function OrgProjectSelector() { const isUserLoading = useIsUserLoading() const isLoggedIn = useIsLoggedIn() + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + const { selectedOrg, selectedProject, setSelectedOrgProject } = useSnapshot(projectsStore) const { @@ -97,11 +103,21 @@ function OrgProjectSelector() { isPending: organizationsIsPending, isError: organizationsIsError, } = useOrganizationsQuery({ enabled: isLoggedIn }) + const { - data: projects, + data: projectsData, isPending: projectsIsPending, isError: projectsIsError, - } = useProjectsQuery({ enabled: isLoggedIn }) + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { enabled: isLoggedIn } + ) + const projects = + useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] const anyIsPending = organizationsIsPending || projectsIsPending const anyIsError = organizationsIsError || projectsIsError @@ -141,7 +157,7 @@ function OrgProjectSelector() { const storedMaybeProjectRef = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT) let storedOrg: Org | undefined - let storedProject: Project | undefined + let storedProject: ProjectInfoInfinite | undefined if (storedMaybeOrgId && storedMaybeProjectRef) { storedOrg = organizations!.find((org) => org.id === Number(storedMaybeOrgId)) storedProject = projects!.find((project) => project.ref === storedMaybeProjectRef) @@ -167,6 +183,11 @@ function OrgProjectSelector() { stateSummary === 'loggedIn.dataSuccess.hasNoData' } options={formattedData} + selectedDisplayName={ + selectedOrg && selectedProject + ? toDisplayNameOrgProject(selectedOrg, selectedProject) + : undefined + } selectedOption={ selectedOrg && selectedProject ? toOrgProjectValue(selectedOrg, selectedProject) : undefined } @@ -181,6 +202,13 @@ function OrgProjectSelector() { setSelectedOrgProject(org, project) } }} + search={search} + isFetching={isFetching} + isFetchingNextPage={isFetchingNextPage} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + setSearch={setSearch} + useCommandSearch={false} /> ) } @@ -190,6 +218,7 @@ function BranchSelector() { const isLoggedIn = useIsLoggedIn() const { selectedProject, selectedBranch, setSelectedBranch } = useSnapshot(projectsStore) + const [branchSearch, setBranchSearch] = useState('') const projectPaused = isProjectPaused(selectedProject) const hasBranches = selectedProject?.is_branch_enabled ?? false @@ -253,7 +282,10 @@ function BranchSelector() { stateSummary === 'loggedIn.branches.dataSuccess.noData' } options={formattedData} + selectedDisplayName={selectedBranch?.name} selectedOption={selectedBranch ? toBranchValue(selectedBranch) : undefined} + search={branchSearch} + setSearch={setBranchSearch} onSelectOption={(option) => { const [branchId] = fromBranchValue(option) if (branchId) { diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts index cb06766dbad7b..15e5c1128ae62 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts @@ -1,9 +1,8 @@ import type { BranchesData } from '~/lib/fetch/branches' import type { OrganizationsData } from '~/lib/fetch/organizations' -import type { ProjectsData } from '~/lib/fetch/projects' +import { ProjectInfoInfinite } from '~/lib/fetch/projects-infinite' export type Org = OrganizationsData[number] -export type Project = ProjectsData[number] export type Branch = BranchesData[number] export type Variable = 'url' | 'publishable' | 'anon' | 'sessionPooler' @@ -39,11 +38,17 @@ type DeepReadonly = { readonly [P in keyof T]: DeepReadonly } -export function toDisplayNameOrgProject(org: DeepReadonly, project: DeepReadonly) { +export function toDisplayNameOrgProject( + org: DeepReadonly, + project: DeepReadonly +) { return `${org.name} / ${project.name}` } -export function toOrgProjectValue(org: DeepReadonly, project: DeepReadonly) { +export function toOrgProjectValue( + org: DeepReadonly, + project: DeepReadonly +) { return escapeDoubleQuotes( // @ts-ignore -- problem in OpenAPI spec -- project has ref property JSON.stringify([org.id, project.ref, removeDoubleQuotes(toDisplayNameOrgProject(org, project))]) diff --git a/apps/docs/content/_partials/auth_rate_limits.mdx b/apps/docs/content/_partials/auth_rate_limits.mdx index 2b3f19c434a2a..b8d2debdc2be0 100644 --- a/apps/docs/content/_partials/auth_rate_limits.mdx +++ b/apps/docs/content/_partials/auth_rate_limits.mdx @@ -2,9 +2,9 @@ | ------------------------------------------------ | -------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | All endpoints that send emails | `/auth/v1/signup` `/auth/v1/recover` `/auth/v1/user`[^1] | Sum of combined requests | Defaults to 4 emails per hour as of 14th July 2023. As of 21 Oct 2023, this has been updated to auth.rate_limits.email.inbuilt_smtp_per_hour emails per hour. You can only change this with your own custom SMTP setup. | | All endpoints that send One-Time-Passwords (OTP) | `/auth/v1/otp` | Sum of combined requests | Defaults to auth.rate_limits.otp.requests_per_hour OTPs per hour. Is customizable. | -| Send OTPs or magic links | `/auth/v1/otp` | Last request | Defaults to auth.rate_limits.otp.period window before a new request is allowed. Is customizable. | -| Signup confirmation request | `/auth/v1/signup` | Last request | Defaults to auth.rate_limits.signup_confirmation.period window before a new request is allowed. Is customizable. | -| Password Reset Request | `/auth/v1/recover` | Last request | Defaults to auth.rate_limits.password_reset.period window before a new request is allowed. Is customizable. | +| Send OTPs or magic links | `/auth/v1/otp` | Last request of the user | Defaults to auth.rate_limits.otp.period window before a new request is allowed to the same user. Is customizable. | +| Signup confirmation request | `/auth/v1/signup` | Last request of the user | Defaults to auth.rate_limits.signup_confirmation.period window before a new request is allowed to the same user. Is customizable. | +| Password Reset Request | `/auth/v1/recover` | Last request of the user | Defaults to auth.rate_limits.password_reset.period window before a new request is allowed to the same user. Is customizable. | | Verification requests | `/auth/v1/verify` | IP Address | auth.rate_limits.verification.requests_per_hour requests per hour (with bursts up to auth.rate_limits.verification.requests_burst requests) | | Token refresh requests | `/auth/v1/token` | IP Address | auth.rate_limits.token_refresh.requests_per_hour requests per hour (with bursts up to auth.rate_limits.token_refresh.requests_burst requests) | | Create or Verify an MFA challenge | `/auth/v1/factors/:id/challenge` `/auth/v1/factors/:id/verify` | IP Address | auth.rate_limits.mfa.requests_per_hour requests per hour (with bursts up to auth.rate_limits.verification.mfa requests) | diff --git a/apps/docs/content/guides/database/extensions/pgvector.mdx b/apps/docs/content/guides/database/extensions/pgvector.mdx index e0d11c1fb7a52..56918c381ccdb 100644 --- a/apps/docs/content/guides/database/extensions/pgvector.mdx +++ b/apps/docs/content/guides/database/extensions/pgvector.mdx @@ -22,7 +22,7 @@ Vector similarity refers to a measure of the similarity between two related item ### Embeddings -This is particularly useful if you're building on top of OpenAI's [GPT-3](https://openai.com/blog/gpt-3-apps/). You can create and store [embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings) for retrieval augmented generation. +This is particularly useful if you're building AI applications with large language models. You can create and store [embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings) for retrieval augmented generation (RAG). ## Usage diff --git a/apps/docs/content/guides/database/full-text-search.mdx b/apps/docs/content/guides/database/full-text-search.mdx index 27a08579f17d4..0446c7d3af042 100644 --- a/apps/docs/content/guides/database/full-text-search.mdx +++ b/apps/docs/content/guides/database/full-text-search.mdx @@ -3,20 +3,11 @@ id: 'full-text-search' title: 'Full Text Search' description: 'How to use full text search in PostgreSQL.' subtitle: 'How to use full text search in PostgreSQL.' -tocVideo: 'b-mgca_2Oe4' +tocVideo: 'GRwIa-ce7RA' --- Postgres has built-in functions to handle `Full Text Search` queries. This is like a "search engine" within Postgres. -
- -
- ## Preparation For this guide we'll use the following example data: @@ -100,6 +91,13 @@ Converts a query string into tokens to match. `to_tsquery()` stands for "to text This conversion step is important because we will want to "fuzzy match" on keywords. For example if a user searches for `eggs`, and a column has the value `egg`, we probably still want to return a match. +Postgres provides several functions to create tsquery objects: + +- **`to_tsquery()`** - Requires manual specification of operators (`&`, `|`, `!`) +- **`plainto_tsquery()`** - Converts plain text to an AND query: `plainto_tsquery('english', 'fat rats')` → `'fat' & 'rat'` +- **`phraseto_tsquery()`** - Creates phrase queries: `phraseto_tsquery('english', 'fat rats')` → `'fat' <-> 'rat'` +- **`websearch_to_tsquery()`** - Supports web search syntax with quotes, "or", and negation + ### Match: `@@` [#match] The `@@` symbol is the "match" symbol for Full Text Search. It returns any matches between a `to_tsvector` result and a `to_tsquery` result. @@ -751,9 +749,84 @@ When you want the search term to include a phrase or multiple words, you can con select * from search_books_by_title_prefix('Little+Puppy'); ``` +## Web search syntax with `websearch_to_tsquery()` [#websearch-to-tsquery] + +The `websearch_to_tsquery()` function provides an intuitive search syntax similar to popular web search engines, making it ideal for user-facing search interfaces. + +### Basic usage + + + + +```sql +select * +from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'green eggs'); +``` + + + + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', 'green eggs', { type: 'websearch' }) +``` + + + + +### Quoted phrases + +Use quotes to search for exact phrases: + +```sql +select * from books +where to_tsvector(description || ' ' || title) @@ websearch_to_tsquery('english', '"Green Eggs"'); +-- Matches documents containing "Green" immediately followed by "Eggs" +``` + +### OR searches + +Use "or" (case-insensitive) to search for multiple terms: + +```sql +select * from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'puppy or rabbit'); +-- Matches documents containing either "puppy" OR "rabbit" +``` + +### Negation + +Use a dash (-) to exclude terms: + +```sql +select * from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'animal -rabbit'); +-- Matches documents containing "animal" but NOT "rabbit" +``` + +### Complex queries + +Combine multiple operators for sophisticated searches: + +```sql +select * from books +where to_tsvector(description || ' ' || title) @@ + websearch_to_tsquery('english', '"Harry Potter" or "Dr. Seuss" -vegetables'); +-- Matches books by "Harry Potter" or "Dr. Seuss" but excludes those mentioning vegetables +``` + ## Creating indexes -Now that we have Full Text Search working, let's create an `index`. This will allow Postgres to "build" the documents preemptively so that they +Now that you have Full Text Search working, create an `index`. This allows Postgres to "build" the documents preemptively so that they don't need to be created at the time we execute the query. This will make our queries much faster. ### Searchable columns @@ -1142,6 +1215,323 @@ data = client.from_('books').select().text_search('description', "'big' & !'litt +## Ranking search results [#ranking] + +Postgres provides ranking functions to sort search results by relevance, helping you present the most relevant matches first. Since ranking functions need to be computed server-side, use RPC functions and generated columns. + +### Creating a search function with ranking [#search-function-ranking] + +First, create a Postgres function that handles search and ranking: + +```sql +create or replace function search_books(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(to_tsvector('english', books.description), to_tsquery(search_query)) as rank + from books + where to_tsvector('english', books.description) @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +Now you can call this function from your client: + + + + +```js +const { data, error } = await supabase.rpc('search_books', { search_query: 'big' }) +``` + + +<$Show if="sdk:dart"> + + +```dart +final result = await client + .rpc('search_books', params: { 'search_query': 'big' }); +``` + + + +<$Show if="sdk:python"> + + +```python +data = client.rpc('search_books', { 'search_query': 'big' }).execute() +``` + + + + + +```sql +select * from search_books('big'); +``` + + + + +### Ranking with weighted columns [#weighted-ranking] + +Postgres allows you to assign different importance levels to different parts of your documents using weight labels. This is especially useful when you want matches in certain fields (like titles) to rank higher than matches in other fields (like descriptions). + +#### Understanding weight labels + +Postgres uses four weight labels: **A**, **B**, **C**, and **D**, where: + +- **A** = Highest importance (weight 1.0) +- **B** = High importance (weight 0.4) +- **C** = Medium importance (weight 0.2) +- **D** = Low importance (weight 0.1) + +#### Creating weighted search columns + +First, create a weighted tsvector column that gives titles higher priority than descriptions: + +```sql +-- Add a weighted fts column +alter table books +add column fts_weighted tsvector +generated always as ( + setweight(to_tsvector('english', title), 'A') || + setweight(to_tsvector('english', description), 'B') +) stored; + +-- Create index for the weighted column +create index books_fts_weighted on books using gin (fts_weighted); +``` + +Now create a search function that uses this weighted column: + +```sql +create or replace function search_books_weighted(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts_weighted, to_tsquery(search_query)) as rank + from books + where books.fts_weighted @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +#### Custom weight arrays + +You can also specify custom weights by providing a weight array to `ts_rank()`: + +```sql +create or replace function search_books_custom_weights(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank( + '{0.0, 0.2, 0.5, 1.0}'::real[], -- Custom weights {D, C, B, A} + books.fts_weighted, + to_tsquery(search_query) + ) as rank + from books + where books.fts_weighted @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +This example uses custom weights where: + +- A-labeled terms (titles) have maximum weight (1.0) +- B-labeled terms (descriptions) have medium weight (0.5) +- C-labeled terms have low weight (0.2) +- D-labeled terms are ignored (0.0) + +#### Using the weighted search + + + + +```js +// Search with standard weighted ranking +const { data, error } = await supabase.rpc('search_books_weighted', { search_query: 'Harry' }) + +// Search with custom weights +const { data: customData, error: customError } = await supabase.rpc('search_books_custom_weights', { + search_query: 'Harry', +}) +``` + + +<$Show if="sdk:python"> + + +```python +# Search with standard weighted ranking +data = client.rpc('search_books_weighted', { 'search_query': 'Harry' }).execute() + +# Search with custom weights +custom_data = client.rpc('search_books_custom_weights', { 'search_query': 'Harry' }).execute() +``` + + + + + +```sql +-- Standard weighted search +select * from search_books_weighted('Harry'); + +-- Custom weighted search +select * from search_books_custom_weights('Harry'); +``` + + + + +#### Practical example with results + +Say you search for "Harry". With weighted columns: + +1. **"Harry Potter and the Goblet of Fire"** (title match) gets weight A = 1.0 +2. **Books mentioning "Harry" in description** get weight B = 0.4 + +This ensures that books with "Harry" in the title ranks significantly higher than books that only mention "Harry" in the description, providing more relevant search results for users. + +### Using ranking with indexes [#ranking-with-indexes] + +When using the `fts` column you created earlier, ranking becomes more efficient. Create a function that uses the indexed column: + +```sql +create or replace function search_books_fts(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts, to_tsquery(search_query)) as rank + from books + where books.fts @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + + + + +```js +const { data, error } = await supabase.rpc('search_books_fts', { search_query: 'little & big' }) +``` + + +<$Show if="sdk:dart"> + + +```dart +final result = await client + .rpc('search_books_fts', params: { 'search_query': 'little & big' }); +``` + + + +<$Show if="sdk:python"> + + +```python +data = client.rpc('search_books_fts', { 'search_query': 'little & big' }).execute() +``` + + + + + +```sql +select * from search_books_fts('little & big'); +``` + + + + +### Using web search syntax with ranking [#websearch-ranking] + +You can also create a function that combines `websearch_to_tsquery()` with ranking for user-friendly search: + +```sql +create or replace function websearch_books(search_text text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts, websearch_to_tsquery('english', search_text)) as rank + from books + where books.fts @@ websearch_to_tsquery('english', search_text) + order by rank desc; +end; +$$ language plpgsql; +``` + + + + +```js +// Support natural search syntax +const { data, error } = await supabase.rpc('websearch_books', { + search_text: '"little puppy" or train -vegetables', +}) +``` + + + + +```sql +select * from websearch_books('"little puppy" or train -vegetables'); +``` + + + + ## Resources - [Postgres: Text Search Functions and Operators](https://www.postgresql.org/docs/12/functions-textsearch.html) diff --git a/apps/docs/content/guides/realtime/broadcast.mdx b/apps/docs/content/guides/realtime/broadcast.mdx index f7fd0d6292cfb..7e3c667c7f788 100644 --- a/apps/docs/content/guides/realtime/broadcast.mdx +++ b/apps/docs/content/guides/realtime/broadcast.mdx @@ -836,12 +836,6 @@ You can also send a Broadcast message by making an HTTP request to Realtime serv ## Trigger broadcast messages from your database - - -This feature is currently in Public Alpha. If you have any issues [submit a support ticket](https://supabase.help). - - - ### How it works Broadcast Changes allows you to trigger messages from your database. To achieve it Realtime is directly reading your WAL (Write Append Log) file using a publication against the `realtime.messages` table so whenever a new insert happens a message is sent to connected users. @@ -948,3 +942,194 @@ const changes = supabase .on('broadcast', { event: 'DELETE' }, (payload) => console.log(payload)) .subscribe() ``` + +## Broadcast replay + + + +This feature is currently in Public Alpha. If you have any issues [submit a support ticket](https://supabase.help). + + + +### How it works + +Broadcast Replay enables **private** channels to access messages that were sent earlier. Only messages published via [Broadcast From the Database](#broadcast-from-the-database) are available for replay. + +You can configure replay with the following options: + +- **`since`** (Required): The epoch timestamp in milliseconds (e.g., `1697472000000`), specifying the earliest point from which messages should be retrieved. +- **`limit`** (Optional): The number of messages to return. This must be a positive integer, with a maximum value of 25. + + + + + + This is currently available only in the Supabase JavaScript client version 2.74.0 and later. + + + + ```js + const config = { + private: true, + broadcast: { + replay: { + since: 1697472000000, // Unix timestamp in milliseconds + limit: 10 + } + } + } + const channel = supabase.channel('main:room', { config }) + + // Broadcast callback receives meta field + channel.on('broadcast', { event: 'position' }, (payload) => { + if (payload?.meta?.replayed) { + console.log('Replayed message: ', payload) + } else { + console.log('This is a new message', payload) + } + // ... + }) + .subscribe() + ``` + + + +<$Show if="sdk:dart"> + + + + + This is currently available only in the Supabase Dart client version 2.10.0 and later. + + + ```dart + // Configure broadcast with replay + final channel = supabase.channel( + 'my-channel', + RealtimeChannelConfig( + self: true, + ack: true, + private: true, + replay: ReplayOption( + since: 1697472000000, // Unix timestamp in milliseconds + limit: 25, + ), + ), + ); + + // Broadcast callback receives meta field + channel.onBroadcast( + event: 'position', + callback: (payload) { + final meta = payload['meta'] as Map?; + if (meta?['replayed'] == true) { + print('Replayed message: ${meta?['id']}'); + } + }, + ).subscribe(); + ``` + + + + +<$Show if="sdk:swift"> + + + + + This is currently available only in the Supabase Swift client version 2.34.0 and later. + + + ```swift + // Configure broadcast with replay + let channel = supabase.realtimeV2.channel("my-channel") { + $0.isPrivate = true + $0.broadcast.acknowledgeBroadcasts = true + $0.broadcast.receiveOwnBroadcasts = true + $0.broadcast.replay = ReplayOption( + since: 1697472000000, // Unix timestamp in milliseconds + limit: 25 + ) + } + + var subscriptions = Set() + + // Broadcast callback receives meta field + channel.onBroadcast(event: "position") { message in + if let meta = message["payload"]?.objectValue?["meta"]?.objectValue, + let replayed = meta["replayed"]?.boolValue, + replayed { + print("Replayed message: \(meta["id"]?.stringValue ?? "")") + } + } + .store(in: &subscriptions) + + await channel.subscribe() + ``` + + + + +<$Show if="sdk:kotlin"> + + + + + Unsupported in Kotlin for now. + + + + + + +<$Show if="sdk:python"> + + + + + This is currently available only in the Supabase Python client version 2.22.0 and later. + + + ```python + # Configure broadcast with replay + channel = client.channel('my-channel', { + 'config': { + "private": True, + 'broadcast': { + 'self': True, + 'ack': True, + 'replay': { + 'since': 1697472000000, + 'limit': 100 + } + } + } + }) + + # Broadcast callback receives meta field + def on_broadcast(payload): + if payload.get('meta', {}).get('replayed'): + print(f"Replayed message: {payload['meta']['id']}") + + await channel.on_broadcast('position', on_broadcast) + await channel.subscribe() + ``` + + + + + +#### When to use Broadcast replay + +A few common use cases for Broadcast Replay include: + +- Displaying the most recent messages from a chat room +- Loading the last events that happened during a sports event +- Ensuring users always see the latest events after a page reload or network interruption +- Highlighting the most recent sections that changed in a web page diff --git a/apps/docs/content/guides/realtime/settings.mdx b/apps/docs/content/guides/realtime/settings.mdx index fab3055d3a5ff..d7b0ea319af11 100644 --- a/apps/docs/content/guides/realtime/settings.mdx +++ b/apps/docs/content/guides/realtime/settings.mdx @@ -23,8 +23,11 @@ All changes made in this screen will disconnect all your connected clients to en You can set the following settings using the Realtime Settings screen in your Dashboard: +- Enable Realtime service: Determines if the Realtime service is enabled or disabled for your project. - Channel Restrictions: You can toggle this settings to set Realtime to allow public channels or set it to use only private channels with [Realtime Authorization](/docs/guides/realtime/authorization). - Database connection pool size: Determines the number of connections used for Realtime Authorization RLS checking {/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} - Max concurrent clients: Determines the maximum number of clients that can be connected - Max events per second: Determines the maximum number of events per second that can be sent +- Max presence events per second: Determines the maximum number of presence events per second that can be sent +- Max payload size in KB: Determines the maximum number of payload size in KB that can be sent diff --git a/apps/docs/features/docs/Reference.api.utils.ts b/apps/docs/features/docs/Reference.api.utils.ts index 841c433df0d1e..df0c9f6d1cea5 100644 --- a/apps/docs/features/docs/Reference.api.utils.ts +++ b/apps/docs/features/docs/Reference.api.utils.ts @@ -24,6 +24,7 @@ export interface IApiEndPoint { } tags?: Array security?: Array + 'x-oauth-scope'?: string } export type ISchema = diff --git a/apps/docs/features/docs/Reference.sections.tsx b/apps/docs/features/docs/Reference.sections.tsx index ca9197dab149e..67c07fccd2f3f 100644 --- a/apps/docs/features/docs/Reference.sections.tsx +++ b/apps/docs/features/docs/Reference.sections.tsx @@ -341,6 +341,18 @@ async function ApiEndpointSection({ link, section, servicePath }: ApiEndpointSec {endpointDetails.description} )} + {endpointDetails['x-oauth-scope'] && ( +
+

OAuth scopes

+
    +
  • + + {endpointDetails['x-oauth-scope']} + +
  • +
+
+ )} {pathParameters.length > 0 && (

Path parameters

diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx index be86b5261d51d..87ccd8c1d1a7e 100644 --- a/apps/docs/features/ui/McpConfigPanel.tsx +++ b/apps/docs/features/ui/McpConfigPanel.tsx @@ -4,12 +4,11 @@ import { useIsLoggedIn, useIsUserLoading } from 'common' import { Check, ChevronDown } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' -import { useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Button, cn, Command_Shadcn_, - CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, @@ -17,10 +16,14 @@ import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, + ScrollArea, } from 'ui' import { Admonition } from 'ui-patterns' import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder' -import { useProjectsQuery } from '~/lib/fetch/projects' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useDebounce } from '~/hooks/useDebounce' +import { useIntersectionObserver } from '~/hooks/useIntersectionObserver' +import { useProjectsInfiniteQuery } from '~/lib/fetch/projects-infinite' type PlatformType = (typeof PLATFORMS)[number]['value'] @@ -29,6 +32,7 @@ const PLATFORMS = [ { value: 'local', label: 'CLI' }, ] as const satisfies Array<{ value: string; label: string }> +// [Joshen] Ideally we consolidate this component with what's in ProjectConfigVariables - they seem to be doing the same thing function ProjectSelector({ className, selectedProject, @@ -38,14 +42,57 @@ function ProjectSelector({ selectedProject?: { ref: string; name: string } | null onProjectSelect?: (project: { ref: string; name: string } | null) => void }) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + const isUserLoading = useIsUserLoading() const isLoggedIn = useIsLoggedIn() - const { data: projects, isLoading, isError } = useProjectsQuery({ enabled: isLoggedIn }) - const [open, setOpen] = useState(false) + const { + data: projectsData, + isLoading, + isError, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { enabled: isLoggedIn } + ) + const projects = + useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] + + useEffect(() => { + if ( + !isLoading && + !isFetching && + !isFetchingNextPage && + hasNextPage && + entry?.isIntersecting && + !!fetchNextPage + ) { + fetchNextPage() + } + }, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage]) return ( - + { + setOpen(open) + if (!open) setSearch('') + }} + >
Project @@ -71,43 +118,68 @@ function ProjectSelector({ } >
- {isUserLoading || isLoading - ? 'Loading projects...' - : isError - ? 'Error fetching projects' - : selectedProject?.name ?? 'Select a project'} + {selectedProject?.name ?? + (isUserLoading || isLoading + ? 'Loading projects...' + : isError + ? 'Error fetching projects' + : 'Select a project')}
)}
- - - + + + setSearch('')} + /> - No results found. - {projects?.map((project) => ( - { - onProjectSelect?.(project.ref === selectedProject?.ref ? null : project) - setOpen(false) - }} - className="flex gap-2 items-center" - > - {project.name} - - - ))} + {isLoading ? ( +
+ + +
+ ) : ( + <> + {search.length > 0 && projects.length === 0 && ( +

+ No projects found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + { + onProjectSelect?.(project.ref === selectedProject?.ref ? null : project) + setOpen(false) + }} + className="flex gap-2 items-center" + > + {project.name} + + + ))} +
+ {hasNextPage && } + + + )} diff --git a/apps/docs/hooks/useDebounce.tsx b/apps/docs/hooks/useDebounce.tsx new file mode 100644 index 0000000000000..30e6d7fea0612 --- /dev/null +++ b/apps/docs/hooks/useDebounce.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' + +// [Joshen] Copying from uidotdev/usehooks instead of installing the whole package +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L239 +export function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/docs/hooks/useIntersectionObserver.tsx b/apps/docs/hooks/useIntersectionObserver.tsx new file mode 100644 index 0000000000000..aed2be307a627 --- /dev/null +++ b/apps/docs/hooks/useIntersectionObserver.tsx @@ -0,0 +1,40 @@ +import { RefCallback, useCallback, useRef, useState } from 'react' + +// [Joshen] Copying from uidotdev/usehooks instead of installing the whole package +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L512 +export function useIntersectionObserver( + options: { + root?: Element | Document | null + rootMargin?: string + threshold?: number | number[] + } = {} +): [RefCallback, IntersectionObserverEntry | null] { + const { threshold = 1, root = null, rootMargin = '0px' } = options + const [entry, setEntry] = useState(null) + + const previousObserver = useRef(null) + + const customRef = useCallback( + (node) => { + if (previousObserver.current) { + previousObserver.current.disconnect() + previousObserver.current = null + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new IntersectionObserver( + ([entry]) => { + setEntry(entry) + }, + { threshold, root, rootMargin } + ) + + observer.observe(node) + previousObserver.current = observer + } + }, + [threshold, root, rootMargin] + ) + + return [customRef, entry] +} diff --git a/apps/docs/lib/fetch/projects-infinite.ts b/apps/docs/lib/fetch/projects-infinite.ts new file mode 100644 index 0000000000000..6d86bd7616eab --- /dev/null +++ b/apps/docs/lib/fetch/projects-infinite.ts @@ -0,0 +1,86 @@ +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import type { ResponseError } from '~/types/fetch' +import { get } from './fetchWrappers' + +const DEFAULT_LIMIT = 10 +const projectKeys = { + listInfinite: (params?: { + limit: number + sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc' + search?: string + }) => ['all-projects-infinite', params].filter(Boolean), +} + +interface GetProjectsInfiniteVariables { + limit?: number + sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc' + search?: string + page?: number +} + +export type ProjectInfoInfinite = + components['schemas']['ListProjectsPaginatedResponse']['projects'][number] + +async function getProjects( + { + limit = DEFAULT_LIMIT, + page = 0, + sort = 'name_asc', + search: _search = '', + }: GetProjectsInfiniteVariables, + signal?: AbortSignal, + headers?: Record +) { + const offset = page * limit + const search = _search.length === 0 ? undefined : _search + + const { data, error } = await get('/platform/projects', { + // @ts-ignore [Joshen] API type issue for Version 2 endpoints + params: { query: { limit, offset, sort, search } }, + signal, + headers: { ...headers, Version: '2' }, + }) + + if (error) throw error + return data as unknown as components['schemas']['ListProjectsPaginatedResponse'] +} + +export type ProjectsInfiniteData = Awaited> +export type ProjectsInfiniteError = ResponseError + +export const useProjectsInfiniteQuery = < + TData = { pages: ProjectsInfiniteData[]; pageParams: number[] }, +>( + { limit = DEFAULT_LIMIT, sort = 'name_asc', search }: GetProjectsInfiniteVariables, + { + enabled = true, + ...options + }: Omit< + UseInfiniteQueryOptions, + 'queryKey' | 'getNextPageParam' | 'initialPageParam' + > +) => { + return useInfiniteQuery({ + enabled, + queryKey: projectKeys.listInfinite({ limit, sort, search }), + queryFn: ({ signal, pageParam }) => + getProjects({ limit, page: pageParam as any, sort, search }, signal), + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const currentTotalCount = page * limit + // @ts-ignore [Joshen] API type issue for Version 2 endpoints + const totalCount = lastPage.pagination.count + + if (currentTotalCount >= totalCount) return undefined + return page + }, + staleTime: 30 * 60 * 1000, // 30 minutes + ...options, + }) +} + +export function isProjectPaused(project: { status: string } | null): boolean | undefined { + return !project ? undefined : project.status === 'INACTIVE' +} diff --git a/apps/docs/lib/fetch/projects.ts b/apps/docs/lib/fetch/projects.ts deleted file mode 100644 index de4771ee14f83..0000000000000 --- a/apps/docs/lib/fetch/projects.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import type { ResponseError } from '~/types/fetch' -import { get } from './fetchWrappers' -import { type ReadonlyRecursive } from '~/types/utils' - -const projectKeys = { - list: () => ['all-projects'] as const, -} - -export async function getProjects(signal?: AbortSignal) { - const { data, error } = await get('/platform/projects', { signal }) - if (error) throw error - return data -} - -export type ProjectsData = Awaited> -type ProjectsError = ResponseError - -export function useProjectsQuery({ - enabled = true, - ...options -}: Omit, 'queryKey'> = {}) { - return useQuery({ - queryKey: projectKeys.list(), - queryFn: ({ signal }) => getProjects(signal), - enabled, - ...options, - }) -} - -export function isProjectPaused( - project: ReadonlyRecursive | null -): boolean | undefined { - return !project ? undefined : project.status === 'INACTIVE' -} diff --git a/apps/docs/public/img/guides/platform/realtime/realtime-settings--dark.png b/apps/docs/public/img/guides/platform/realtime/realtime-settings--dark.png index 8d3d73e420bcd..fb0ac2002467b 100644 Binary files a/apps/docs/public/img/guides/platform/realtime/realtime-settings--dark.png and b/apps/docs/public/img/guides/platform/realtime/realtime-settings--dark.png differ diff --git a/apps/docs/public/img/guides/platform/realtime/realtime-settings--light.png b/apps/docs/public/img/guides/platform/realtime/realtime-settings--light.png index fbb18d0301305..bcf40c9833d0e 100644 Binary files a/apps/docs/public/img/guides/platform/realtime/realtime-settings--light.png and b/apps/docs/public/img/guides/platform/realtime/realtime-settings--light.png differ diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index ff5bbd74c8787..77dc6dd6112b6 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,5 +1,6 @@ import { ExternalLink, Github } from 'lucide-react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' import { APIKeysData } from 'data/api-keys/api-keys-query' @@ -15,6 +16,7 @@ import { TableHeader, TableRow, } from 'ui' +import { SupportLink } from '../Support/SupportLink' import { ApiKeyPill } from './ApiKeyPill' import { CreateNewAPIKeysButton } from './CreateNewAPIKeysButton' import { useApiKeysVisibility } from './hooks/useApiKeysVisibility' @@ -191,12 +193,17 @@ export const ApiKeysFeedbackBanner = () => {

Having trouble with the new API keys?{' '} - Contact support - +

diff --git a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx index 91306f2d191d9..90e9c00edb242 100644 --- a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx @@ -5,6 +5,8 @@ import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { LOCAL_STORAGE_KEYS } from 'common' +import { NO_PROJECT_MARKER } from 'components/interfaces/Support/SupportForm.utils' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProfile } from 'lib/profile' @@ -26,7 +28,6 @@ import { Input_Shadcn_, Separator, } from 'ui' -import { LOCAL_STORAGE_KEYS } from 'common' const setDeletionRequestFlag = () => { const expiryDate = new Date() @@ -92,7 +93,7 @@ export const DeleteAccountButton = () => { severity: 'Low', allowSupportAccess: false, verified: true, - projectRef: 'no-project', + projectRef: NO_PROJECT_MARKER, } submitSupportTicket(payload) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index b8d9c8f3db47d..b13784f317be1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -1,7 +1,9 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { noop } from 'lodash' +import { useParams } from 'common' import AlertError from 'components/ui/AlertError' +import { InlineLink } from 'components/ui/InlineLink' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useTablesRolesAccessQuery } from 'data/tables/tables-roles-access-query' @@ -19,6 +21,7 @@ import { TableHeader, TableRow, } from 'ui' +import { Admonition } from 'ui-patterns' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { PolicyRow } from './PolicyRow' import { PolicyTableRowHeader } from './PolicyTableRowHeader' @@ -50,6 +53,7 @@ export const PolicyTableRow = ({ onSelectEditPolicy = noop, onSelectDeletePolicy = noop, }: PolicyTableRowProps) => { + const { ref } = useParams() const { data: project } = useSelectedProjectQuery() // [Joshen] Changes here are so that warnings are more accurate and granular instead of purely relying if RLS is disabled or enabled @@ -92,7 +96,8 @@ export const PolicyTableRow = ({ - {(isPubliclyReadableWritable || rlsEnabledNoPolicies) && ( + {!isTableExposedThroughAPI && ( + + No data will be selectable via Supabase APIs as this schema is not exposed. You may + configure this in your project's{' '} + API settings. + + )} + + {(isPubliclyReadableWritable || rlsEnabledNoPolicies) && isTableExposedThroughAPI && ( diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index f8391f8339ce8..8f2d69bc386f8 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -376,8 +376,8 @@ export const SmtpForm = () => { name="SMTP_MAX_FREQUENCY" render={({ field }) => ( diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx index 1dd9201fcf318..5b5deccf3c0dc 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx @@ -3,6 +3,7 @@ import { CreditCardIcon } from 'lucide-react' import Link from 'next/link' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -66,11 +67,14 @@ const CurrentPaymentMethod = () => { , ]} > diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index 3a8cb3a7c5237..446c704d3769b 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -14,6 +14,7 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' +import { useRouter } from 'next/router' import { Button, DIALOG_PADDING_X, @@ -32,11 +33,12 @@ import { cn, } from 'ui' import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './Connect.constants' -import { getContentFilePath } from './Connect.utils' +import { getContentFilePath, inferConnectTabFromParentKey } from './Connect.utils' import { ConnectDropdown } from './ConnectDropdown' import { ConnectTabContent } from './ConnectTabContent' export const Connect = () => { + const router = useRouter() const { ref: projectRef } = useParams() const { data: selectedProject } = useSelectedProjectQuery() const isActiveHealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_HEALTHY @@ -83,7 +85,7 @@ export const Connect = () => { } } - const [tab, setTab] = useQueryState('tab', parseAsString.withDefault('direct')) + const [tab, setTab] = useQueryState('connectTab', parseAsString.withDefault('direct')) const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString) const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString) const [queryWith, setQueryWith] = useQueryState('with', parseAsString) @@ -264,6 +266,20 @@ export const Connect = () => { } } + useEffect(() => { + if (!showConnect) return + const noConnectTabInUrl = typeof router.query.connectTab === 'undefined' + const hasQuery = queryFramework || queryUsing || queryWith + const inferred = inferConnectTabFromParentKey(queryFramework) + + if (noConnectTabInUrl && hasQuery && inferred) { + setTab(inferred) + if (inferred === 'frameworks') setConnectionObject(FRAMEWORKS) + if (inferred === 'mobiles') setConnectionObject(MOBILES) + if (inferred === 'orms') setConnectionObject(ORMS) + } + }, [showConnect, router.query.connectTab, queryFramework, queryUsing, queryWith, setTab]) + useEffect(() => { if (!showConnect) return @@ -293,7 +309,16 @@ export const Connect = () => { if (queryWith) { if (grandchild?.key !== queryWith) setQueryWith(grandchild?.key ?? null) } - }, [showConnect, tab, FRAMEWORKS, queryFramework, queryUsing, queryWith]) + }, [ + showConnect, + tab, + queryFramework, + setQueryFramework, + queryUsing, + setQueryUsing, + queryWith, + setQueryWith, + ]) if (!isActiveHealthy) { return ( @@ -333,7 +358,7 @@ export const Connect = () => { { resetQueryStates() handleConnectionType(value) diff --git a/apps/studio/components/interfaces/Connect/Connect.utils.ts b/apps/studio/components/interfaces/Connect/Connect.utils.ts index 9c497fb095d39..885604d189677 100644 --- a/apps/studio/components/interfaces/Connect/Connect.utils.ts +++ b/apps/studio/components/interfaces/Connect/Connect.utils.ts @@ -1,4 +1,4 @@ -import { ConnectionType } from './Connect.constants' +import { ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './Connect.constants' export function getProjectRef(url: string): string | null { const regex: RegExp = /https:\/\/([^\.]+)\./ @@ -43,3 +43,13 @@ export const getContentFilePath = ({ return '' } + +export function inferConnectTabFromParentKey( + parentKey: string | null +): 'frameworks' | 'mobiles' | 'orms' | null { + if (!parentKey) return null + if (FRAMEWORKS.find((x: ConnectionType) => x.key === parentKey)) return 'frameworks' + if (MOBILES.find((x: ConnectionType) => x.key === parentKey)) return 'mobiles' + if (ORMS.find((x: ConnectionType) => x.key === parentKey)) return 'orms' + return null +} diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index ffe4a03f960f6..dd8cdfaf9ed9b 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -1,7 +1,8 @@ import dayjs from 'dayjs' -import Link from 'next/link' import { useState } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query' @@ -64,11 +65,15 @@ const Migrations = () => { } > )} diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 6cbe106ade34c..6343e588b776a 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -2,7 +2,9 @@ import { CpuIcon, Lock, Microchip } from 'lucide-react' import { useMemo } from 'react' import { UseFormReturn } from 'react-hook-form' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DocsButton } from 'components/ui/DocsButton' import { InlineLink } from 'components/ui/InlineLink' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' @@ -11,7 +13,6 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { DOCS_URL, InstanceSpecs } from 'lib/constants' -import Link from 'next/link' import { cn, FormField_Shadcn_, @@ -302,8 +303,12 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group] w-full h-[110px]' )} label={ -
@@ -334,7 +339,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
- + } /> diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/Connect.test.ts b/apps/studio/components/interfaces/HomeNew/GettingStarted/Connect.test.ts new file mode 100644 index 0000000000000..9e6a4b545998c --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/GettingStarted/Connect.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { + getContentFilePath, + inferConnectTabFromParentKey, +} from 'components/interfaces/Connect/Connect.utils' +import { FRAMEWORKS } from 'components/interfaces/Connect/Connect.constants' + +describe('Connect.utils helpers', () => { + it('inferConnectTabFromParentKey returns frameworks for nextjs', () => { + expect(inferConnectTabFromParentKey('nextjs')).toBe('frameworks') + }) + + it('inferConnectTabFromParentKey returns null for unknown', () => { + expect(inferConnectTabFromParentKey('unknown-x')).toBeNull() + }) + + it('inferConnectTabFromParentKey returns mobiles for exporeactnative', () => { + expect(inferConnectTabFromParentKey('exporeactnative')).toBe('mobiles') + }) + + it('inferConnectTabFromParentKey returns orms for prisma', () => { + expect(inferConnectTabFromParentKey('prisma')).toBe('orms') + }) + + it('inferConnectTabFromParentKey returns null for null parentKey', () => { + expect(inferConnectTabFromParentKey(null)).toBeNull() + }) + + describe('getContentFilePath', () => { + it('returns parent/child/grandchild when all exist', () => { + const path = getContentFilePath({ + connectionObject: FRAMEWORKS, + selectedParent: 'nextjs', + selectedChild: 'app', + selectedGrandchild: 'supabasejs', + }) + expect(path).toBe('nextjs/app/supabasejs') + }) + + it('returns parent/child when grandchild does not exist', () => { + const path = getContentFilePath({ + connectionObject: FRAMEWORKS, + selectedParent: 'remix', + selectedChild: 'supabasejs', + selectedGrandchild: 'does-not-exist', + }) + expect(path).toBe('remix/supabasejs') + }) + + it('returns parent when child does not exist', () => { + const path = getContentFilePath({ + connectionObject: FRAMEWORKS, + selectedParent: 'nextjs', + selectedChild: 'unknown-child', + selectedGrandchild: 'any', + }) + expect(path).toBe('nextjs') + }) + + it('returns empty string when parent does not exist', () => { + const path = getContentFilePath({ + connectionObject: FRAMEWORKS, + selectedParent: 'unknown-parent', + selectedChild: 'any', + selectedGrandchild: 'any', + }) + expect(path).toBe('') + }) + }) +}) diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/FrameworkSelector.tsx b/apps/studio/components/interfaces/HomeNew/GettingStarted/FrameworkSelector.tsx index 5be91506db440..22b38428edf29 100644 --- a/apps/studio/components/interfaces/HomeNew/GettingStarted/FrameworkSelector.tsx +++ b/apps/studio/components/interfaces/HomeNew/GettingStarted/FrameworkSelector.tsx @@ -45,7 +45,7 @@ export const FrameworkSelector = ({
- + {/* Render in a portal to avoid layout/stacking shifts; prevent auto-focus to stop scroll jump */} + e.preventDefault()} + > diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx b/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx index ba121daa17079..165faed1418da 100644 --- a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx @@ -48,42 +48,68 @@ export function GettingStartedSection({ value, onChange }: GettingStartedSection [aiSnap] ) - const openConnect = useCallback(() => { - router.push( - { - pathname: router.pathname, - query: { - ...router.query, - showConnect: true, - connectTab: 'frameworks', - framework: selectedFramework, + const connectPresetLinks = useMemo(() => { + const basePath = router.asPath.split('?')[0] + const parent = FRAMEWORKS.find((f) => f.key === selectedFramework) + if (!parent) { + return [ + { + label: 'Connect', + href: `${basePath}?showConnect=true&connectTab=frameworks`, }, - }, - undefined, - { shallow: true } - ) - }, [router, selectedFramework]) + ] + } - const connectActions: GettingStartedAction[] = useMemo( - () => [ - { - label: 'Framework selector', - component: ( - - ), - }, + let using: string | undefined + let withKey: string | undefined + if (parent.children && parent.children.length > 0) { + // prefer App Router for Nextjs by default + if (parent.key === 'nextjs') { + const appChild = parent.children.find((c) => c.key === 'app') || parent.children[0] + using = appChild?.key + withKey = appChild?.children?.[0]?.key + } else { + using = parent.children[0]?.key + withKey = parent.children[0]?.children?.[0]?.key + } + } + + const qs = new URLSearchParams({ + showConnect: 'true', + connectTab: 'frameworks', + framework: parent.key, + }) + if (using) qs.set('using', using) + if (withKey) qs.set('with', withKey) + + return [ { label: 'Connect', - variant: 'primary', - onClick: openConnect, + href: `${basePath}?${qs.toString()}`, }, - ], - [openConnect, selectedFramework] - ) + ] + }, [router.asPath, selectedFramework]) + + const connectActions: GettingStartedAction[] = useMemo(() => { + const selector: GettingStartedAction = { + label: 'Framework selector', + component: ( + + ), + } + + const linkActions: GettingStartedAction[] = connectPresetLinks.map((lnk) => ({ + label: 'Connect', + href: lnk.href, + variant: 'primary', + })) + + return [selector, ...linkActions] + }, [connectPresetLinks, selectedFramework]) const codeSteps: GettingStartedStep[] = useMemo( () => diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts new file mode 100644 index 0000000000000..b6725227bf1a6 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { + computeChangePercent, + computeSuccessAndNonSuccessRates, + sumErrors, + sumTotal, + sumWarnings, + toLogsBarChartData, +} from './ProjectUsage.metrics' + +describe('ProjectUsage.metrics', () => { + const rows = [ + { timestamp: '2025-10-22T13:00:00Z', ok_count: 90, warning_count: 5, error_count: 5 }, + { timestamp: '2025-10-22T13:01:00Z', ok_count: 50, warning_count: 10, error_count: 0 }, + ] + + it('toLogsBarChartData maps and coerces fields correctly', () => { + const data = toLogsBarChartData(rows) + expect(data).toHaveLength(2) + expect(data[0]).toEqual({ + timestamp: '2025-10-22T13:00:00Z', + ok_count: 90, + warning_count: 5, + error_count: 5, + }) + }) + + it('sum helpers compute totals correctly', () => { + const data = toLogsBarChartData(rows) + expect(sumTotal(data)).toBe(160) + expect(sumWarnings(data)).toBe(15) + expect(sumErrors(data)).toBe(5) + }) + + it('computeSuccessAndNonSuccessRates returns expected percentages', () => { + const data = toLogsBarChartData(rows) + const total = sumTotal(data) + const warns = sumWarnings(data) + const errs = sumErrors(data) + const { successRate, nonSuccessRate } = computeSuccessAndNonSuccessRates(total, warns, errs) + + // success = 160 - (15 + 5) = 140 → 87.5% + expect(successRate).toBeCloseTo(87.5) + expect(nonSuccessRate).toBeCloseTo(12.5) + }) + + it('computeChangePercent handles zero previous safely', () => { + expect(computeChangePercent(10, 0)).toBe(100) + expect(computeChangePercent(0, 0)).toBe(0) + }) + + it('computeChangePercent returns standard percentage delta', () => { + expect(computeChangePercent(120, 100)).toBe(20) + expect(computeChangePercent(80, 100)).toBe(-20) + }) +}) diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts new file mode 100644 index 0000000000000..f98ec14a4b217 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts @@ -0,0 +1,44 @@ +export type LogsBarChartDatum = { + timestamp: string + error_count: number + ok_count: number + warning_count: number +} + +export const toLogsBarChartData = ( + rows: Array> = [] +): LogsBarChartDatum[] => { + return rows.map((r) => ({ + timestamp: r.timestamp?.toString() ?? '', + ok_count: Number(r.ok_count) || 0, + warning_count: Number(r.warning_count) || 0, + error_count: Number(r.error_count) || 0, + })) +} + +export const sumTotal = (data: LogsBarChartDatum[]): number => + data.reduce((acc, r) => acc + r.ok_count + r.warning_count + r.error_count, 0) + +export const sumWarnings = (data: LogsBarChartDatum[]): number => + data.reduce((acc, r) => acc + r.warning_count, 0) + +export const sumErrors = (data: LogsBarChartDatum[]): number => + data.reduce((acc, r) => acc + r.error_count, 0) + +export const computeSuccessAndNonSuccessRates = ( + totalRequests: number, + totalWarnings: number, + totalErrors: number +): { successRate: number; nonSuccessRate: number } => { + if (totalRequests <= 0) return { successRate: 0, nonSuccessRate: 0 } + const nonSuccessRate = ((totalWarnings + totalErrors) / totalRequests) * 100 + const successRate = 100 - nonSuccessRate + return { successRate, nonSuccessRate } +} + +export const computeChangePercent = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0 + return ((current - previous) / previous) * 100 +} + +export const formatDelta = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx index b29c45a6761bb..4984cc11d8fd2 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -33,6 +33,16 @@ import { import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' import { useServiceStats } from './ProjectUsageSection.utils' +import type { LogsBarChartDatum } from './ProjectUsage.metrics' +import { + toLogsBarChartData, + sumTotal, + sumWarnings, + sumErrors, + computeSuccessAndNonSuccessRates, + computeChangePercent, + formatDelta, +} from './ProjectUsage.metrics' const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90 } @@ -65,13 +75,6 @@ const CHART_INTERVALS: ChartIntervals[] = [ type ChartIntervalKey = '1hr' | '1day' | '7day' -type LogsBarChartDatum = { - timestamp: string - error_count: number - ok_count: number - warning_count: number -} - type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' type ServiceEntry = { @@ -138,21 +141,6 @@ export const ProjectUsageSection = () => { previousEnd ) - const toLogsBarChartData = (rows: any[] = []): LogsBarChartDatum[] => { - return rows.map((r) => ({ - timestamp: String(r.timestamp), - ok_count: Number(r.ok_count || 0), - warning_count: Number(r.warning_count || 0), - error_count: Number(r.error_count || 0), - })) - } - - const sumTotal = (data: LogsBarChartDatum[]) => - data.reduce((acc, r) => acc + r.ok_count + r.warning_count + r.error_count, 0) - const sumWarnings = (data: LogsBarChartDatum[]) => - data.reduce((acc, r) => acc + r.warning_count, 0) - const sumErrors = (data: LogsBarChartDatum[]) => data.reduce((acc, r) => acc + r.error_count, 0) - const serviceBase: ServiceEntry[] = useMemo( () => [ { @@ -240,7 +228,12 @@ export const ProjectUsageSection = () => { const enabledServices = services.filter((s) => s.enabled) const totalRequests = enabledServices.reduce((sum, s) => sum + (s.total || 0), 0) const totalErrors = enabledServices.reduce((sum, s) => sum + (s.err || 0), 0) - const errorRate = totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0 + const totalWarnings = enabledServices.reduce((sum, s) => sum + (s.warn || 0), 0) + const { successRate, nonSuccessRate } = computeSuccessAndNonSuccessRates( + totalRequests, + totalWarnings, + totalErrors + ) const prevServiceTotals = useMemo( () => @@ -250,7 +243,6 @@ export const ProjectUsageSection = () => { return { enabled: s.enabled, total: sumTotal(data), - err: sumErrors(data), } }), [serviceBase, statsByService] @@ -258,24 +250,10 @@ export const ProjectUsageSection = () => { const enabledPrev = prevServiceTotals.filter((s) => s.enabled) const prevTotalRequests = enabledPrev.reduce((sum, s) => sum + (s.total || 0), 0) - const prevTotalErrors = enabledPrev.reduce((sum, s) => sum + (s.err || 0), 0) - const prevErrorRate = prevTotalRequests > 0 ? (prevTotalErrors / prevTotalRequests) * 100 : 0 - const totalRequestsChangePct = - prevTotalRequests === 0 - ? totalRequests > 0 - ? 100 - : 0 - : ((totalRequests - prevTotalRequests) / prevTotalRequests) * 100 - const errorRateChangePct = - prevErrorRate === 0 - ? errorRate > 0 - ? 100 - : 0 - : ((errorRate - prevErrorRate) / prevErrorRate) * 100 - const formatDelta = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` + const totalRequestsChangePct = computeChangePercent(totalRequests, prevTotalRequests) const totalDeltaClass = totalRequestsChangePct >= 0 ? 'text-brand' : 'text-destructive' - const errorDeltaClass = errorRateChangePct <= 0 ? 'text-brand' : 'text-destructive' + const nonSuccessClass = nonSuccessRate > 0 ? 'text-destructive' : 'text-brand' return (
@@ -289,11 +267,9 @@ export const ProjectUsageSection = () => {
- {errorRate.toFixed(1)}% - Error Rate - - {formatDelta(errorRateChangePct)} - + {successRate.toFixed(1)}% + Success Rate + {formatDelta(nonSuccessRate)}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 71772fb560d98..cf0db499a2b76 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -6,7 +6,6 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/ import { useQueryClient } from '@tanstack/react-query' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' -import Link from 'next/link' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -14,6 +13,8 @@ import { z } from 'zod' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { NO_PROJECT_MARKER } from 'components/interfaces/Support/SupportForm.utils' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationCreditTopUpMutation } from 'data/organizations/organization-credit-top-up-mutation' import { subscriptionKeys } from 'data/subscriptions/keys' @@ -241,13 +242,18 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { up credits do not expire.

- For larger discounted credit packages, please{' '} - - reach out. - + support + + .

diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index b6c3173f9e4b6..2e9a052a10e94 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,7 +1,8 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import Link from 'next/link' import { useFlag, useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldSection, ScaffoldSectionContent, @@ -109,15 +110,16 @@ const Subscription = () => { variant="info" title={`Unable to update plan from ${planName}`} actions={[ -
- -
, + , ]} > Please contact us if you'd like to change your plan. diff --git a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx index 1b35df888dc4b..7190771e20761 100644 --- a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx @@ -1,6 +1,6 @@ -import Link from 'next/link' - +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold' +import { InlineLinkClassName } from 'components/ui/InlineLink' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { Fragment } from 'react' import { CustomDocument } from './CustomDocument' @@ -59,10 +59,8 @@ const Documents = () => {

- - Submit a support request - {' '} - if you require additional documents for financial or tax reasons, such as a W-9 form. + Submit a support request if + you require additional documents for financial or tax reasons, such as a W-9 form.

diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 26071ec4cd8a1..6068ee37c1214 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -64,7 +64,7 @@ export const RealtimeSettings = () => { const isFreePlan = organization?.plan.id === 'free' const isUsageBillingEnabled = organization?.usage_billing_enabled - + const isRealtimeDisabed = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend // Check if RLS policies exist for realtime.messages table const realtimeMessagesPolicies = policies?.filter( (policy) => policy.schema === 'realtime' && policy.table === 'messages' @@ -87,6 +87,9 @@ export const RealtimeSettings = () => { .max(maxConn?.maxConnections ?? 100), max_concurrent_users: z.coerce.number().min(1).max(50000), max_events_per_second: z.coerce.number().min(1).max(10000), + max_presence_events_per_second: z.coerce.number().min(1).max(10000), + max_payload_size_in_kb: z.coerce.number().min(1).max(3000), + suspend: z.boolean(), // [Joshen] These fields are temporarily hidden from the UI // max_bytes_per_second: z.coerce.number().min(1).max(10000000), // max_channels_per_client: z.coerce.number().min(1).max(10000), @@ -118,6 +121,9 @@ export const RealtimeSettings = () => { connection_pool: data.connection_pool, max_concurrent_users: data.max_concurrent_users, max_events_per_second: data.max_events_per_second, + max_presence_events_per_second: data.max_presence_events_per_second, + max_payload_size_in_kb: data.max_payload_size_in_kb, + suspend: data.suspend, }) } @@ -129,6 +135,53 @@ export const RealtimeSettings = () => { ) : ( + + ( + Enable Realtime service} + > + + + + field.onChange(!checked)} + disabled={!canUpdateConfig} + /> + + + + {isSuccessOrganization && isRealtimeDisabed && ( + +
+
+
+ Realtime service is disabled +
+

+ You will need to enable it to continue using Realtime +

+
+
+
+ )} +
+
+ )} + /> +
{ - {isSuccessPolicies && !hasRealtimeMessagesPolicies && !allow_public && ( - -

- Private mode is {isSettingToPrivate ? 'being ' : ''} - enabled, but no RLS policies exists on the{' '} - realtime.messages table. No - messages will be received by users. -

+ {isSuccessPolicies && + !hasRealtimeMessagesPolicies && + !allow_public && + !isRealtimeDisabed && ( + +

+ Private mode is {isSettingToPrivate ? 'being ' : ''} + enabled, but no RLS policies exists on the{' '} + realtime.messages table. No + messages will be received by users. +

- - - } - /> - )} + + + } + /> + )} )} @@ -209,7 +265,7 @@ export const RealtimeSettings = () => { @@ -249,7 +305,12 @@ export const RealtimeSettings = () => { > - + @@ -282,12 +343,134 @@ export const RealtimeSettings = () => { + + + {isSuccessOrganization && !isUsageBillingEnabled && !isRealtimeDisabed && ( + +
+
+
+ Spend cap needs to be disabled to configure this value +
+

+ {isFreePlan + ? 'Upgrade to the Pro plan first to disable spend cap' + : 'You may adjust this setting in the organization billing settings'} +

+
+
+ {isFreePlan ? ( + + ) : ( + + )} +
+
+
+ )} + + + )} + /> +
+ + ( + + Sets maximum number of presence events per second that can be sent to + your Realtime service +

+ } + > + Max presence events per second + + } + > + + + + + + {isSuccessOrganization && !isUsageBillingEnabled && !isRealtimeDisabed && ( + +
+
+
+ Spend cap needs to be disabled to configure this value +
+

+ {isFreePlan + ? 'Upgrade to the Pro plan first to disable spend cap' + : 'You may adjust this setting in the organization billing settings'} +

+
+
+ {isFreePlan ? ( + + ) : ( + + )} +
+
+
+ )} +
+
+ )} + /> +
+ + ( + + Sets maximum number of payload size in KB that can be sent to your + Realtime service +

+ } + > + Max payload size in KB + + } + > + + + - {isSuccessOrganization && !isUsageBillingEnabled && ( + {isSuccessOrganization && !isUsageBillingEnabled && !isRealtimeDisabed && (
diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 0b200b9a810ac..79b6520407461 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -5,6 +5,7 @@ import Image from 'next/image' import Link from 'next/link' import { useMemo } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { getAddons, @@ -12,6 +13,7 @@ import { } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { NoticeBar } from 'components/interfaces/DiskManagement/ui/NoticeBar' import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, @@ -446,7 +448,7 @@ export const Addons = () => {
@@ -487,11 +489,15 @@ export const Addons = () => { Reach out to us via support if you're interested

diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 172f0c9966c23..8e655eeb145ca 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -258,7 +259,7 @@ const PITRSidePanel = () => {
diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx index 58e6fd8d41378..46e1bd6e09abd 100644 --- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { number, object } from 'yup' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -118,11 +119,15 @@ const DiskSizeConfigurationModal = ({ need more than this, contact us via support for help.

diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 570e054c8cfef..b7d786ce54717 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -1,16 +1,17 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { template } from 'lodash' import { Download, Loader2 } from 'lucide-react' -import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { InlineLinkClassName } from 'components/ui/InlineLink' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSSLEnforcementQuery } from 'data/ssl-enforcement/ssl-enforcement-query' import { useSSLEnforcementUpdateMutation } from 'data/ssl-enforcement/ssl-enforcement-update-mutation' @@ -109,15 +110,8 @@ const SSLConfiguration = () => { title="SSL enforcement was not updated successfully" > Please try updating again, or contact{' '} - - support - {' '} - if this error persists + support if this + error persists )}
diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index f3ee01982a244..06bd6f23fcdb2 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -1,13 +1,15 @@ import { AlertCircle } from 'lucide-react' -import Link from 'next/link' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { FormHeader } from 'components/ui/Forms/FormHeader' +import { InlineLinkClassName } from 'components/ui/InlineLink' import Panel from 'components/ui/Panel' import UpgradeToPro from 'components/ui/UpgradeToPro' import { - type CustomDomainsData, useCustomDomainsQuery, + type CustomDomainsData, } from 'data/custom-domains/custom-domains-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -91,9 +93,12 @@ export const CustomDomainConfig = () => {

Failed to retrieve custom domain configuration. Please try again later or{' '} - + contact support - + .

diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index e6f55fac5b269..3d975a7696167 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { calculateIOPSPrice, @@ -12,6 +13,7 @@ import { DISK_PRICING, DiskType, } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DocsButton } from 'components/ui/DocsButton' import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation' @@ -287,13 +289,16 @@ const DeployNewReplicaPanel = ({ diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 9b9700a3ee282..ab0edd298e8e5 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -273,7 +273,7 @@ const ProjectLinks = () => { active={isUndefined(activeRoute) && !isUndefined(router.query.ref)} route={{ key: 'HOME', - label: 'Project overview', + label: 'Project Overview', icon: , link: `/project/${ref}`, linkElement: , diff --git a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx index c4e05cf8bd51b..ddca9ff4c619d 100644 --- a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx +++ b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx @@ -1,9 +1,10 @@ import * as Sentry from '@sentry/nextjs' import { useEffect } from 'react' -import { InlineLink } from 'components/ui/InlineLink' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink' import { toast } from 'sonner' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { SupportLink } from '../Support/SupportLink' interface SessionTimeoutModalProps { visible: boolean @@ -60,9 +61,12 @@ export const SessionTimeoutModal = ({

If none of these steps work, please{' '} - + Contact support - + .

diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 31e22f38b2597..199cb417e00e1 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import z from 'zod' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useAuthError } from 'common' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' @@ -17,6 +18,7 @@ import { useSignOut } from 'lib/auth' import { getReturnToPath } from 'lib/gotrue' import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { SupportLink } from '../Support/SupportLink' const schema = z.object({ code: z.string().min(1, 'MFA Code is required'), @@ -200,14 +202,15 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {

  • - Reach out to us via support - +
  • diff --git a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx index 6105523fcca46..a13720a5ff204 100644 --- a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx +++ b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx @@ -1,7 +1,8 @@ +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import Link from 'next/link' import type { ResponseError } from 'types' import { Alert, Button } from 'ui' +import { SupportLink } from '../Support/SupportLink' export interface StorageBucketsErrorProps { error: ResponseError @@ -19,11 +20,15 @@ const StorageBucketsError = ({ error }: StorageBucketsErrorProps) => { title="Failed to fetch buckets" actions={[ , ]} > diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx index ae10f3997b09f..d8bc51dd936de 100644 --- a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -22,6 +22,7 @@ import { SEVERITY_OPTIONS, } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' +import { NO_PROJECT_MARKER } from './SupportForm.utils' interface CategoryAndSeverityInfoProps { form: UseFormReturn @@ -148,7 +149,7 @@ function SeveritySelector({ form }: SeveritySelectorProps) { } const IssueSuggestion = ({ category, projectRef }: { category: string; projectRef?: string }) => { - const baseUrl = `/project/${projectRef === 'no-project' ? '_' : projectRef}` + const baseUrl = `/project/${projectRef === NO_PROJECT_MARKER ? '_' : projectRef}` const className = 'col-span-2 mb-0' diff --git a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx new file mode 100644 index 0000000000000..02a9792049747 --- /dev/null +++ b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx @@ -0,0 +1,74 @@ +import { ChevronRight } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { UseFormReturn } from 'react-hook-form' + +import { + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + FormField_Shadcn_, + Switch, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import type { SupportFormValues } from './SupportForm.schema' +import { DASHBOARD_LOG_CATEGORIES, getSanitizedBreadcrumbs } from './dashboard-logs' + +interface DashboardLogsToggleProps { + form: UseFormReturn +} + +export function DashboardLogsToggle({ form }: DashboardLogsToggleProps) { + const sanitizedLogJson = useMemo(() => JSON.stringify(getSanitizedBreadcrumbs(), null, 2), []) + + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + + if (!DASHBOARD_LOG_CATEGORIES.includes(form.getValues('category'))) return + + return ( + ( + + Include dashboard activity log + + } + description={ +
    + + Share sanitized logs of recent dashboard actions to help reproduce the issue. + + + + + Preview log + + +
    +                    {sanitizedLogJson}
    +                  
    +
    +
    +
    + } + > + +
    + )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/Success.tsx b/apps/studio/components/interfaces/Support/Success.tsx index 4d428c6a376e6..1ad3a17cbe111 100644 --- a/apps/studio/components/interfaces/Support/Success.tsx +++ b/apps/studio/components/interfaces/Support/Success.tsx @@ -6,19 +6,23 @@ import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useProfile } from 'lib/profile' import { Button, Input, Separator } from 'ui' import { CATEGORY_OPTIONS } from './Support.constants' +import { NO_PROJECT_MARKER } from './SupportForm.utils' interface SuccessProps { sentCategory?: string selectedProject?: string } -export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: SuccessProps) => { +export const Success = ({ + sentCategory = '', + selectedProject = NO_PROJECT_MARKER, +}: SuccessProps) => { const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' const { data: project } = useProjectDetailQuery( { ref: selectedProject }, - { enabled: selectedProject !== 'no-project' } + { enabled: selectedProject !== NO_PROJECT_MARKER } ) const projectName = project ? project.name : 'No specific project' @@ -40,7 +44,7 @@ export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: S

    We will reach out to you at {respondToEmail}.

    - {selectedProject !== 'no-project' && ( + {selectedProject !== NO_PROJECT_MARKER && (

    Your ticket has been logged for the project{' '} {projectName}, reference ID:{' '} diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index bdb5b8df1ee52..a5b49bc2e010b 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -35,6 +35,7 @@ export function SupportAccessToggle({ form }: SupportAccessToggleProps) { render={({ field }) => { return ( diff --git a/apps/studio/components/interfaces/Support/SupportForm.schema.ts b/apps/studio/components/interfaces/Support/SupportForm.schema.ts index 79c9af8cfeb29..29b160027f89d 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.schema.ts +++ b/apps/studio/components/interfaces/Support/SupportForm.schema.ts @@ -20,6 +20,7 @@ const createFormSchema = (showClientLibraries: boolean) => { message: z.string().min(1, "Please add a message about the issue that you're facing"), affectedServices: z.string(), allowSupportAccess: z.boolean(), + attachDashboardLogs: z.boolean(), dashboardSentryIssueId: z.string().optional(), }) diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 991fbcb3c0374..853600d4d76b4 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -3,16 +3,16 @@ import { createLoader, createParser, createSerializer, - parseAsString, type inferParserType, + parseAsString, type UseQueryStatesKeysMap, } from 'nuqs' // End of third-party imports import { - DocsSearchResultType as PageType, type DocsSearchResult as Page, type DocsSearchResultSection as PageSection, + DocsSearchResultType as PageType, } from 'common' import { getProjectDetail } from 'data/projects/project-detail-query' import dayjs from 'dayjs' @@ -28,11 +28,13 @@ export const formatMessage = ({ attachments = [], error, commit, + dashboardLogUrl, }: { message: string attachments?: string[] error: string | null | undefined commit: { commitSha: string; commitTime: string } | undefined + dashboardLogUrl?: string }) => { const errorString = error != null ? `\n\nError: ${error}` : '' const attachmentsString = @@ -41,7 +43,8 @@ export const formatMessage = ({ commit != undefined ? `\n\n---\nSupabase Studio version: SHA ${commit.commitSha} deployed at ${commit.commitTime === 'unknown' ? 'unknown time' : dayjs(commit.commitTime).format('YYYY-MM-DD HH:mm:ss Z')}` : '' - return `${message}${errorString}${attachmentsString}${commitString}` + const logString = dashboardLogUrl ? `\nDashboard logs: ${dashboardLogUrl}` : '' + return `${message}${errorString}${attachmentsString}${commitString}${logString}` } export function getPageIcon(page: Page) { @@ -119,8 +122,8 @@ const parseAsCategoryOption = createParser({ }) const supportFormUrlState = { - projectRef: parseAsString.withDefault(NO_PROJECT_MARKER), - orgSlug: parseAsString.withDefault(NO_ORG_MARKER), + projectRef: parseAsString.withDefault(''), + orgSlug: parseAsString.withDefault(''), category: parseAsCategoryOption, subject: parseAsString.withDefault(''), message: parseAsString.withDefault(''), @@ -134,7 +137,7 @@ export const loadSupportFormInitialParams = createLoader(supportFormUrlState) const serializeSupportFormInitialParams = createSerializer(supportFormUrlState) -export function createSupportFormUrl(initialParams: SupportFormUrlKeys) { +export function createSupportFormUrl(initialParams: Partial) { const serializedParams = serializeSupportFormInitialParams(initialParams) return `/support/new${serializedParams ?? ''}` } diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index e4d48d0b9a4d6..064a4c6fe22ab 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -23,8 +23,8 @@ import type { SupportFormValues } from './SupportForm.schema' import { createInitialSupportFormState, type SupportFormActions, - supportFormReducer, type SupportFormState, + supportFormReducer, } from './SupportForm.state' import { SupportFormV2 } from './SupportFormV2' import { useSupportForm } from './useSupportForm' diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 04b62436b1409..398857db16b8c 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -8,10 +8,11 @@ import { CLIENT_LIBRARIES } from 'common/constants' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation' import { useDeploymentCommitQuery } from 'data/utils/deployment-commit-query' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' -import { DialogSectionSeparator, Form_Shadcn_, Separator } from 'ui' +import { DialogSectionSeparator, Form_Shadcn_ } from 'ui' import { AffectedServicesSelector, CATEGORIES_WITHOUT_AFFECTED_SERVICES, @@ -19,6 +20,7 @@ import { import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload' import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' import { ClientLibraryInfo } from './ClientLibraryInfo' +import { DashboardLogsToggle } from './DashboardLogsToggle' import { MessageField } from './MessageField' import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' @@ -33,6 +35,7 @@ import { NO_ORG_MARKER, NO_PROJECT_MARKER, } from './SupportForm.utils' +import { DASHBOARD_LOG_CATEGORIES, uploadDashboardLog } from './dashboard-logs' const useIsSimplifiedForm = (slug: string) => { const simplifiedSupportForm = useFlag('simplifiedSupportForm') @@ -64,6 +67,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) const attachmentUpload = useAttachmentUpload() + const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation() const { data: commit } = useDeploymentCommitQuery({ staleTime: 1000 * 60 * 10, // 10 minutes @@ -86,9 +90,19 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo }, }) - const onSubmit: SubmitHandler = async (values) => { + const onSubmit: SubmitHandler = async (formValues) => { dispatch({ type: 'SUBMIT' }) - const attachments = await attachmentUpload.createAttachments() + + const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues + const attachDashboardLogs = + formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category) + + const [attachments, dashboardLogUrl] = await Promise.all([ + attachmentUpload.createAttachments(), + attachDashboardLogs + ? uploadDashboardLog({ userId: profile?.gotrue_id, uploadDashboardLogFn }) + : undefined, + ]) const selectedLibrary = values.library ? CLIENT_LIBRARIES.find((library) => library.language === values.library) @@ -111,6 +125,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo attachments, error: initialError, commit, + dashboardLogUrl: dashboardLogUrl?.[0], }), verified: true, tags: ['dashboard-support-form'], @@ -195,13 +210,21 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo -

    - {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( - <> - - - - )} + {DASHBOARD_LOG_CATEGORIES.includes(category) && ( + <> + + + + )} + + {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + <> + + + + )} + +
    } & Omit, 'href'> +>) => { + const href = createSupportFormUrl(queryParams ?? {}) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 5f6f45a3f09e6..03ec0e4e77311 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -88,11 +88,22 @@ vi.mock('react-inlinesvg', () => ({ default: () => null, })) -// Mock the support storage client module - will be configured per test vi.mock('../support-storage-client', () => ({ createSupportStorageClient: vi.fn(), })) +vi.mock(import('lib/breadcrumbs'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getOwnershipOfBreadcrumbSnapshot: vi.fn(), + } +}) + +let createSupportStorageClientMock: ReturnType +let getBreadcrumbSnapshotMock: ReturnType +let generateAttachmentUrlSpy: ReturnType + // Mock sonner toast vi.mock('sonner', () => ({ toast: { @@ -221,6 +232,13 @@ const selectLibraryOption = async (screen: Screen, optionLabel: string) => { await userEvent.click(option) } +const getDashboardLogsToggle = (screen: Screen, type: 'find' | 'query' = 'find') => { + const labelMatcher = /dashboard .* log/i + return type === 'find' + ? screen.findByRole('switch', { name: labelMatcher }) + : screen.queryByRole('switch', { name: labelMatcher }) +} + const getSupportForm = () => { const form = document.querySelector('form#support-form') expect(form).not.toBeNull() @@ -273,10 +291,69 @@ describe('SupportFormPage', () => { }) }) - beforeEach(() => { + beforeEach(async () => { mockUseDeploymentCommitQuery.mockReturnValue({ data: { commitSha: mockCommitSha, commitTime: mockCommitTime }, }) + const { createSupportStorageClient } = await import('../support-storage-client') + createSupportStorageClientMock = vi.mocked(createSupportStorageClient) + createSupportStorageClientMock.mockReset() + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload: vi.fn(async (path: string) => ({ + data: { path }, + error: null, + })), + createSignedUrls: vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })), + })), + }, + } as any) + + generateAttachmentUrlSpy = vi.fn() + mswServer.use( + http.post('*/rest/v1/rpc/docs_search_fts', async () => { + return HttpResponse.json([]) + }), + http.post('*/rest/v1/rpc/docs_search_fts_nimbus', async () => { + return HttpResponse.json([]) + }), + http.post('*/functions/v1/search-embeddings', async () => { + return HttpResponse.json([]) + }), + http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => { + const body = (await request.json()) as { + bucket?: string + filenames?: string[] + } + generateAttachmentUrlSpy(body) + const filenames = body.filenames ?? [] + return HttpResponse.json( + filenames.map((filename) => `https://storage.example.com/signed/${filename}`) + ) + }) + ) + + const breadcrumbsModule = await import('lib/breadcrumbs') + getBreadcrumbSnapshotMock = vi.mocked(breadcrumbsModule.getOwnershipOfBreadcrumbSnapshot) + getBreadcrumbSnapshotMock.mockReset() + getBreadcrumbSnapshotMock.mockReturnValue([ + { + timestamp: 1_710_000_000, + category: 'ui.action', + message: 'Clicked button', + level: 'info', + data: { route: '/project/_/dashboard' }, + }, + ]) + Object.defineProperty(window.navigator, 'userAgent', { value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', @@ -427,6 +504,20 @@ describe('SupportFormPage', () => { }) }) + test('loading a URL with explicit no project ref falls back to first organization and no project', async () => { + Object.defineProperty(window, 'location', { + value: createMockLocation(`?projectRef=${NO_PROJECT_MARKER}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + }) + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { mswServer.use( http.get(`${API_URL}/platform/projects/:ref`, () => @@ -597,10 +688,13 @@ describe('SupportFormPage', () => { renderSupportFormPage() - await waitFor(() => { - expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') - expect(getProjectSelector(screen)).toHaveTextContent('Project 1') - }) + await waitFor( + () => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }, + { timeout: 5_000 } + ) await selectCategoryOption(screen, 'Dashboard bug') await waitFor(() => { @@ -1043,6 +1137,241 @@ describe('SupportFormPage', () => { } }, 10_000) + test('shows dashboard logs toggle only for Dashboard bug issues', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + + await selectCategoryOption(screen, 'APIs and client libraries') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + await waitFor(() => { + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + const dashboardLogToggleAgain = await getDashboardLogsToggle(screen) + expect(dashboardLogToggleAgain).toBeChecked() + }) + + test('skips dashboard log upload when toggle is disabled', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async () => ({ + data: { path: 'dashboard-logs/mock.log.json' }, + error: null, + })) + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + await userEvent.click(dashboardLogToggle!) + expect(dashboardLogToggle).not.toBeChecked() + + await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing') + await userEvent.type(getMessageField(screen), 'Charts throw error on load') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).not.toHaveBeenCalled() + expect(createSignedUrls).not.toHaveBeenCalled() + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Charts throw error on load') + expect(payload.message).not.toContain('Dashboard logs:') + }) + + test('skips dashboard log upload when toggle hidden', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async () => ({ + data: { path: 'dashboard-logs/mock.log.json' }, + error: null, + })) + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + + await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing') + await userEvent.type(getMessageField(screen), 'Charts throw error on load') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).not.toHaveBeenCalled() + expect(createSignedUrls).not.toHaveBeenCalled() + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Charts throw error on load') + expect(payload.message).not.toContain('Dashboard logs:') + }) + + test('uploads dashboard logs when enabled and appends link to message', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async (path: string) => ({ data: { path }, error: null })) + const createSignedUrls = vi.fn(async (paths: string[], _expiry: number) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/signed/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await screen.findByRole('switch', { + name: /include dashboard activity log/i, + }) + expect(dashboardLogToggle).toBeChecked() + + await userEvent.type(getSummaryField(screen), 'Dashboard navigation broken') + await userEvent.type( + getMessageField(screen), + 'Navigation menu does not respond after latest deploy' + ) + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(generateAttachmentUrlSpy).toHaveBeenCalledTimes(1) + }) + expect(generateAttachmentUrlSpy.mock.calls[0]?.[0]).toMatchObject({ + bucket: 'dashboard-logs', + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Navigation menu does not respond after latest deploy') + expect(payload.message).toMatch( + /Dashboard logs: https:\/\/storage\.example\.com\/signed\/.+\.json/ + ) + }) + test('shows toast on submission error and allows form re-editing and resubmission', async () => { const submitSpy = vi.fn() const toastErrorSpy = vi.fn() @@ -1111,9 +1440,10 @@ describe('SupportFormPage', () => { const payload = submitSpy.mock.calls[0]?.[0] expect(payload.subject).toBe('Cannot access settings') - expect(payload.message).toBe( + expect(payload.message).toMatch( 'Settings page shows 500 error - updated description' + supportVersionInfo ) + expect(payload.message).toMatch(/Dashboard logs: https:\/\/storage\.example\.com\/.+\.json/) await waitFor(() => { expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!') @@ -1149,7 +1479,6 @@ describe('SupportFormPage', () => { 'https://storage.example.com/signed/file2.jpg?token=def456', ] - const { createSupportStorageClient } = await import('../support-storage-client') const mockStorageClient = { storage: { from: vi.fn(() => ({ @@ -1160,7 +1489,8 @@ describe('SupportFormPage', () => { })), }, } - vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any) + + createSupportStorageClientMock.mockReturnValue(mockStorageClient as any) mswServer.use( http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => { @@ -1271,7 +1601,7 @@ describe('SupportFormPage', () => { unmount?.() url.createObjectURL = originalCreateObjectURL url.revokeObjectURL = originalRevokeObjectURL - vi.mocked(createSupportStorageClient).mockReset() + createSupportStorageClientMock.mockReset() } }, 10_000) @@ -1313,6 +1643,11 @@ describe('SupportFormPage', () => { expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') }) + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + await userEvent.click(dashboardLogToggle!) + expect(dashboardLogToggle).not.toBeChecked() + await userEvent.type(getSummaryField(screen), 'Cannot access my account') await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account') diff --git a/apps/studio/components/interfaces/Support/dashboard-logs.ts b/apps/studio/components/interfaces/Support/dashboard-logs.ts new file mode 100644 index 0000000000000..93da930b3a855 --- /dev/null +++ b/apps/studio/components/interfaces/Support/dashboard-logs.ts @@ -0,0 +1,77 @@ +import * as Sentry from '@sentry/nextjs' + +import { SupportCategories } from '@supabase/shared-types/out/constants' +import type { + GenerateAttachmentURLsData, + GenerateAttachmentURLsVariables, +} from 'data/support/generate-attachment-urls-mutation' +import { getMirroredBreadcrumbs, getOwnershipOfBreadcrumbSnapshot } from 'lib/breadcrumbs' +import { uuidv4 } from 'lib/helpers' +import { sanitizeArrayOfObjects } from 'lib/sanitize' +import { createSupportStorageClient } from './support-storage-client' +import type { ExtendedSupportCategories } from './Support.constants' + +export type DashboardBreadcrumb = Sentry.Breadcrumb + +export const DASHBOARD_LOG_BUCKET = 'dashboard-logs' + +export const DASHBOARD_LOG_CATEGORIES: ExtendedSupportCategories[] = [ + SupportCategories.DASHBOARD_BUG, +] + +export const getSanitizedBreadcrumbs = (): unknown[] => { + const breadcrumbs = getOwnershipOfBreadcrumbSnapshot() ?? getMirroredBreadcrumbs() + return sanitizeArrayOfObjects(breadcrumbs) +} + +export const uploadDashboardLog = async ({ + userId, + uploadDashboardLogFn, +}: { + userId: string | undefined + uploadDashboardLogFn: ( + vars: GenerateAttachmentURLsVariables + ) => Promise +}): Promise => { + if (!userId) { + console.error( + '[SupportForm > uploadDashboardLog] Cannot upload dashboard log: user ID is undefined' + ) + return [] + } + + const sanitized = getSanitizedBreadcrumbs() + if (sanitized.length === 0) return [] + + try { + const supportStorageClient = createSupportStorageClient() + const objectKey = `${userId}/${uuidv4()}.json` + const body = new Blob([JSON.stringify(sanitized, null, 2)], { + type: 'application/json', + }) + + const { error: uploadError } = await supportStorageClient.storage + .from(DASHBOARD_LOG_BUCKET) + .upload(objectKey, body, { + cacheControl: '3600', + contentType: 'application/json', + upsert: false, + }) + + if (uploadError) { + console.error( + '[SupportForm > uploadDashboardLog] Failed to upload dashboard log to support storage bucket', + uploadError + ) + return [] + } + + return uploadDashboardLogFn({ + bucket: DASHBOARD_LOG_BUCKET, + filenames: [objectKey], + }) + } catch (error) { + console.error('[SupportForm] Unexpected error uploading dashboard log', error) + return [] + } +} diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts index b3aa32e06c3f6..34575b4c87354 100644 --- a/apps/studio/components/interfaces/Support/useSupportForm.ts +++ b/apps/studio/components/interfaces/Support/useSupportForm.ts @@ -24,6 +24,7 @@ const supportFormDefaultValues: DefaultValues = { message: '', affectedServices: '', allowSupportAccess: true, + attachDashboardLogs: true, dashboardSentryIssueId: '', } @@ -83,10 +84,7 @@ export function useSupportForm(dispatch: Dispatch): UseSuppo urlParamsRef.current.orgSlug && urlParamsRef.current.orgSlug !== NO_ORG_MARKER ? urlParamsRef.current.orgSlug : null - const projectRefFromUrl = - urlParamsRef.current.projectRef && urlParamsRef.current.projectRef !== NO_PROJECT_MARKER - ? urlParamsRef.current.projectRef - : null + const projectRefFromUrl = urlParamsRef.current.projectRef ?? null selectInitialOrgAndProject({ projectRef: projectRefFromUrl, diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index b12a696178fab..0b6a7975d456d 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -5,6 +5,7 @@ import { useParams } from 'common' import ClientLibrary from 'components/interfaces/Home/ClientLibrary' import { ExampleProject } from 'components/interfaces/Home/ExampleProject' import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' import { useInvalidateProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query' @@ -112,7 +113,7 @@ const BuildingState = () => { support ticket.

    } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index 54e30a670a711..c73d79a056201 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -1,7 +1,7 @@ import { Lightbulb, TriangleAlert } from 'lucide-react' -import Link from 'next/link' import { useState } from 'react' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui' import { FeedbackWidget } from './FeedbackWidget' @@ -42,13 +42,13 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
    What would you like to share?
    diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index ca0a7f839c2ae..cdd1f10dcfe79 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -1,10 +1,10 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { PermissionAction, SupportCategories } 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 { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { InlineLink } from 'components/ui/InlineLink' @@ -69,11 +69,15 @@ export const PauseFailedState = () => {
    {
    {
    {
    diff --git a/apps/studio/components/ui/Error.tsx b/apps/studio/components/ui/Error.tsx index 52045c1595108..c61a43c4f2b75 100644 --- a/apps/studio/components/ui/Error.tsx +++ b/apps/studio/components/ui/Error.tsx @@ -1,3 +1,4 @@ +import { SupportLink } from 'components/interfaces/Support/SupportLink' import Link from 'next/link' import { useEffect } from 'react' import { Button } from 'ui' @@ -21,7 +22,7 @@ export default function EmptyPageState({ error }: any) { Head back

    diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx index c8539fd37eec2..6747f5717f491 100644 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx @@ -3,6 +3,8 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Button, cn } from 'ui' import { Admonition } from 'ui-patterns' @@ -153,13 +155,16 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback > {!isRemoveChildError && !isInsertBeforeError && ( )} @@ -176,14 +181,17 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback )} {(isRemoveChildError || isInsertBeforeError) && ( - Still stuck? - + )}

    diff --git a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx index f6b7a6688b7f6..67018c65260ea 100644 --- a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx +++ b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx @@ -1,10 +1,11 @@ import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' import dayjs from 'dayjs' import { X } from 'lucide-react' -import Link from 'next/link' import { useEffect, useState } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query' import { IS_PLATFORM } from 'lib/constants' import { Alert, Button } from 'ui' @@ -60,13 +61,16 @@ export const ProjectUpgradeFailedBanner = () => { actions={
    ) } diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 8c9db51c95c43..976bdb575b55f 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { PropsWithChildren } from 'react' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button } from 'ui' @@ -35,11 +36,19 @@ export const UpgradePlanButton = ({ const href = billingAll ? propsHref ?? `/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}` - : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}` + : '' + const linkChildren = children || `Upgrade to ${plan}` + const link = billingAll ? ( + {linkChildren} + ) : ( + + {linkChildren} + + ) return ( ) } diff --git a/apps/studio/data/projects/project-delete-mutation.ts b/apps/studio/data/projects/project-delete-mutation.ts index b03b8b44548d8..e803d1a007582 100644 --- a/apps/studio/data/projects/project-delete-mutation.ts +++ b/apps/studio/data/projects/project-delete-mutation.ts @@ -32,31 +32,35 @@ export const useProjectDeleteMutation = ({ > = {}) => { const queryClient = useQueryClient() - return useMutation( - (vars) => deleteProject(vars), - { - async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(projectKeys.list()) - - if (variables.organizationSlug) { - await queryClient.invalidateQueries( - projectKeys.infiniteListByOrg(variables.organizationSlug) - ) + return useMutation({ + mutationFn: (vars) => deleteProject(vars), + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries(projectKeys.list()), + queryClient.invalidateQueries(projectKeys.detail(data.ref)), + ]) + + if (variables.organizationSlug) { + await Promise.all([ + queryClient.invalidateQueries(projectKeys.infiniteListByOrg(variables.organizationSlug)), + queryClient.invalidateQueries(organizationKeys.detail(variables.organizationSlug)), + queryClient.invalidateQueries(projectKeys.orgProjects(variables.organizationSlug)), + queryClient.invalidateQueries( organizationKeys.freeProjectLimitCheck(variables.organizationSlug) - ) - } - - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to delete project: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) + ), + ]) + } + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete project: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) } diff --git a/apps/studio/data/realtime/realtime-config-mutation.ts b/apps/studio/data/realtime/realtime-config-mutation.ts index 6d380b5dceaa2..9629e76c794a3 100644 --- a/apps/studio/data/realtime/realtime-config-mutation.ts +++ b/apps/studio/data/realtime/realtime-config-mutation.ts @@ -19,6 +19,9 @@ export async function updateRealtimeConfiguration({ max_bytes_per_second, max_channels_per_client, max_joins_per_second, + max_presence_events_per_second, + max_payload_size_in_kb, + suspend, }: RealtimeConfigurationUpdateVariables) { if (!ref) return console.error('Project ref is required') @@ -32,6 +35,9 @@ export async function updateRealtimeConfiguration({ max_bytes_per_second, max_channels_per_client, max_joins_per_second, + max_presence_events_per_second, + max_payload_size_in_kb, + suspend, }, }) diff --git a/apps/studio/data/realtime/realtime-config-query.ts b/apps/studio/data/realtime/realtime-config-query.ts index fa03f6f5840fb..f9677d13d4865 100644 --- a/apps/studio/data/realtime/realtime-config-query.ts +++ b/apps/studio/data/realtime/realtime-config-query.ts @@ -17,6 +17,9 @@ export const REALTIME_DEFAULT_CONFIG = { max_bytes_per_second: 100000, max_channels_per_client: 100, max_joins_per_second: 100, + max_presence_events_per_second: 100, + max_payload_size_in_kb: 100, + suspend: false, } export async function getRealtimeConfiguration( diff --git a/apps/studio/data/support/generate-attachment-urls-mutation.ts b/apps/studio/data/support/generate-attachment-urls-mutation.ts index cf6cfc4ec99ad..edd36078e7a55 100644 --- a/apps/studio/data/support/generate-attachment-urls-mutation.ts +++ b/apps/studio/data/support/generate-attachment-urls-mutation.ts @@ -12,7 +12,7 @@ export type GenerateAttachmentURLsResponse = { export type GenerateAttachmentURLsVariables = { filenames: string[] - bucket?: 'support-attachments' | 'feedback-attachments' + bucket?: 'support-attachments' | 'feedback-attachments' | 'dashboard-logs' } export async function generateAttachmentURLs({ @@ -45,7 +45,7 @@ export async function generateAttachmentURLs({ } } -type GenerateAttachmentURLsData = Awaited> +export type GenerateAttachmentURLsData = Awaited> export const useGenerateAttachmentURLsMutation = ({ onSuccess, diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index bafe2feee5980..5c5cb94c5c7dc 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -3,9 +3,12 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' +import { match } from 'path-to-regexp' + import { hasConsented } from 'common' import { IS_PLATFORM } from 'common/constants/environment' -import { match } from 'path-to-regexp' +import { MIRRORED_BREADCRUMBS } from 'lib/breadcrumbs' +import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from 'lib/sanitize' // This is a workaround to ignore hCaptcha related errors. function isHCaptchaRelatedError(event: Sentry.Event): boolean { @@ -48,6 +51,21 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, + beforeBreadcrumb(breadcrumb, _hint) { + const cleanedBreadcrumb = { ...breadcrumb } + + if (cleanedBreadcrumb.category === 'navigation') { + if (typeof cleanedBreadcrumb.data?.from === 'string') { + cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) + } + if (typeof cleanedBreadcrumb.data?.to === 'string') { + cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) + } + } + + MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) + return cleanedBreadcrumb + }, beforeSend(event, hint) { const consent = hasConsented() @@ -90,6 +108,9 @@ Sentry.init({ return null } + if (event.breadcrumbs) { + event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] + } return event }, ignoreErrors: [ diff --git a/apps/studio/lib/breadcrumbs.ts b/apps/studio/lib/breadcrumbs.ts new file mode 100644 index 0000000000000..2cf662eac9075 --- /dev/null +++ b/apps/studio/lib/breadcrumbs.ts @@ -0,0 +1,21 @@ +import type * as Sentry from '@sentry/nextjs' + +import { RingBuffer } from './ringBuffer' + +export const MIRRORED_BREADCRUMBS = new RingBuffer(50) + +export const getMirroredBreadcrumbs = (): Sentry.Breadcrumb[] => { + return MIRRORED_BREADCRUMBS.toArray() +} + +let BREADCRUMB_SNAPSHOT: Sentry.Breadcrumb[] | null = null + +export const takeBreadcrumbSnapshot = (): void => { + BREADCRUMB_SNAPSHOT = getMirroredBreadcrumbs() +} + +export const getOwnershipOfBreadcrumbSnapshot = (): Sentry.Breadcrumb[] | null => { + const snapshot = BREADCRUMB_SNAPSHOT + BREADCRUMB_SNAPSHOT = null + return snapshot +} diff --git a/apps/studio/lib/ringBuffer.test.ts b/apps/studio/lib/ringBuffer.test.ts new file mode 100644 index 0000000000000..5aaac0d604208 --- /dev/null +++ b/apps/studio/lib/ringBuffer.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' + +import { RingBuffer } from './ringBuffer' + +describe('RingBuffer', () => { + it('follows FIFO order when popping from the front', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.popFront()).toBe(1) + expect(buffer.popFront()).toBe(2) + expect(buffer.popFront()).toBe(3) + expect(buffer.popFront()).toBeUndefined() + }) + + it('supports popping from the back', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.popBack()).toBe(3) + expect(buffer.popBack()).toBe(2) + expect(buffer.popBack()).toBe(1) + expect(buffer.popBack()).toBeUndefined() + }) + + it('drops the oldest element when full', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + + expect(buffer.length).toBe(3) + expect(buffer.popFront()).toBe(2) + expect(buffer.popFront()).toBe(3) + expect(buffer.popFront()).toBe(4) + }) + + it('handles mixed operations correctly', () => { + const buffer = new RingBuffer(2) + + buffer.pushBack('a') + buffer.pushBack('b') + + expect(buffer.popFront()).toBe('a') + + buffer.pushBack('c') + buffer.pushBack('d') + + expect(buffer.length).toBe(2) + expect(buffer.popBack()).toBe('d') + expect(buffer.popFront()).toBe('c') + expect(buffer.popFront()).toBeUndefined() + }) + + it('returns undefined when popping from an empty buffer', () => { + const buffer = new RingBuffer(1) + + expect(buffer.popFront()).toBeUndefined() + expect(buffer.popBack()).toBeUndefined() + }) + + it('requires a positive integer capacity', () => { + expect(() => new RingBuffer(0)).toThrow('positive integer') + expect(() => new RingBuffer(-1)).toThrow('positive integer') + expect(() => new RingBuffer(1.5 as unknown as number)).toThrow('positive integer') + }) + + it('returns the full contents in order via toArray', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.toArray()).toEqual([1, 2, 3]) + }) + + it('supports slice-style bounds for toArray', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + + expect(buffer.toArray(1, 3)).toEqual([2, 3]) + expect(buffer.toArray(2)).toEqual([3, 4]) + }) + + it('handles negative and overflowing bounds in toArray', () => { + const buffer = new RingBuffer(4) + + buffer.pushBack(10) + buffer.pushBack(20) + buffer.pushBack(30) + buffer.pushBack(40) + + expect(buffer.toArray(-2)).toEqual([30, 40]) + expect(buffer.toArray(0, -1)).toEqual([10, 20, 30]) + expect(buffer.toArray(-5, 10)).toEqual([10, 20, 30, 40]) + }) + + it('returns an empty array when the slice is empty', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + + expect(buffer.toArray(5)).toEqual([]) + expect(buffer.toArray(2, 2)).toEqual([]) + expect(buffer.toArray(2, 1)).toEqual([]) + + const emptyBuffer = new RingBuffer(3) + expect(emptyBuffer.toArray()).toEqual([]) + expect(emptyBuffer.toArray(1)).toEqual([]) + }) + + it('returns entries in order after overwriting oldest values', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + buffer.pushBack(5) + + expect(buffer.toArray()).toEqual([3, 4, 5]) + expect(buffer.toArray(1)).toEqual([4, 5]) + expect(buffer.toArray(-1)).toEqual([5]) + }) +}) diff --git a/apps/studio/lib/ringBuffer.ts b/apps/studio/lib/ringBuffer.ts new file mode 100644 index 0000000000000..0f1b21e2ea786 --- /dev/null +++ b/apps/studio/lib/ringBuffer.ts @@ -0,0 +1,87 @@ +export class RingBuffer { + private readonly capacity: number + private readonly buffer: (T | undefined)[] + private head = 0 + private tail = 0 + private size = 0 + + constructor(capacity: number) { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new Error('RingBuffer capacity must be a positive integer') + } + + this.capacity = capacity + this.buffer = new Array(capacity).fill(undefined) + } + + get length(): number { + return this.size + } + + pushBack(value: T): void { + this.buffer[this.tail] = value + + if (this.size === this.capacity) { + this.head = (this.head + 1) % this.capacity + } else { + this.size += 1 + } + + this.tail = (this.tail + 1) % this.capacity + } + + popFront(): T | undefined { + if (this.size === 0) { + return undefined + } + + const value = this.buffer[this.head] + this.buffer[this.head] = undefined + this.head = (this.head + 1) % this.capacity + this.size -= 1 + + return value + } + + popBack(): T | undefined { + if (this.size === 0) { + return undefined + } + + const index = (this.tail - 1 + this.capacity) % this.capacity + const value = this.buffer[index] + this.buffer[index] = undefined + this.tail = index + this.size -= 1 + + return value + } + + toArray(start?: number, end?: number): T[] { + const len = this.size + + let startIndex = start === undefined ? 0 : Math.trunc(start) + if (startIndex < 0) { + startIndex = Math.max(len + startIndex, 0) + } else { + startIndex = Math.min(startIndex, len) + } + + let endIndex = end === undefined ? len : Math.trunc(end) + if (endIndex < 0) { + endIndex = Math.max(len + endIndex, 0) + } else { + endIndex = Math.min(endIndex, len) + } + + const sliceLength = Math.max(endIndex - startIndex, 0) + const result = new Array(sliceLength) + + for (let offset = 0; offset < sliceLength; offset += 1) { + const physicalIndex = (this.head + startIndex + offset) % this.capacity + result[offset] = this.buffer[physicalIndex] as T + } + + return result + } +} diff --git a/apps/studio/lib/sanitize.test.ts b/apps/studio/lib/sanitize.test.ts new file mode 100644 index 0000000000000..f0707ef4671b6 --- /dev/null +++ b/apps/studio/lib/sanitize.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeArrayOfObjects } from './sanitize' + +describe('sanitizeArrayOfObjects', () => { + it('redacts sensitive keys case-insensitively', () => { + const input = [{ Password: 'hunter2', username: 'alice' }] + + const result = sanitizeArrayOfObjects(input) as Array> + + expect(result).toEqual([{ Password: '[REDACTED]', username: 'alice' }]) + }) + + it('honors custom redaction and extra sensitive keys', () => { + const input = [ + { + customSensitive: 'value', + token: 'should hide', + nested: { customSensitive: 'also hide' }, + }, + ] + + const result = sanitizeArrayOfObjects(input, { + redaction: '', + sensitiveKeys: ['customSensitive'], + }) as Array + + expect(result[0].customSensitive).toBe('') + expect(result[0].token).toBe('') + expect(result[0].nested).toEqual({ customSensitive: '' }) + expect(input[0].nested.customSensitive).toBe('also hide') + }) + + it('redacts known secret patterns in strings', () => { + const samples = [ + { value: '192.168.0.1' }, + { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' }, + { value: 'AKIAIOSFODNN7EXAMPLE' }, + { value: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }, + { value: 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789' }, + { + value: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + }, + { value: 'A'.repeat(32) }, + ] + + const result = sanitizeArrayOfObjects(samples) as Array<{ value: string }> + + for (const item of result) { + expect(item.value).toBe('[REDACTED]') + } + }) + + it('limits recursion depth and uses truncation notice', () => { + const input = [ + { + level1: { + level2: { + level3: { + password: 'secret', + }, + }, + }, + }, + ] + + const [result] = sanitizeArrayOfObjects(input, { + maxDepth: 2, + truncationNotice: '', + }) as Array + + expect(result.level1.level2).toBe('') + expect(result.level1).not.toBe(input[0].level1) + expect(input[0].level1.level2.level3.password).toBe('secret') + }) + + it('handles circular references without crashing', () => { + const obj: any = { name: 'loop' } + obj.self = obj + + const [result] = sanitizeArrayOfObjects([obj]) as Array + + expect(result.self).toBe('[Circular]') + expect(result.name).toBe('loop') + }) + + it('sanitizes complex types consistently', () => { + const date = new Date('2024-01-01T00:00:00.000Z') + const regex = /abc/gi + const fn = () => {} + const arrayBuffer = new ArrayBuffer(8) + const typedArray = new Uint8Array([1, 2, 3]) + const map = new Map() + map.set('password', 'hunter2') + map.set('public', date) + const set = new Set([1, date]) + const url = new URL('https://example.com/path') + const error = new Error('Token is Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789') + const custom = new (class Custom { + toString() { + return 'custom-instance' + } + })() + + const [result] = sanitizeArrayOfObjects([ + { + date, + regex, + fn, + arrayBuffer, + typedArray, + map, + set, + url, + error, + custom, + }, + ]) as Array + + expect(result.date).toBe('2024-01-01T00:00:00.000Z') + expect(result.regex).toBe('/abc/gi') + expect(result.fn).toBe('[Function]') + expect(result.arrayBuffer).toBe('[ArrayBuffer byteLength=8]') + expect(result.typedArray).toBe('[TypedArray byteLength=3]') + expect(result.map).toEqual([ + ['[REDACTED]', '[REDACTED]'], + ['public', '2024-01-01T00:00:00.000Z'], + ]) + expect(result.set).toEqual([1, '2024-01-01T00:00:00.000Z']) + expect(result.url).toBe('https://example.com/path') + expect(result.error).toEqual({ + name: 'Error', + message: 'Token is [REDACTED]', + stack: '[REDACTED: max depth reached]', + }) + expect(result.custom).toBe('custom-instance') + }) + + it('sanitizes primitive array entries', () => { + const [redacted, number] = sanitizeArrayOfObjects([ + 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789', + 42, + ]) as Array + + expect(redacted).toBe('[REDACTED]') + expect(number).toBe(42) + }) + + it('applies maxDepth=0 to top-level entries', () => { + const result = sanitizeArrayOfObjects( + [{ password: 'secret', nested: { value: 'test' } }, 'visible'], + { + maxDepth: 0, + truncationNotice: '', + } + ) as Array + + expect(result[0]).toBe('') + expect(result[1]).toBe('visible') + }) +}) diff --git a/apps/studio/lib/sanitize.ts b/apps/studio/lib/sanitize.ts new file mode 100644 index 0000000000000..c6ae53b5414c7 --- /dev/null +++ b/apps/studio/lib/sanitize.ts @@ -0,0 +1,218 @@ +export function sanitizeUrlHashParams(url: string): string { + return url.split('#')[0] +} + +/** + * Best-effort sanitizer for arrays of objects. + * - Redacts likely secrets by key name (password, token, apiKey, etc.) + * - Redacts likely secrets by value pattern (IPv4/IPv6, AWS keys, Bearer/JWT, generic long tokens) + * - Recurses into nested arrays/objects up to `maxDepth`; beyond that replaces with a notice + * - Handles circular references + * + * @param {any[]} inputArr - Array of items to sanitize (non-objects are copied as-is). + * @param {Object} [opts] + * @param {number} [opts.maxDepth=3] - Maximum depth to traverse (0 == only top level). + * @param {string} [opts.redaction="[REDACTED]"] - Replacement text for sensitive values. + * @param {string} [opts.truncationNotice="[REDACTED: max depth reached]"] - Used when depth limit is hit. + * @param {string[]} [opts.sensitiveKeys] - Extra key names to treat as sensitive (case-insensitive). + * @returns {any[]} a deeply-sanitized clone of the input array + */ +export function sanitizeArrayOfObjects( + inputArr: unknown[], + opts: { + maxDepth?: number + redaction?: string + truncationNotice?: string + sensitiveKeys?: string[] + } = {} +): unknown[] { + const { + maxDepth = 3, + redaction = '[REDACTED]', + truncationNotice = '[REDACTED: max depth reached]', + sensitiveKeys = [], + } = opts + + // Common sensitive key names (case-insensitive). Extendable via opts.sensitiveKeys. + const sensitiveKeySet = new Set( + [ + 'password', + 'passwd', + 'pwd', + 'pass', + 'secret', + 'token', + 'id_token', + 'access_token', + 'refresh_token', + 'apikey', + 'api_key', + 'api-key', + 'apiKey', + 'key', + 'privatekey', + 'private_key', + 'client_secret', + 'clientSecret', + 'auth', + 'authorization', + 'ssh_key', + 'sshKey', + 'bearer', + 'session', + 'cookie', + 'csrf', + 'xsrf', + 'ip', + 'ip_address', + 'ipAddress', + 'aws_access_key_id', + 'aws_secret_access_key', + 'gcp_service_account_key', + ...sensitiveKeys, + ].map((k) => k.toLowerCase()) + ) + + // Value patterns that often indicate secrets or PII + const patterns = [ + // IPv4 + { re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, reason: 'ip' }, + // IPv6 (simplified but effective) + { re: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b/g, reason: 'ip6' }, + // AWS Access Key ID (starts with AKIA/ASIA, 16 remaining upper alnum) + { re: /\b(AKI|ASI)A[0-9A-Z]{16}\b/g, reason: 'aws_access_key_id' }, + // AWS Secret Access Key (40 base64-ish chars) + { re: /\b[0-9A-Za-z/+]{40}\b/g, reason: 'aws_secret_access_key_like' }, + // Bearer tokens + { re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g, reason: 'bearer' }, + // JWT (three base64url segments separated by dots) + { re: /\b[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\b/g, reason: 'jwt_like' }, + // Generic long API-ish token (conservative: 24–64 safe chars) + { re: /\b[A-Za-z0-9_\-]{24,64}\b/g, reason: 'long_token' }, + ] + + const seen = new WeakMap() + + function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null + } + + function redactString(str: string) { + let out = str + for (const { re } of patterns) out = out.replace(re, redaction) + return out + } + + function shouldRedactByKey(key: string | symbol | number) { + return sensitiveKeySet.has(String(key).toLowerCase()) + } + + function sanitizeValue(value: unknown, depth: number): unknown { + if ( + value == null || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return value + } + + if (typeof value === 'string') { + return redactString(value) + } + + if (typeof value === 'function') { + return '[Function]' + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (value instanceof RegExp) { + return value.toString() + } + + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return `[TypedArray byteLength=${value.byteLength}]` + } + if (value instanceof ArrayBuffer) { + return `[ArrayBuffer byteLength=${value.byteLength}]` + } + + if (depth >= maxDepth) { + return truncationNotice + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]' + } + + if (Array.isArray(value)) { + const outArr: unknown[] = [] + seen.set(value, outArr) + for (let i = 0; i < value.length; i++) { + outArr[i] = sanitizeValue(value[i], depth + 1) + } + return outArr + } + + if (isPlainObject(value)) { + const outObj: Record = {} + seen.set(value, outObj) + for (const [k, v] of Object.entries(value)) { + if (shouldRedactByKey(k)) { + outObj[k] = redaction + } else { + outObj[k] = sanitizeValue(v, depth + 1) + } + } + return outObj + } + + if (value instanceof Map) { + const out: unknown[] = [] + seen.set(value, out) + for (const [k, v] of value.entries()) { + const redactedKey = shouldRedactByKey(k) ? redaction : sanitizeValue(k, depth + 1) + const redactedVal = shouldRedactByKey(k) ? redaction : sanitizeValue(v, depth + 1) + out.push([redactedKey, redactedVal]) + } + return out + } + + if (value instanceof Set) { + const out: unknown[] = [] + seen.set(value, out) + for (const v of value.values()) { + out.push(sanitizeValue(v, depth + 1)) + } + return out + } + + if (value instanceof URL) return value.toString() + if (value instanceof Error) { + const o = { + name: value.name, + message: redactString(value.message), + stack: truncationNotice, + } + seen.set(value, o) + return o + } + + try { + return redactString(String(value)) + } catch { + return redactString(Object.prototype.toString.call(value)) + } + } + + return redactString(String(value)) + } + + return inputArr.map((item) => sanitizeValue(item, 0)) +} diff --git a/apps/studio/pages/500.tsx b/apps/studio/pages/500.tsx index 72ff3bbb32899..e37f834dd87e6 100644 --- a/apps/studio/pages/500.tsx +++ b/apps/studio/pages/500.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { LOCAL_STORAGE_KEYS } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSignOut } from 'lib/auth' import { Button } from 'ui' @@ -69,7 +70,7 @@ const Error500: NextPage = () => { )}
    diff --git a/apps/studio/pages/api/generate-attachment-url.ts b/apps/studio/pages/api/generate-attachment-url.ts index 87c3b89211786..1bb8fcbff42d8 100644 --- a/apps/studio/pages/api/generate-attachment-url.ts +++ b/apps/studio/pages/api/generate-attachment-url.ts @@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js' import type { NextApiRequest, NextApiResponse } from 'next' import z from 'zod' +import { DASHBOARD_LOG_BUCKET } from 'components/interfaces/Support/dashboard-logs' import apiWrapper from 'lib/api/apiWrapper' import { getUserClaims } from 'lib/gotrue' @@ -9,7 +10,9 @@ export const maxDuration = 120 const GenerateAttachmentUrlSchema = z.object({ filenames: z.array(z.string()), - bucket: z.enum(['support-attachments', 'feedback-attachments']).default('support-attachments'), + bucket: z + .enum(['support-attachments', 'feedback-attachments', DASHBOARD_LOG_BUCKET]) + .default('support-attachments'), }) async function handlePost(req: NextApiRequest, res: NextApiResponse) { diff --git a/packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts b/packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts index ceaf86c7fa5ad..67b80967b0907 100644 --- a/packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts +++ b/packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts @@ -17,4 +17,3 @@ WHERE n.nspname = '${schema}' ) ; `.trim() - diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 1aca37d67f9cb..54d41edfe5751 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -81,8 +81,8 @@ const LabelContainerVariants = cva('transition-all duration-500 ease-in-out', { layout: { horizontal: 'flex flex-col gap-2 col-span-4', vertical: 'flex flex-row gap-2 justify-between', - flex: 'flex flex-col gap-0', - 'flex-row-reverse': 'flex flex-col', + flex: 'flex flex-col gap-0 min-w-0', + 'flex-row-reverse': 'flex flex-col min-w-0', }, labelLayout: { horizontal: '', diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index eb8c2c207c2fc..b05eeb89ae7a9 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -280,6 +280,7 @@ allow_list = [ "TextLocal", "TimescaleDB", "Transformers.js", + "tsquery", "[Tt]unneled", "Twilio", "Undici",