From 271ee3af6dd1c6021df1f8a0e1d4a26407e44679 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 12 Sep 2025 01:07:31 -0400 Subject: [PATCH 1/9] fix: estimate number of users when count is large (#38638) * fix: estimate number of users when count is large In the Auth Users table, we always fetch an exact count of users. This can be a problem for projects with many (>50K) users as the count(*) might cause performance issues on the database. We already have logic on the Table Editor to only run automatic count estimates (fetching the exact count only if usr requests it), this change ports the same logic over to Auth Users. * Nit refactor --------- Co-authored-by: Joshen Lim --- .../footer/pagination/Pagination.utils.ts | 4 +- .../interfaces/Auth/Users/UsersV2.tsx | 89 ++++++++++++++++++- apps/studio/data/auth/keys.ts | 1 + apps/studio/data/auth/users-count-query.ts | 88 +++++++++++++++--- .../data/table-rows/table-rows-count-query.ts | 2 +- 5 files changed, 167 insertions(+), 17 deletions(-) diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.utils.ts b/apps/studio/components/grid/components/footer/pagination/Pagination.utils.ts index 4280c29cef541..9590b5efbbf33 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.utils.ts +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.utils.ts @@ -7,5 +7,7 @@ export const formatEstimatedCount = (value: number) => { const unit = i > 4 ? 'T' : sizes[i] - return `${(value / Math.pow(k, i > 4 ? 4 : i)).toFixed(1)}${unit}` + const formattedValue = value / Math.pow(k, i > 4 ? 4 : i) + + return unit === '' ? `${formattedValue}` : `${formattedValue.toFixed(1)}${unit}` } diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 49c683f70b236..98d1e3316d8d1 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -1,6 +1,16 @@ import { useQueryClient } from '@tanstack/react-query' import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Trash, Users, X } from 'lucide-react' +import { + ArrowDown, + ArrowUp, + HelpCircle, + Loader2, + RefreshCw, + Search, + Trash, + Users, + X, +} from 'lucide-react' import { UIEvent, useEffect, useMemo, useRef, useState } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import { toast } from 'sonner' @@ -16,6 +26,7 @@ import { authKeys } from 'data/auth/keys' import { useUserDeleteMutation } from 'data/auth/user-delete-mutation' import { useUsersCountQuery } from 'data/auth/users-count-query' import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' +import { THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -40,6 +51,9 @@ import { SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -93,6 +107,9 @@ export const UsersV2 = () => { const [showDeleteModal, setShowDeleteModal] = useState(false) const [isDeletingUsers, setIsDeletingUsers] = useState(false) + const [forceExactCount, setForceExactCount] = useState(false) + const [showFetchExactCountModal, setShowFetchExactCountModal] = useState(false) + const [ columnConfiguration, setColumnConfiguration, @@ -133,21 +150,32 @@ export const UsersV2 = () => { } ) - const { data: countData, refetch: refetchCount } = useUsersCountQuery({ + const { + data: countData, + refetch: refetchCount, + isLoading: isLoadingCount, + } = useUsersCountQuery({ projectRef, connectionString: project?.connectionString, keywords: filterKeywords, filter: filter === 'all' ? undefined : filter, providers: selectedProviders, + forceExactCount, }) const { mutateAsync: deleteUser } = useUserDeleteMutation() - const totalUsers = countData ?? 0 + const totalUsers = countData?.count ?? 0 const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages]) // [Joshen] Only relevant for when selecting one user only const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0]) + const formatEstimatedCount = (count: number) => { + if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M` + if (count >= 1e3) return `${(count / 1e3).toFixed(1)}K` + return count.toString() + } + const handleScroll = (event: UIEvent) => { const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -576,7 +604,43 @@ export const UsersV2 = () => {
- {isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`} +
+ {isLoadingCount ? ( + 'Loading user count...' + ) : ( + <> + + Total:{' '} + {countData?.is_estimate + ? formatEstimatedCount(totalUsers) + : totalUsers.toLocaleString()}{' '} + user{totalUsers !== 1 ? 's' : ''} + {countData?.is_estimate && ' (estimated)'} + + {countData?.is_estimate && ( + + +
{(isLoading || isRefetching || isFetchingNextPage) && ( Loading... @@ -622,6 +686,23 @@ export const UsersV2 = () => { setSelectedUserToDelete(undefined) }} /> + + setShowFetchExactCountModal(false)} + onConfirm={() => { + setForceExactCount(true) + setShowFetchExactCountModal(false) + }} + > +

+ Your project has more than {THRESHOLD_COUNT.toLocaleString()} users, and fetching the + exact count may cause performance issues on your database. +

+
) } diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts index 725370b157265..de6e3852ea7ee 100644 --- a/apps/studio/data/auth/keys.ts +++ b/apps/studio/data/auth/keys.ts @@ -30,6 +30,7 @@ export const authKeys = { keywords: string | undefined filter: string | undefined providers: string[] | undefined + forceExactCount?: boolean } ) => ['projects', projectRef, 'users-count', ...(params ? [params].filter(Boolean) : [])] as const, diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts index d0d1d47db8532..0f0c7371ec6f8 100644 --- a/apps/studio/data/auth/users-count-query.ts +++ b/apps/studio/data/auth/users-count-query.ts @@ -1,8 +1,9 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { useQuery, type UseQueryOptions } from '@tanstack/react-query' -import { executeSql, ExecuteSqlError } from 'data/sql/execute-sql-query' +import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query' +import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query' import { authKeys } from './keys' -import { Filter } from './users-infinite-query' +import { type Filter } from './users-infinite-query' type UsersCountVariables = { projectRef?: string @@ -10,21 +11,25 @@ type UsersCountVariables = { keywords?: string filter?: Filter providers?: string[] + forceExactCount?: boolean } const getUsersCountSql = ({ filter, keywords, providers, + forceExactCount = false, }: { filter?: Filter keywords?: string providers?: string[] + forceExactCount?: boolean }) => { const hasValidKeywords = keywords && keywords !== '' const conditions: string[] = [] const baseQueryCount = `select count(*) from auth.users` + const baseQuerySelect = `select * from auth.users` if (hasValidKeywords) { // [Joshen] Escape single quotes properly @@ -57,15 +62,50 @@ const getUsersCountSql = ({ } const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') - - return `${baseQueryCount}${conditions.length > 0 ? ` where ${combinedConditions}` : ''};` + const whereClause = conditions.length > 0 ? ` where ${combinedConditions}` : '' + + if (forceExactCount) { + return `select (${baseQueryCount}${whereClause}), false as is_estimate;` + } else { + const selectBaseSql = `${baseQuerySelect}${whereClause}` + const countBaseSql = `${baseQueryCount}${whereClause}` + + const escapedSelectSql = selectBaseSql.replaceAll("'", "''") + + const sql = ` +${COUNT_ESTIMATE_SQL} + +with approximation as ( + select reltuples as estimate + from pg_class + where oid = 'auth.users'::regclass +) +select + case + when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int + when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'} + else (${countBaseSql}) + end as count, + estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate +from approximation; +`.trim() + + return sql + } } export async function getUsersCount( - { projectRef, connectionString, keywords, filter, providers }: UsersCountVariables, + { + projectRef, + connectionString, + keywords, + filter, + providers, + forceExactCount, + }: UsersCountVariables, signal?: AbortSignal ) { - const sql = getUsersCountSql({ filter, keywords, providers }) + const sql = getUsersCountSql({ filter, keywords, providers, forceExactCount }) const { result } = await executeSql( { @@ -78,25 +118,51 @@ export async function getUsersCount( ) const count = result?.[0]?.count + const isEstimate = result?.[0]?.is_estimate if (typeof count !== 'number') { throw new Error('Error fetching users count') } - return count + return { + count, + is_estimate: isEstimate ?? true, + } } export type UsersCountData = Awaited> export type UsersCountError = ExecuteSqlError export const useUsersCountQuery = ( - { projectRef, connectionString, keywords, filter, providers }: UsersCountVariables, + { + projectRef, + connectionString, + keywords, + filter, + providers, + forceExactCount, + }: UsersCountVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - authKeys.usersCount(projectRef, { keywords, filter, providers }), + authKeys.usersCount(projectRef, { + keywords, + filter, + providers, + forceExactCount, + }), ({ signal }) => - getUsersCount({ projectRef, connectionString, keywords, filter, providers }, signal), + getUsersCount( + { + projectRef, + connectionString, + keywords, + filter, + providers, + forceExactCount, + }, + signal + ), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index 3823cdc514248..acfede4a8caa1 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -16,7 +16,7 @@ type GetTableRowsCountArgs = { } export const THRESHOLD_COUNT = 50000 -const COUNT_ESTIMATE_SQL = /* SQL */ ` +export const COUNT_ESTIMATE_SQL = /* SQL */ ` CREATE OR REPLACE FUNCTION pg_temp.count_estimate( query text ) RETURNS integer LANGUAGE plpgsql AS $$ From 229f5560a67e1aa61585fac03b8d7c779930b394 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 12 Sep 2025 01:50:02 -0400 Subject: [PATCH 2/9] fix: prevent crash on _ page when user has no organizations (#38640) * fix: prevent crash on _ page when user has no organizations * Nit refactors to make wild card pages consistent + fix loading state of org wildcard route * Smol fix --------- Co-authored-by: Joshen Lim --- .../Home/ProjectList/EmptyStates.tsx | 36 +++++ apps/studio/next-env.d.ts | 1 - apps/studio/pages/org/_/[[...routeSlug]].tsx | 135 +++++++----------- .../pages/project/_/[[...routeSlug]].tsx | 99 +++++-------- 4 files changed, 123 insertions(+), 148 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index d09575245629d..f2dabecd8bbe0 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -2,6 +2,7 @@ import { Plus } from 'lucide-react' import Link from 'next/link' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { BASE_PATH } from 'lib/constants' import { Button, Card, @@ -16,6 +17,23 @@ import { } from 'ui' import { ShimmeringCard } from './ShimmeringCard' +export const Header = () => { + return ( +
+
+ + Supabase + +
+
+ ) +} + export const NoFilterResults = ({ filterStatus, resetFilterStatus, @@ -122,3 +140,21 @@ export const NoProjectsState = ({ slug }: { slug: string }) => {
) } + +export const NoOrganizationsState = () => { + return ( +
+
+

You are not part of any organizations yet

+

+ Create your first organization to get started with Supabase +

+
+
+ +
+
+ ) +} diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts index 254b73c165d90..52e831b434248 100644 --- a/apps/studio/next-env.d.ts +++ b/apps/studio/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/studio/pages/org/_/[[...routeSlug]].tsx b/apps/studio/pages/org/_/[[...routeSlug]].tsx index db3226c6804f7..c89df6107e74e 100644 --- a/apps/studio/pages/org/_/[[...routeSlug]].tsx +++ b/apps/studio/pages/org/_/[[...routeSlug]].tsx @@ -1,31 +1,17 @@ -import { Plus } from 'lucide-react' import { NextPage } from 'next' -import Link from 'next/link' import { useRouter } from 'next/router' -import { ShimmeringCard } from 'components/interfaces/Home/ProjectList/ShimmeringCard' +import { + Header, + LoadingCardView, + NoOrganizationsState, +} from 'components/interfaces/Home/ProjectList/EmptyStates' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import CardButton from 'components/ui/CardButton' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { withAuth } from 'hooks/misc/withAuth' -import { BASE_PATH } from 'lib/constants' -import { Button, cn } from 'ui' - -const Header = () => { - return ( -
-
- - Supabase - -
-
- ) -} +import { cn } from 'ui' // [Joshen] Thinking we can deprecate this page in favor of /organizations const GenericOrganizationPage: NextPage = () => { @@ -52,70 +38,51 @@ const GenericOrganizationPage: NextPage = () => { return ( <>
-
-

- Select an organization to continue -

-
-
- {isLoading ? ( -
    + + +
    +
    + {isLoading ? ( + + ) : organizations?.length === 0 ? ( + + ) : ( +
      + {organizations?.map((organization) => ( +
    • + + {organization.name} +
    + } + footer={ +
    + + {organization.slug} + +
    + } + /> + + ))} +
)} - > - - - - ) : organizations?.length === 0 ? ( -
-
-

You are not part of any organizations yet

-

- Get started by creating a new organization. -

-
-
- -
- ) : ( -
    - {organizations?.map((organization) => ( -
  • - - {organization.name} -
- } - footer={ -
- - {organization.slug} - -
- } - /> - - ))} - - )} -
-
- + + + + ) } diff --git a/apps/studio/pages/project/_/[[...routeSlug]].tsx b/apps/studio/pages/project/_/[[...routeSlug]].tsx index b4c1e96940a32..30bdfef0c6ef1 100644 --- a/apps/studio/pages/project/_/[[...routeSlug]].tsx +++ b/apps/studio/pages/project/_/[[...routeSlug]].tsx @@ -1,10 +1,14 @@ import { AlertTriangleIcon } from 'lucide-react' import { NextPage } from 'next' -import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'common' +import { + Header, + LoadingCardView, + NoOrganizationsState, +} from 'components/interfaces/Home/ProjectList/EmptyStates' import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from 'components/interfaces/HomePageActions' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' @@ -12,7 +16,6 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' -import { BASE_PATH } from 'lib/constants' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -23,44 +26,6 @@ import { SelectTrigger_Shadcn_, SelectValue_Shadcn_, } from 'ui' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' - -const Header = () => { - return ( -
-
- - Supabase - -
-
- ) -} - -const OrganizationLoadingState = () => { - return ( - <> - - - - - ) -} - -const OrganizationErrorState = () => { - return ( - - - Failed to load your Supabase organizations - Try refreshing the page - - ) -} // [Joshen] I'd say we don't do route validation here, this page will act more // like a proxy to the project specific pages, and we let those pages handle @@ -108,7 +73,7 @@ const GenericProjectPage: NextPage = () => { if (!!lastVisitedOrgSlug) { setSlug(lastVisitedOrgSlug) } else if (isSuccessOrganizations) { - setSlug(organizations[0].slug) + setSlug(organizations[0]?.slug) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastVisitedOrgSlug, isSuccessOrganizations]) @@ -118,31 +83,39 @@ const GenericProjectPage: NextPage = () => {
- -
- - -
-

