diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index b04bcaa9bea..f893c0ae9f7 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -126,6 +126,8 @@ export const ProjectCard = ({ projectRef={project.ref} cloudProvider={project.cloud_provider} computeSize={getComputeSize(project)} + resourceWarnings={resourceWarnings} + badgeClassName="text-[10px] leading-none tracking-[0.07em]" /> )} {isVercelIntegrated && ( diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 03d4e8530ba..7affa6b144d 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -137,6 +137,7 @@ export const ProjectTableRow = ({ projectRef={project.ref} cloudProvider={project.cloud_provider} computeSize={getComputeSize(project)} + resourceWarnings={resourceWarnings} /> ) : ( – diff --git a/apps/studio/components/interfaces/ProjectHome/TopSection.tsx b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx index 0b102da46aa..85eafe9f610 100644 --- a/apps/studio/components/interfaces/ProjectHome/TopSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx @@ -7,6 +7,7 @@ import { InlineLink } from 'components/ui/InlineLink' import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner' import { useBranchesQuery } from 'data/branches/branches-query' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' @@ -20,6 +21,8 @@ export const TopSection = () => { const isOrioleDb = useIsOrioleDb() const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() + const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug }) + const projectResourceWarnings = resourceWarnings?.find((w) => w.project === project?.ref) const { data: parentProject } = useProjectDetailQuery({ ref: project?.parent_project_ref }) const { data: branches } = useBranchesQuery({ @@ -79,6 +82,7 @@ export const TopSection = () => { slug={organization?.slug} cloudProvider={project?.cloud_provider} computeSize={project?.infra_compute_size} + resourceWarnings={projectResourceWarnings} /> {project?.high_availability && } diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 8fb816cf310..de834e83828 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -17,6 +17,28 @@ const { mockRouter, mockSetSelectedDatabaseId, mockSetMobileMenuOpen } = vi.hois mockSetMobileMenuOpen: vi.fn(), })) +const { + mockAddBanner, + mockDismissBanner, + mockProjectState, + mockResourceWarningsState, + mockBannerDismissedState, +} = vi.hoisted(() => ({ + mockAddBanner: vi.fn(), + mockDismissBanner: vi.fn(), + mockProjectState: { + current: { + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY', + postgrestStatus: 'ONLINE', + infra_compute_size: undefined as string | undefined, + }, + }, + mockResourceWarningsState: { current: undefined as any[] | undefined }, + mockBannerDismissedState: { current: false }, +})) + vi.mock('next/router', () => ({ useRouter: () => mockRouter, })) @@ -47,6 +69,12 @@ vi.mock('common', () => ({ mergeRefs: (..._refs: any[]) => (_value: unknown) => {}, + IS_PLATFORM: false, + LOCAL_STORAGE_KEYS: { + FREE_MICRO_UPGRADE_BANNER_DISMISSED: (ref: string) => + `free-micro-upgrade-banner-dismissed-${ref}`, + }, + isFeatureEnabled: () => false, })) vi.mock('framer-motion', () => ({ @@ -121,6 +149,27 @@ vi.mock('@/hooks/custom-content/useCustomContent', () => ({ useCustomContent: () => ({ appTitle: 'Supabase' }), })) +vi.mock('@/hooks/misc/useLocalStorage', () => ({ + useLocalStorageQuery: () => [mockBannerDismissedState.current, vi.fn()], +})) + +vi.mock('@/components/ui/BannerStack/BannerStackProvider', () => ({ + BANNER_ID: { FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner' }, + useBannerStack: () => ({ + addBanner: mockAddBanner, + dismissBanner: mockDismissBanner, + banners: [], + }), +})) + +vi.mock('@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade', () => ({ + BannerFreeMicroUpgrade: () => null, +})) + +vi.mock('data/usage/resource-warnings-query', () => ({ + useResourceWarningsQuery: () => ({ data: mockResourceWarningsState.current }), +})) + vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => ({ data: { name: 'Organization 1', slug: 'org-1' }, @@ -128,14 +177,7 @@ vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ })) vi.mock('@/hooks/misc/useSelectedProject', () => ({ - useSelectedProjectQuery: () => ({ - data: { - ref: 'default', - name: 'Project 1', - status: 'ACTIVE_HEALTHY', - postgrestStatus: 'ONLINE', - }, - }), + useSelectedProjectQuery: () => ({ data: mockProjectState.current }), })) vi.mock('@/hooks/misc/withAuth', () => ({ @@ -160,6 +202,15 @@ vi.mock('@/state/database-selector', () => ({ }), })) +const renderLayout = () => + render( + + +
+ + + ) + describe('ProjectLayout title', () => { beforeEach(() => { mockRouter.pathname = '/project/[ref]/observability/query-performance' @@ -210,3 +261,91 @@ describe('ProjectLayout title', () => { }) }) }) + +describe('FREE_MICRO_UPGRADE banner', () => { + beforeEach(() => { + mockRouter.pathname = '/project/[ref]' + mockRouter.asPath = '/project/default' + mockProjectState.current = { + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY', + postgrestStatus: 'ONLINE', + infra_compute_size: 'nano', + } + mockResourceWarningsState.current = [ + { + project: 'default', + cpu_exhaustion: true, + memory_and_swap_exhaustion: false, + disk_space_exhaustion: false, + }, + ] + mockBannerDismissedState.current = false + }) + + afterEach(() => { + vi.clearAllMocks() + mockRouter.pathname = '/project/[ref]/observability/query-performance' + mockRouter.asPath = '/project/default/observability/query-performance' + mockProjectState.current = { + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY', + postgrestStatus: 'ONLINE', + infra_compute_size: undefined, + } + mockResourceWarningsState.current = undefined + mockBannerDismissedState.current = false + }) + + it('calls addBanner when project is nano and compute is near exhaustion', async () => { + renderLayout() + + await waitFor(() => { + expect(mockAddBanner).toHaveBeenCalledWith( + expect.objectContaining({ id: 'free-micro-upgrade-banner' }) + ) + }) + }) + + it('calls dismissBanner when banner was previously dismissed', async () => { + mockBannerDismissedState.current = true + + renderLayout() + + await waitFor(() => { + expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') + }) + expect(mockAddBanner).not.toHaveBeenCalled() + }) + + it('calls dismissBanner when compute warnings are cleared', async () => { + mockResourceWarningsState.current = [ + { + project: 'default', + cpu_exhaustion: false, + memory_and_swap_exhaustion: false, + disk_space_exhaustion: false, + }, + ] + + renderLayout() + + await waitFor(() => { + expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') + }) + expect(mockAddBanner).not.toHaveBeenCalled() + }) + + it('calls dismissBanner when project is not nano compute', async () => { + mockProjectState.current = { ...mockProjectState.current, infra_compute_size: 'micro' } + + renderLayout() + + await waitFor(() => { + expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') + }) + expect(mockAddBanner).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index eff94d559a0..535bbcc1736 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,4 +1,5 @@ -import { mergeRefs, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' +import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query' import { AnimatePresence, motion } from 'framer-motion' import Head from 'next/head' import { useRouter } from 'next/router' @@ -38,8 +39,11 @@ import { RestoringState } from './RestoringState' import { UpgradingState } from './UpgradingState' import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade' +import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { withAuth } from '@/hooks/misc/withAuth' @@ -110,6 +114,24 @@ export const ProjectLayout = forwardRef w.project === selectedProject?.ref + ) + const isComputeNearExhaustion = + !!projectResourceWarnings?.cpu_exhaustion || + !!projectResourceWarnings?.memory_and_swap_exhaustion || + !!projectResourceWarnings?.disk_space_exhaustion || + !!projectResourceWarnings?.disk_io_exhaustion + const isNanoCompute = selectedProject?.infra_compute_size === 'nano' + const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion + const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''), + false + ) const { showSidebar } = useAppStateSnapshot() const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet() @@ -153,6 +175,28 @@ export const ProjectLayout = forwardRef { + if (!selectedProject?.ref) return + const isProjectHomepage = router.pathname === '/project/[ref]' + if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) { + addBanner({ + id: BANNER_ID.FREE_MICRO_UPGRADE, + isDismissed: false, + content: , + priority: 2, + }) + } else { + dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE) + } + }, [ + router.pathname, + selectedProject?.ref, + showUpgradeBanner, + isFreeMicroUpgradeBannerDismissed, + addBanner, + dismissBanner, + ]) + useLayoutEffect(() => { const unregister = registerOpenMenu(() => { setMobileSheetContent( diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx index 86b27f9a729..59be47502c9 100644 --- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx +++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx @@ -1,10 +1,11 @@ import { createContext, useCallback, useContext, useState } from 'react' -const BANNER_ID = { +export const BANNER_ID = { METRICS_API: 'metrics-api-banner', INDEX_ADVISOR: 'index-advisor-banner', TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner', RLS_EVENT_TRIGGER: 'rls-event-trigger-banner', + FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner', } as const export type BannerId = (typeof BANNER_ID)[keyof typeof BANNER_ID] diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade.tsx new file mode 100644 index 00000000000..2c804072c5d --- /dev/null +++ b/apps/studio/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade.tsx @@ -0,0 +1,69 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { useParams } from 'common/hooks' +import { ChevronsUpAnimated } from 'components/ui/ComputeBadgeWrapper' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useTrack } from 'lib/telemetry/track' +import Link from 'next/link' +import { Button } from 'ui' +import { ComputeBadge } from 'ui-patterns/ComputeBadge' + +import { BannerCard } from '../BannerCard' +import { BANNER_ID, useBannerStack } from '../BannerStackProvider' + +export const BannerFreeMicroUpgrade = () => { + const { ref } = useParams() + const track = useTrack() + const { dismissBanner } = useBannerStack() + const [, setIsDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(ref ?? ''), + false + ) + + return ( + { + setIsDismissed(true) + dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE) + track('free_micro_upgrade_banner_dismissed') + }} + > +
+
+
+
+ } + className="text-brand-600 border-brand-500 bg-brand/10 gap-1" + /> + +
+
+
+
+

Project low on resources

+

+ Your Nano compute is approaching its limits. Your Pro plan includes a free upgrade to + Micro — double the memory at no extra cost. +

+
+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index c620cece43f..7f0ba8677e3 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -1,16 +1,40 @@ +import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { ProjectDetail } from 'data/projects/project-detail-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { ProjectAddonVariantMeta } from 'data/subscriptions/types' +import { ResourceWarning } from 'data/usage/resource-warnings-query' +import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' +import { INSTANCE_MICRO_SPECS } from 'lib/constants' import Link from 'next/link' import { useState } from 'react' -import { Button, HoverCard, HoverCardContent, HoverCardTrigger, Separator } from 'ui' +import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger, Separator } from 'ui' import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' -import { ProjectDetail } from '@/data/projects/project-detail-query' -import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' -import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' -import { ProjectAddonVariantMeta } from '@/data/subscriptions/types' -import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils' -import { INSTANCE_MICRO_SPECS } from '@/lib/constants' +export const ChevronsUpAnimated = () => ( + + + + +) const Row = ({ label, stat }: { label: string; stat: React.ReactNode | string }) => { return ( @@ -26,6 +50,8 @@ interface ComputeBadgeWrapperProps { projectRef?: string cloudProvider?: string computeSize?: ProjectDetail['infra_compute_size'] + resourceWarnings?: ResourceWarning + badgeClassName?: string } export const ComputeBadgeWrapper = ({ @@ -33,6 +59,8 @@ export const ComputeBadgeWrapper = ({ projectRef, cloudProvider, computeSize, + resourceWarnings, + badgeClassName, }: ComputeBadgeWrapperProps) => { // handles the state of the hover card // once open it will fetch the addons @@ -71,6 +99,12 @@ export const ComputeBadgeWrapper = ({ ) const isEligibleForFreeUpgrade = data?.plan.id !== 'free' && computeSize === 'nano' + const isComputeNearExhaustion = + !!resourceWarnings?.cpu_exhaustion || + !!resourceWarnings?.memory_and_swap_exhaustion || + !!resourceWarnings?.disk_space_exhaustion || + !!resourceWarnings?.disk_io_exhaustion + const showUpgradeGlow = isEligibleForFreeUpgrade && isComputeNearExhaustion const isLoading = isLoadingAddons || isLoadingSubscriptions @@ -79,8 +113,25 @@ export const ComputeBadgeWrapper = ({ return ( setOpenState(!open)} openDelay={280}> e.stopPropagation()}> -
- +
+
+ } + className={cn( + showUpgradeGlow && 'text-brand-600 border-brand-500 bg-brand/10 gap-1', + badgeClassName + )} + /> + {showUpgradeGlow && ( + + )} +
`table-editor-queue-operations-banner-dismissed-${ref}`, + FREE_MICRO_UPGRADE_BANNER_DISMISSED: (ref: string) => + `free-micro-upgrade-banner-dismissed-${ref}`, + PRIVACY_NOTICE_ACKNOWLEDGED: 'privacy-notice-acknowledged-2026-03', /** * COMMON diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 8f8914b8915..2b46ced7ad9 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2930,6 +2930,44 @@ export interface LogExplorerQueryRunButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked an upgrade CTA inside the compute badge hover card. + * + * @group Events + * @source studio + */ +export interface ComputeBadgeUpgradeClickedEvent { + action: 'compute_badge_upgrade_clicked' + properties: { + computeSize: string + planId: string + upgradeType: 'pro_upgrade' | 'free_micro_upgrade' | 'compute_upgrade' + } + groups: TelemetryGroups +} + +/** + * User dismissed the free Micro upgrade banner. + * + * @group Events + * @source studio + */ +export interface FreeMicroUpgradeBannerDismissedEvent { + action: 'free_micro_upgrade_banner_dismissed' + groups: TelemetryGroups +} + +/** + * User clicked the CTA on the free Micro upgrade banner. + * + * @group Events + * @source studio + */ +export interface FreeMicroUpgradeBannerCtaClickedEvent { + action: 'free_micro_upgrade_banner_cta_clicked' + groups: TelemetryGroups +} + /** * User clicked the Navigate action in the storage explorer header. * @@ -3119,3 +3157,6 @@ export type TelemetryEvent = | OrgSubmenuOpenedEvent | OrgMenuBackClickedEvent | OrgMenuItemClickedEvent + | ComputeBadgeUpgradeClickedEvent + | FreeMicroUpgradeBannerDismissedEvent + | FreeMicroUpgradeBannerCtaClickedEvent diff --git a/packages/ui-patterns/src/ComputeBadge/index.tsx b/packages/ui-patterns/src/ComputeBadge/index.tsx index bb25feb2eca..662fb1b6ba2 100644 --- a/packages/ui-patterns/src/ComputeBadge/index.tsx +++ b/packages/ui-patterns/src/ComputeBadge/index.tsx @@ -1,5 +1,5 @@ import { components } from 'api-types' -import type { HTMLAttributes } from 'react' +import type { HTMLAttributes, ReactNode } from 'react' import { cn } from 'ui' interface ComputeBadgeProps extends HTMLAttributes { @@ -7,9 +7,10 @@ interface ComputeBadgeProps extends HTMLAttributes { | components['schemas']['ProjectDetailResponse']['infra_compute_size'] | '>16XL' | undefined + icon?: ReactNode } -export function ComputeBadge({ infraComputeSize, className, ...props }: ComputeBadgeProps) { +export function ComputeBadge({ infraComputeSize, className, icon, ...props }: ComputeBadgeProps) { const smallCompute = infraComputeSize?.toLocaleLowerCase() === 'micro' || infraComputeSize?.toLocaleLowerCase() === 'nano' @@ -20,7 +21,7 @@ export function ComputeBadge({ infraComputeSize, className, ...props }: ComputeB
{infraComputeSize} + {icon && {icon}}
) }