mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat(studio): nano exhaustion indicators (#44395)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This PR introduces some visual indicators when a Nano project is exhausting resources on Pro plus plans. The indicator tells the user a free upgrade to Micro is available via a flashing Nano badge that guides them to Upgrade and a global banner that notifies them resources are near exhaustion, please upgrade for free. | Project List | Project View | |--------|--------| | <img width="412" height="223" alt="Screenshot 2026-03-31 at 16 11 19" src="https://github.com/user-attachments/assets/0d0e7727-e4eb-4ade-9024-528a2501596c" /> | <img width="1256" height="618" alt="Screenshot 2026-03-31 at 16 11 10" src="https://github.com/user-attachments/assets/b664e020-2b18-4842-8e64-4e49fac69eef" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Free Micro Upgrade banner appears for nano-tier projects near compute exhaustion with animated visuals and an “Upgrade for free” CTA. * Compute badge highlights near-exhaustion with glow/shimmer and an animated upgrade indicator; optional icon support added. * Per-project banner dismissal is saved locally; dismissed banners remain hidden. * **Tests** * Added tests covering banner display and dismissal scenarios. * **Chores** * Added local-storage keys and telemetry events for banner and upgrade interactions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
c3af2858bd
commit
908da85225
12 files changed
+398
-23
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -137,6 +137,7 @@ export const ProjectTableRow = ({
|
||||
projectRef={project.ref}
|
||||
cloudProvider={project.cloud_provider}
|
||||
computeSize={getComputeSize(project)}
|
||||
resourceWarnings={resourceWarnings}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted">–</span>
|
||||
|
||||
@@ -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 && <HighAvailabilityBadge />}
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<MobileSheetProvider>
|
||||
<ProjectLayout product="Database" isBlocking={false}>
|
||||
<div />
|
||||
</ProjectLayout>
|
||||
</MobileSheetProvider>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement, PropsWithChildren<Projec
|
||||
const router = useRouter()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const { data: selectedProject } = useSelectedProjectQuery()
|
||||
const { addBanner, dismissBanner } = useBannerStack()
|
||||
const { data: resourceWarnings } = useResourceWarningsQuery({
|
||||
slug: selectedOrganization?.slug,
|
||||
})
|
||||
const projectResourceWarnings = resourceWarnings?.find(
|
||||
(w) => 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<HTMLDivElement, PropsWithChildren<Projec
|
||||
router.pathname.includes('/project/[ref]/logs')
|
||||
const showPausedState = isPaused && !ignorePausedState
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject?.ref) return
|
||||
const isProjectHomepage = router.pathname === '/project/[ref]'
|
||||
if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
|
||||
addBanner({
|
||||
id: BANNER_ID.FREE_MICRO_UPGRADE,
|
||||
isDismissed: false,
|
||||
content: <BannerFreeMicroUpgrade />,
|
||||
priority: 2,
|
||||
})
|
||||
} else {
|
||||
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
|
||||
}
|
||||
}, [
|
||||
router.pathname,
|
||||
selectedProject?.ref,
|
||||
showUpgradeBanner,
|
||||
isFreeMicroUpgradeBannerDismissed,
|
||||
addBanner,
|
||||
dismissBanner,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const unregister = registerOpenMenu(() => {
|
||||
setMobileSheetContent(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (
|
||||
<BannerCard
|
||||
onDismiss={() => {
|
||||
setIsDismissed(true)
|
||||
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
|
||||
track('free_micro_upgrade_banner_dismissed')
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<div className="animate-badge-pulse">
|
||||
<div className="relative inline-flex overflow-hidden rounded">
|
||||
<ComputeBadge
|
||||
infraComputeSize="nano"
|
||||
icon={<ChevronsUpAnimated />}
|
||||
className="text-brand-600 border-brand-500 bg-brand/10 gap-1"
|
||||
/>
|
||||
<span className="animate-badge-shimmer pointer-events-none absolute inset-0 bg-gradient-to-br from-transparent via-brand/20 to-transparent blur-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 mb-2">
|
||||
<p className="text-sm font-medium">Project low on resources</p>
|
||||
<p className="text-xs text-foreground-lighter text-balance">
|
||||
Your Nano compute is approaching its limits. Your Pro plan includes a free upgrade to
|
||||
Micro — double the memory at no extra cost.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
asChild
|
||||
onClick={() => {
|
||||
setIsDismissed(true)
|
||||
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
|
||||
track('free_micro_upgrade_banner_cta_clicked')
|
||||
}}
|
||||
>
|
||||
<Link href={`/project/${ref}/settings/compute-and-disk?upgrade=micro`}>
|
||||
Upgrade for free
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BannerCard>
|
||||
)
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<svg
|
||||
width={10}
|
||||
height={10}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline
|
||||
points="17 18 12 13 7 18"
|
||||
className="animate-chevron-up"
|
||||
style={{ animationDelay: '0s' }}
|
||||
/>
|
||||
<polyline
|
||||
points="17 11 12 6 7 11"
|
||||
className="animate-chevron-up"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
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 (
|
||||
<HoverCard onOpenChange={() => setOpenState(!open)} openDelay={280}>
|
||||
<HoverCardTrigger asChild className="group" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center">
|
||||
<ComputeBadge infraComputeSize={computeSize} />
|
||||
<div className={cn('flex items-center', showUpgradeGlow && 'animate-badge-pulse')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
showUpgradeGlow && 'relative inline-flex overflow-hidden rounded'
|
||||
)}
|
||||
>
|
||||
<ComputeBadge
|
||||
infraComputeSize={computeSize}
|
||||
icon={showUpgradeGlow && <ChevronsUpAnimated />}
|
||||
className={cn(
|
||||
showUpgradeGlow && 'text-brand-600 border-brand-500 bg-brand/10 gap-1',
|
||||
badgeClassName
|
||||
)}
|
||||
/>
|
||||
{showUpgradeGlow && (
|
||||
<span className="animate-badge-shimmer pointer-events-none absolute inset-0 bg-gradient-to-br from-transparent via-brand/20 to-transparent blur-md" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
|
||||
@@ -80,6 +80,9 @@ module.exports = config({
|
||||
|
||||
animation: {
|
||||
shimmer: 'shimmer 2s infinite linear',
|
||||
'badge-shimmer': 'badge-shimmer 3s ease-in-out infinite',
|
||||
'badge-pulse': 'badge-pulse 3s ease-in-out infinite',
|
||||
'chevron-up': 'chevron-up 2s ease-in-out infinite',
|
||||
sway: 'sway 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
@@ -91,6 +94,21 @@ module.exports = config({
|
||||
'background-position': '1000px 0',
|
||||
},
|
||||
},
|
||||
'badge-shimmer': {
|
||||
'0%': { transform: 'rotate(-45deg) translateX(-100%)', opacity: '0' },
|
||||
'10%': { opacity: '1' },
|
||||
'40%': { opacity: '1' },
|
||||
'50%': { transform: 'rotate(-45deg) translateX(100%)', opacity: '0' },
|
||||
'100%': { transform: 'rotate(-45deg) translateX(100%)', opacity: '0' },
|
||||
},
|
||||
'badge-pulse': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.8' },
|
||||
},
|
||||
'chevron-up': {
|
||||
'0%, 100%': { opacity: '0.2' },
|
||||
'50%': { opacity: '1' },
|
||||
},
|
||||
sway: {
|
||||
'0%, 100%': {
|
||||
transform: 'rotate(-10deg) scale(1.5) translateY(4rem)',
|
||||
|
||||
@@ -116,6 +116,9 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
|
||||
TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED: (ref: string) =>
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
@@ -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<HTMLDivElement> {
|
||||
@@ -7,9 +7,10 @@ interface ComputeBadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
| 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
|
||||
<div
|
||||
className={cn(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md text-center font-mono uppercase',
|
||||
'inline-flex items-center justify-center rounded text-center font-mono uppercase',
|
||||
'whitespace-nowrap font-medium tracking-[0.06em] text-[11px] leading-[1.1] px-[5.5px] py-[3px]',
|
||||
'transition-all',
|
||||
// Variant styles
|
||||
@@ -39,6 +40,7 @@ export function ComputeBadge({ infraComputeSize, className, ...props }: ComputeB
|
||||
{...props}
|
||||
>
|
||||
{infraComputeSize}
|
||||
{icon && <span className="flex items-center">{icon}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user