Organization:

- -
-
- - {organizations.map((org) => ( - - {org.name} - - ))} - -
- -
-
+ {organizations.length > 0 && ( + +
+ + +
+

Organization:

+ +
+
+ + {organizations.map((org) => ( + + {org.name} + + ))} + +
+ +
+
+ )} {isLoadingOrganizations ? ( - + ) : isErrorOrganizations ? ( - + + + Failed to load your Supabase organizations + Try refreshing the page + + ) : organizations.length === 0 ? ( + ) : !!selectedOrganization ? ( Date: Fri, 12 Sep 2025 07:56:24 +0200 Subject: [PATCH 3/9] chore: add show testimonial as an enabled feature (#38651) --- apps/studio/components/layouts/SignInLayout/SignInLayout.tsx | 5 ++++- packages/common/enabled-features/enabled-features.json | 1 + .../common/enabled-features/enabled-features.schema.json | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx index 5a97ecb7b262c..6c21c1bc7f4c0 100644 --- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -7,6 +7,7 @@ import { PropsWithChildren, useEffect, useState } from 'react' import { useFlag } from 'common' import { DocsButton } from 'components/ui/DocsButton' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' import { tweets } from 'shared-data' @@ -30,6 +31,8 @@ const SignInLayout = ({ const { resolvedTheme } = useTheme() const ongoingIncident = useFlag('ongoingIncident') + const showTestimonial = useIsFeatureEnabled('dashboard_auth:show_testimonial') + // This useEffect redirects the user to MFA if they're already halfway signed in useEffect(() => { auth @@ -148,7 +151,7 @@ const SignInLayout = ({