mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { useBreakpoint } from 'common'
|
|
import { useMemo } from 'react'
|
|
import { cn } from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { BILLING_BREAKDOWN_METRICS } from '../BillingSettings/BillingBreakdown/BillingBreakdown.constants'
|
|
import { BillingMetric } from '../BillingSettings/BillingBreakdown/BillingMetric'
|
|
import { ComputeMetric } from '../BillingSettings/BillingBreakdown/ComputeMetric'
|
|
import { SectionContent } from './SectionContent'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import {
|
|
ComputeUsageMetric,
|
|
computeUsageMetricLabel,
|
|
PricingMetric,
|
|
} from '@/data/analytics/org-daily-stats-query'
|
|
import type { OrgSubscription } from '@/data/subscriptions/types'
|
|
import { useOrgUsageQuery } from '@/data/usage/org-usage-query'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
|
|
export interface ComputeProps {
|
|
orgSlug: string
|
|
projectRef?: string | null
|
|
startDate: string | undefined
|
|
endDate: string | undefined
|
|
subscription: OrgSubscription | undefined
|
|
currentBillingCycleSelected: boolean
|
|
}
|
|
|
|
const METRICS_TO_HIDE_WITH_NO_USAGE: PricingMetric[] = [
|
|
PricingMetric.DISK_IOPS_IO2,
|
|
PricingMetric.DISK_IOPS_GP3,
|
|
PricingMetric.DISK_SIZE_GB_HOURS_GP3,
|
|
PricingMetric.DISK_SIZE_GB_HOURS_IO2,
|
|
PricingMetric.DISK_THROUGHPUT_GP3,
|
|
PricingMetric.LOG_STORAGE,
|
|
PricingMetric.ACTIVE_COMPUTE_HOURS,
|
|
PricingMetric.ETL_PIPELINE,
|
|
PricingMetric.ETL_REPLICATED_DATA,
|
|
PricingMetric.ETL_COPY_BACKFILL_DATA,
|
|
]
|
|
|
|
export const TotalUsage = ({
|
|
orgSlug,
|
|
projectRef,
|
|
subscription,
|
|
startDate,
|
|
endDate,
|
|
currentBillingCycleSelected,
|
|
}: ComputeProps) => {
|
|
const isMobile = useBreakpoint('md')
|
|
const isUsageBillingEnabled = subscription?.usage_billing_enabled
|
|
const { billingAll } = useIsFeatureEnabled(['billing:all'])
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
const hasActiveRestriction = Boolean(org?.restriction_status)
|
|
|
|
const {
|
|
data: usage,
|
|
error: usageError,
|
|
isPending: isLoadingUsage,
|
|
isError: isErrorUsage,
|
|
isSuccess: isSuccessUsage,
|
|
} = useOrgUsageQuery({
|
|
orgSlug,
|
|
projectRef,
|
|
start: !currentBillingCycleSelected && startDate ? new Date(startDate) : undefined,
|
|
end: !currentBillingCycleSelected && endDate ? new Date(endDate) : undefined,
|
|
})
|
|
|
|
// When the user filters by project ref or selects a custom timeframe, we only display usage+project breakdown, but no costs/limits
|
|
const showRelationToSubscription = currentBillingCycleSelected && !projectRef
|
|
|
|
const isOnHigherPlan = ['team', 'enterprise', 'platform'].includes(subscription?.plan.id ?? '')
|
|
|
|
const hasExceededAnyLimits =
|
|
showRelationToSubscription &&
|
|
Boolean(
|
|
usage?.usages.find(
|
|
(usageItem) =>
|
|
// Filter out compute as compute has no quota and is always being charged for
|
|
!usageItem.metric.startsWith('COMPUTE_') &&
|
|
// Log metrics should not trigger the exceeded limits alert for now (soft rollout)
|
|
usageItem.metric !== PricingMetric.LOG_INGESTION &&
|
|
usageItem.metric !== PricingMetric.LOG_QUERYING &&
|
|
!usageItem.unlimited &&
|
|
usageItem.usage > (usageItem?.pricing_free_units ?? 0)
|
|
)
|
|
)
|
|
|
|
const sortedBillingMetrics = useMemo(() => {
|
|
if (!usage) return []
|
|
|
|
const breakdownMetrics = BILLING_BREAKDOWN_METRICS(subscription)
|
|
.filter((metric) => usage.usages.some((usage) => usage.metric === metric.key))
|
|
.filter((metric) => {
|
|
if (!METRICS_TO_HIDE_WITH_NO_USAGE.includes(metric.key as PricingMetric)) return true
|
|
|
|
const metricUsage = usage.usages.find((it) => it.metric === metric.key)
|
|
|
|
return metricUsage && metricUsage.usage > 0
|
|
})
|
|
|
|
const PINNED_METRICS = [PricingMetric.LOG_INGESTION, PricingMetric.LOG_QUERYING]
|
|
|
|
return breakdownMetrics.slice().sort((a, b) => {
|
|
const pinnedIndexA = PINNED_METRICS.indexOf(a.key as PricingMetric)
|
|
const pinnedIndexB = PINNED_METRICS.indexOf(b.key as PricingMetric)
|
|
|
|
if (pinnedIndexA !== -1 || pinnedIndexB !== -1) {
|
|
if (pinnedIndexA === -1) return 1
|
|
if (pinnedIndexB === -1) return -1
|
|
return pinnedIndexA - pinnedIndexB
|
|
}
|
|
|
|
const usageMetaA = usage.usages.find((x) => x.metric === a.key)
|
|
const usageRatioA =
|
|
typeof usageMetaA !== 'number'
|
|
? (usageMetaA?.usage ?? 0) / (usageMetaA?.pricing_free_units ?? 0)
|
|
: 0
|
|
|
|
const usageMetaB = usage.usages.find((x) => x.metric === b.key)
|
|
const usageRatioB =
|
|
typeof usageMetaB !== 'number'
|
|
? (usageMetaB?.usage ?? 0) / (usageMetaB?.pricing_free_units ?? 0)
|
|
: 0
|
|
|
|
return (
|
|
// Sort unavailable features to bottom
|
|
Number(usageMetaB?.available_in_plan) - Number(usageMetaA?.available_in_plan) ||
|
|
// Sort high-usage features to top
|
|
usageRatioB - usageRatioA
|
|
)
|
|
})
|
|
}, [usage, subscription])
|
|
|
|
const computeMetrics = (usage?.usages || [])
|
|
.filter((it) => it.metric.startsWith('COMPUTE'))
|
|
.map((it) => it.metric) as ComputeUsageMetric[]
|
|
|
|
return (
|
|
<div id="summary">
|
|
<SectionContent
|
|
section={{
|
|
name: 'Usage Summary',
|
|
description: isUsageBillingEnabled
|
|
? `Your plan includes a limited amount of usage. If exceeded, you will be charged for the overages. It may take up to 1 hour to refresh.`
|
|
: `Your plan includes a limited amount of usage. If exceeded, you may experience restrictions, as you are currently not billed for overages. It may take up to 1 hour to refresh.`,
|
|
links: billingAll
|
|
? [
|
|
{
|
|
name: 'How billing works',
|
|
url: `${DOCS_URL}/guides/platform/billing-on-supabase`,
|
|
},
|
|
{
|
|
name: 'Supabase Plans',
|
|
url: 'https://supabase.com/pricing',
|
|
},
|
|
]
|
|
: [],
|
|
}}
|
|
>
|
|
{isLoadingUsage && (
|
|
<div className="space-y-2">
|
|
<ShimmeringLoader />
|
|
<ShimmeringLoader className="w-3/4" />
|
|
<ShimmeringLoader className="w-1/2" />
|
|
</div>
|
|
)}
|
|
|
|
{isErrorUsage && <AlertError subject="Failed to retrieve usage data" error={usageError} />}
|
|
|
|
{isSuccessUsage && subscription && (
|
|
<div>
|
|
{showRelationToSubscription && !isOnHigherPlan && !hasActiveRestriction && (
|
|
<p className="text-sm">
|
|
{!hasExceededAnyLimits ? (
|
|
<span>
|
|
You have not exceeded your{' '}
|
|
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
|
|
this billing cycle.
|
|
</span>
|
|
) : hasExceededAnyLimits && subscription?.plan?.id === 'free' ? (
|
|
<span>
|
|
You have exceeded your{' '}
|
|
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
|
|
this billing cycle. Upgrade your plan to continue using Supabase without
|
|
restrictions.
|
|
</span>
|
|
) : hasExceededAnyLimits &&
|
|
subscription?.usage_billing_enabled === false &&
|
|
subscription?.plan?.id === 'pro' ? (
|
|
<span>
|
|
You have exceeded your{' '}
|
|
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
|
|
this billing cycle. Disable your spend cap to continue using Supabase without
|
|
restrictions.
|
|
</span>
|
|
) : hasExceededAnyLimits && subscription?.usage_billing_enabled === true ? (
|
|
<span>
|
|
You have exceeded your{' '}
|
|
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
|
|
this billing cycle and will be charged for over-usage.
|
|
</span>
|
|
) : (
|
|
<span>
|
|
You have not exceeded your{' '}
|
|
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
|
|
this billing cycle.
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 mt-3 gap-px bg-border">
|
|
{sortedBillingMetrics.map((metric, i) => {
|
|
return (
|
|
<div
|
|
key={metric.key}
|
|
className={cn(
|
|
'col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4',
|
|
i % 2 === 0 ? 'md:pr-4' : 'md:pl-4'
|
|
)}
|
|
>
|
|
<BillingMetric
|
|
idx={i}
|
|
slug={orgSlug}
|
|
metric={metric}
|
|
usage={usage}
|
|
subscription={subscription!}
|
|
relativeToSubscription={showRelationToSubscription}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{computeMetrics.map((metric, i) => {
|
|
return (
|
|
<div
|
|
key={metric}
|
|
className={cn('col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4')}
|
|
>
|
|
<ComputeMetric
|
|
slug={orgSlug}
|
|
metric={{
|
|
key: metric,
|
|
name: computeUsageMetricLabel(metric) + ' Compute Hours' || metric,
|
|
units: 'hours',
|
|
anchor: 'compute',
|
|
category: 'Compute',
|
|
unitName: 'GB',
|
|
}}
|
|
relativeToSubscription={showRelationToSubscription}
|
|
usage={usage}
|
|
className={cn(
|
|
(i + sortedBillingMetrics.length) % 2 === 0 ? 'md:pr-4' : 'md:pl-4'
|
|
)}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{!isMobile && (sortedBillingMetrics.length + computeMetrics.length) % 2 === 1 && (
|
|
<div className="col-span-2 md:col-span-1 bg-sidebar" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SectionContent>
|
|
</div>
|
|
)
|
|
}
|