TotalUsage.tsx254 lines · main
| 1 | import { useBreakpoint } from 'common' |
| 2 | import { useMemo } from 'react' |
| 3 | import { cn } from 'ui' |
| 4 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 5 | |
| 6 | import { BILLING_BREAKDOWN_METRICS } from '../BillingSettings/BillingBreakdown/BillingBreakdown.constants' |
| 7 | import { BillingMetric } from '../BillingSettings/BillingBreakdown/BillingMetric' |
| 8 | import { ComputeMetric } from '../BillingSettings/BillingBreakdown/ComputeMetric' |
| 9 | import { SectionContent } from './SectionContent' |
| 10 | import AlertError from '@/components/ui/AlertError' |
| 11 | import { |
| 12 | ComputeUsageMetric, |
| 13 | computeUsageMetricLabel, |
| 14 | PricingMetric, |
| 15 | } from '@/data/analytics/org-daily-stats-query' |
| 16 | import type { OrgSubscription } from '@/data/subscriptions/types' |
| 17 | import { useOrgUsageQuery } from '@/data/usage/org-usage-query' |
| 18 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 19 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 20 | import { DOCS_URL } from '@/lib/constants' |
| 21 | |
| 22 | export interface ComputeProps { |
| 23 | orgSlug: string |
| 24 | projectRef?: string | null |
| 25 | startDate: string | undefined |
| 26 | endDate: string | undefined |
| 27 | subscription: OrgSubscription | undefined |
| 28 | currentBillingCycleSelected: boolean |
| 29 | } |
| 30 | |
| 31 | const METRICS_TO_HIDE_WITH_NO_USAGE: PricingMetric[] = [ |
| 32 | PricingMetric.DISK_IOPS_IO2, |
| 33 | PricingMetric.DISK_IOPS_GP3, |
| 34 | PricingMetric.DISK_SIZE_GB_HOURS_GP3, |
| 35 | PricingMetric.DISK_SIZE_GB_HOURS_IO2, |
| 36 | PricingMetric.DISK_THROUGHPUT_GP3, |
| 37 | PricingMetric.LOG_INGESTION, |
| 38 | PricingMetric.LOG_STORAGE, |
| 39 | PricingMetric.LOG_QUERYING, |
| 40 | PricingMetric.ACTIVE_COMPUTE_HOURS, |
| 41 | ] |
| 42 | |
| 43 | export const TotalUsage = ({ |
| 44 | orgSlug, |
| 45 | projectRef, |
| 46 | subscription, |
| 47 | startDate, |
| 48 | endDate, |
| 49 | currentBillingCycleSelected, |
| 50 | }: ComputeProps) => { |
| 51 | const isMobile = useBreakpoint('md') |
| 52 | const isUsageBillingEnabled = subscription?.usage_billing_enabled |
| 53 | const { billingAll } = useIsFeatureEnabled(['billing:all']) |
| 54 | const { data: org } = useSelectedOrganizationQuery() |
| 55 | const hasActiveRestriction = Boolean(org?.restriction_status) |
| 56 | |
| 57 | const { |
| 58 | data: usage, |
| 59 | error: usageError, |
| 60 | isPending: isLoadingUsage, |
| 61 | isError: isErrorUsage, |
| 62 | isSuccess: isSuccessUsage, |
| 63 | } = useOrgUsageQuery({ |
| 64 | orgSlug, |
| 65 | projectRef, |
| 66 | start: !currentBillingCycleSelected && startDate ? new Date(startDate) : undefined, |
| 67 | end: !currentBillingCycleSelected && endDate ? new Date(endDate) : undefined, |
| 68 | }) |
| 69 | |
| 70 | // When the user filters by project ref or selects a custom timeframe, we only display usage+project breakdown, but no costs/limits |
| 71 | const showRelationToSubscription = currentBillingCycleSelected && !projectRef |
| 72 | |
| 73 | const isOnHigherPlan = ['team', 'enterprise', 'platform'].includes(subscription?.plan.id ?? '') |
| 74 | |
| 75 | const hasExceededAnyLimits = |
| 76 | showRelationToSubscription && |
| 77 | Boolean( |
| 78 | usage?.usages.find( |
| 79 | (usageItem) => |
| 80 | // Filter out compute as compute has no quota and is always being charged for |
| 81 | !usageItem.metric.startsWith('COMPUTE_') && |
| 82 | !usageItem.unlimited && |
| 83 | usageItem.usage > (usageItem?.pricing_free_units ?? 0) |
| 84 | ) |
| 85 | ) |
| 86 | |
| 87 | const sortedBillingMetrics = useMemo(() => { |
| 88 | if (!usage) return [] |
| 89 | |
| 90 | const breakdownMetrics = BILLING_BREAKDOWN_METRICS.filter((metric) => |
| 91 | usage.usages.some((usage) => usage.metric === metric.key) |
| 92 | ).filter((metric) => { |
| 93 | if (!METRICS_TO_HIDE_WITH_NO_USAGE.includes(metric.key as PricingMetric)) return true |
| 94 | |
| 95 | const metricUsage = usage.usages.find((it) => it.metric === metric.key) |
| 96 | |
| 97 | return metricUsage && metricUsage.usage > 0 |
| 98 | }) |
| 99 | |
| 100 | return breakdownMetrics.slice().sort((a, b) => { |
| 101 | const usageMetaA = usage.usages.find((x) => x.metric === a.key) |
| 102 | const usageRatioA = |
| 103 | typeof usageMetaA !== 'number' |
| 104 | ? (usageMetaA?.usage ?? 0) / (usageMetaA?.pricing_free_units ?? 0) |
| 105 | : 0 |
| 106 | |
| 107 | const usageMetaB = usage.usages.find((x) => x.metric === b.key) |
| 108 | const usageRatioB = |
| 109 | typeof usageMetaB !== 'number' |
| 110 | ? (usageMetaB?.usage ?? 0) / (usageMetaB?.pricing_free_units ?? 0) |
| 111 | : 0 |
| 112 | |
| 113 | return ( |
| 114 | // Sort unavailable features to bottom |
| 115 | Number(usageMetaB?.available_in_plan) - Number(usageMetaA?.available_in_plan) || |
| 116 | // Sort high-usage features to top |
| 117 | usageRatioB - usageRatioA |
| 118 | ) |
| 119 | }) |
| 120 | }, [usage]) |
| 121 | |
| 122 | const computeMetrics = (usage?.usages || []) |
| 123 | .filter((it) => it.metric.startsWith('COMPUTE')) |
| 124 | .map((it) => it.metric) as ComputeUsageMetric[] |
| 125 | |
| 126 | return ( |
| 127 | <div id="summary"> |
| 128 | <SectionContent |
| 129 | section={{ |
| 130 | name: 'Usage Summary', |
| 131 | description: isUsageBillingEnabled |
| 132 | ? `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.` |
| 133 | : `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.`, |
| 134 | links: billingAll |
| 135 | ? [ |
| 136 | { |
| 137 | name: 'How billing works', |
| 138 | url: `${DOCS_URL}/guides/platform/billing-on-briven`, |
| 139 | }, |
| 140 | { |
| 141 | name: 'Briven Plans', |
| 142 | url: 'https://supabase.com/pricing', |
| 143 | }, |
| 144 | ] |
| 145 | : [], |
| 146 | }} |
| 147 | > |
| 148 | {isLoadingUsage && ( |
| 149 | <div className="space-y-2"> |
| 150 | <ShimmeringLoader /> |
| 151 | <ShimmeringLoader className="w-3/4" /> |
| 152 | <ShimmeringLoader className="w-1/2" /> |
| 153 | </div> |
| 154 | )} |
| 155 | |
| 156 | {isErrorUsage && <AlertError subject="Failed to retrieve usage data" error={usageError} />} |
| 157 | |
| 158 | {isSuccessUsage && subscription && ( |
| 159 | <div> |
| 160 | {showRelationToSubscription && !isOnHigherPlan && !hasActiveRestriction && ( |
| 161 | <p className="text-sm"> |
| 162 | {!hasExceededAnyLimits ? ( |
| 163 | <span> |
| 164 | You have not exceeded your{' '} |
| 165 | <span className="font-medium">{subscription?.plan.name}</span> Plan quota in |
| 166 | this billing cycle. |
| 167 | </span> |
| 168 | ) : hasExceededAnyLimits && subscription?.plan?.id === 'free' ? ( |
| 169 | <span> |
| 170 | You have exceeded your{' '} |
| 171 | <span className="font-medium">{subscription?.plan.name}</span> Plan quota in |
| 172 | this billing cycle. Upgrade your plan to continue using Briven without |
| 173 | restrictions. |
| 174 | </span> |
| 175 | ) : hasExceededAnyLimits && |
| 176 | subscription?.usage_billing_enabled === false && |
| 177 | subscription?.plan?.id === 'pro' ? ( |
| 178 | <span> |
| 179 | You have exceeded your{' '} |
| 180 | <span className="font-medium">{subscription?.plan.name}</span> Plan quota in |
| 181 | this billing cycle. Disable your spend cap to continue using Briven without |
| 182 | restrictions. |
| 183 | </span> |
| 184 | ) : hasExceededAnyLimits && subscription?.usage_billing_enabled === true ? ( |
| 185 | <span> |
| 186 | You have exceeded your{' '} |
| 187 | <span className="font-medium">{subscription?.plan.name}</span> Plan quota in |
| 188 | this billing cycle and will be charged for over-usage. |
| 189 | </span> |
| 190 | ) : ( |
| 191 | <span> |
| 192 | You have not exceeded your{' '} |
| 193 | <span className="font-medium">{subscription?.plan.name}</span> Plan quota in |
| 194 | this billing cycle. |
| 195 | </span> |
| 196 | )} |
| 197 | </p> |
| 198 | )} |
| 199 | <div className="grid grid-cols-2 mt-3 gap-px bg-border"> |
| 200 | {sortedBillingMetrics.map((metric, i) => { |
| 201 | return ( |
| 202 | <div |
| 203 | key={metric.key} |
| 204 | className={cn('col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4')} |
| 205 | > |
| 206 | <BillingMetric |
| 207 | idx={i} |
| 208 | slug={orgSlug} |
| 209 | metric={metric} |
| 210 | usage={usage} |
| 211 | subscription={subscription!} |
| 212 | relativeToSubscription={showRelationToSubscription} |
| 213 | className={cn(i % 2 === 0 ? 'md:pr-4' : 'md:pl-4')} |
| 214 | /> |
| 215 | </div> |
| 216 | ) |
| 217 | })} |
| 218 | |
| 219 | {computeMetrics.map((metric, i) => { |
| 220 | return ( |
| 221 | <div |
| 222 | key={metric} |
| 223 | className={cn('col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4')} |
| 224 | > |
| 225 | <ComputeMetric |
| 226 | slug={orgSlug} |
| 227 | metric={{ |
| 228 | key: metric, |
| 229 | name: computeUsageMetricLabel(metric) + ' Compute Hours' || metric, |
| 230 | units: 'hours', |
| 231 | anchor: 'compute', |
| 232 | category: 'Compute', |
| 233 | unitName: 'GB', |
| 234 | }} |
| 235 | relativeToSubscription={showRelationToSubscription} |
| 236 | usage={usage} |
| 237 | className={cn( |
| 238 | (i + sortedBillingMetrics.length) % 2 === 0 ? 'md:pr-4' : 'md:pl-4' |
| 239 | )} |
| 240 | /> |
| 241 | </div> |
| 242 | ) |
| 243 | })} |
| 244 | |
| 245 | {!isMobile && (sortedBillingMetrics.length + computeMetrics.length) % 2 === 1 && ( |
| 246 | <div className="col-span-2 md:col-span-1 bg-sidebar" /> |
| 247 | )} |
| 248 | </div> |
| 249 | </div> |
| 250 | )} |
| 251 | </SectionContent> |
| 252 | </div> |
| 253 | ) |
| 254 | } |