Files
supabase/apps/studio/components/interfaces/DiskManagement/DiskManagement.utils.ts
Jordi Enric 4a7a540268 fix(studio): hide disk IO burst balance charts on 4XL+ compute (#46327)
## Problem

On 4XL and larger compute sizes, Supabase disk IO is sustained rather
than burst-budget based. Baseline equals max, so there is no extra burst
balance to track (the instance can still be throttled if it hits its
configured IOPS or throughput limit, but that is unrelated to a burst
credit pool). The "Disk IO Burst Balance" / "Disk IO % Remaining" /
"Disk IO % Consumed" charts in custom reports are therefore meaningless
on 4XL+, yet they are currently still offered in the picker and rendered
as empty/misleading charts.

Custom reports persist their layout, so a report created when a project
was on a smaller compute and later upgraded to 4XL+ can still contain a
saved burst chart that we need to handle gracefully.

## Fix

- Add a shared \`hasBurstableIO(infra_compute_size)\` helper in
\`DiskManagement.utils.ts\` (replaces the local
\`BURSTABLE_IO_VARIANTS\` set previously defined inline in
\`database-charts.ts\`).
- Filter \`disk_io_budget\` and \`disk_io_consumption\` out of the
custom report chart picker (\`MetricOptions\`) when the project is on a
non-burstable compute size.
- In \`ReportBlock\`, detect saved burst charts on a 4XL+ project and
render a new \`UnavailableChartBlock\` that explains the chart no longer
applies and can be removed.
- The database observability burst balance chart already gated on
\`hasBurstableIO\`; updated to use the shared helper.
- Infrastructure activity page already has equivalent handling via the
dedicated-IO admonition, so no change needed there.

Linear: FDBKPRI-1404

## Test plan

- [ ] On a project below 4XL, the custom report picker still lists "Disk
IO % Remaining" and "Disk IO % Consumed", and they render normally
- [ ] On a 4XL+ project, neither metric appears in the picker
- [ ] Open an existing custom report that contains a saved burst balance
chart on a 4XL+ project, and confirm the placeholder block renders with
a clear explanation and a remove action
- [ ] Database observability page on 4XL+: the burst balance chart
remains hidden, all other charts render

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Disk IO metrics are now filtered based on your project’s compute size
so only relevant charts appear.
* Metrics tied to burstable IO are hidden when the current instance does
not support burstable IO; deprecated metrics remain excluded.
* When a disk IO chart isn’t available for your instance, the UI shows
an explanatory unavailable-chart block with text about burst-balance
limits for very large instances and optional actions to remove the
unavailable chart.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46327?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-26 11:07:38 +02:00

463 lines
15 KiB
TypeScript

import {
COMPUTE_BASELINE_IOPS,
COMPUTE_MAX_IOPS,
computeInstanceAddonVariantIdSchema,
} from 'shared-data'
import {
ComputeInstanceAddonVariantId,
ComputeInstanceSize,
InfraInstanceSize,
} from './DiskManagement.types'
import { DISK_LIMITS, DISK_PRICING, DiskType, PLAN_DETAILS } from './ui/DiskManagement.constants'
import { ProjectDetail } from '@/data/projects/project-detail-query'
import { PlanId, ProjectAddonVariantMeta } from '@/data/subscriptions/types'
import { INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from '@/lib/constants'
// Included disk size only applies to primary, not replicas
export const calculateDiskSizePrice = ({
planId,
oldSize,
oldStorageType,
newSize,
newStorageType,
numReplicas = 0,
}: {
planId: string
oldSize: number
oldStorageType: DiskType
newSize: number
newStorageType: DiskType
numReplicas?: number
}) => {
const oldPricePerGB = DISK_PRICING[oldStorageType]?.storage ?? 0
const newPricePerGB = DISK_PRICING[newStorageType]?.storage ?? 0
const { includedDiskGB } = PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS]
const oldPrice = Math.max(oldSize - includedDiskGB[oldStorageType], 0) * oldPricePerGB
const oldPriceReplica = oldSize * 1.25 * oldPricePerGB
const newPrice = Math.max(newSize - includedDiskGB[newStorageType], 0) * newPricePerGB
const newPriceReplica = newSize * 1.25 * newPricePerGB
return {
oldPrice: (oldPrice + numReplicas * oldPriceReplica).toFixed(2),
newPrice: (newPrice + numReplicas * newPriceReplica).toFixed(2),
}
}
export const calculateComputeSizePrice = ({
availableOptions,
oldComputeSize,
newComputeSize,
plan,
}: {
availableOptions: {
identifier: string
price: number
}[]
oldComputeSize: string
newComputeSize: string
plan: PlanId
}) => {
let _oldComputeSize = oldComputeSize
if (plan !== 'free' && oldComputeSize === 'ci_nano') {
/**
* override the old compute size to micro if the plan is not free
* this is to handle the case in which nano compute is a paid entity
*/
_oldComputeSize = 'ci_micro'
}
const oldPrice = availableOptions?.find((x) => x.identifier === _oldComputeSize)?.price ?? 0
const newPrice = availableOptions?.find((x) => x.identifier === newComputeSize)?.price ?? 0
const oldPriceMonthly = oldPrice * 720
const newPriceMonthly = newPrice * 720
return {
oldPrice: oldPriceMonthly.toFixed(2),
newPrice: newPriceMonthly.toFixed(2),
}
}
// Included IOPS applies to both primary and replicas
export const calculateIOPSPrice = ({
oldStorageType,
oldProvisionedIOPS,
newStorageType,
newProvisionedIOPS,
numReplicas = 0,
}: {
oldStorageType: DiskType
oldProvisionedIOPS: number
newStorageType: DiskType
newProvisionedIOPS: number
numReplicas?: number
}) => {
if (newStorageType === DiskType.GP3) {
const oldChargeableIOPS = Math.max(
0,
oldProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops
)
const newChargeableIOPS = Math.max(
0,
newProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops
)
const oldPrice = oldChargeableIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0)
const newPrice = newChargeableIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0)
return {
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
}
} else {
const oldPrice =
oldStorageType === 'gp3'
? (oldProvisionedIOPS - DISK_LIMITS[oldStorageType].includedIops) *
DISK_PRICING[oldStorageType].iops
: oldProvisionedIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0)
const newPrice = newProvisionedIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0)
return {
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
}
}
}
// This is only applicable for GP3 storage type, no need to consider IO2 at all
// Also assumes that disk size is > 400 GB (separate requirement to update throughput)
// Also, included throughput applies to both primary and replicas
export const calculateThroughputPrice = ({
storageType,
newThroughput,
oldThroughput,
numReplicas = 0,
}: {
storageType: DiskType
newThroughput: number
oldThroughput: number
numReplicas?: number
}) => {
if (storageType === DiskType.GP3 && newThroughput) {
const oldChargeableThroughput = Math.max(
0,
oldThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput
)
const newChargeableThroughput = Math.max(
0,
newThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput
)
const oldPrice = oldChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput
const newPrice = newChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput
return {
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
}
}
return { oldPrice: '0.00', newPrice: '0.00' }
}
export type ComputeAddonVariant = {
identifier: ComputeInstanceAddonVariantId
name: string
price_description: string
price: number
price_interval: 'hourly' | 'monthly'
price_type: string
meta?: ProjectAddonVariantMeta
}
type AvailableAddon = {
type: string
variants: Array<{
identifier: string
name: string
price_description: string
price: number
price_interval: 'hourly' | 'monthly'
price_type: string
meta?: unknown
}>
}
const isProjectAddonVariantMeta = (meta: unknown): meta is ProjectAddonVariantMeta => {
if (typeof meta !== 'object' || meta === null) return false
const obj = meta as Record<string, unknown>
// Validate supported_cloud_providers is an array if present (used at line 200)
if ('supported_cloud_providers' in obj && !Array.isArray(obj.supported_cloud_providers)) {
return false
}
// Check for at least one expected property to ensure it's likely a real ProjectAddonVariantMeta
const hasExpectedProperty =
'cpu_cores' in obj ||
'memory_gb' in obj ||
'cpu_dedicated' in obj ||
'baseline_disk_io_mbs' in obj ||
'max_disk_io_mbs' in obj ||
'connections_direct' in obj ||
'connections_pooler' in obj ||
'backup_duration_days' in obj ||
'supported_cloud_providers' in obj
return hasExpectedProperty
}
export function getAvailableComputeOptions(
availableAddons: AvailableAddon[],
projectCloudProvider?: string
) {
const computeAddon = availableAddons.find((addon) => addon.type === 'compute_instance')
const computeOptions: ComputeAddonVariant[] =
computeAddon?.variants.flatMap((option) => {
const parsedId = computeInstanceAddonVariantIdSchema.safeParse(option.identifier)
if (!parsedId.success) return []
if (projectCloudProvider && isProjectAddonVariantMeta(option.meta)) {
const isSupported =
!option.meta.supported_cloud_providers ||
option.meta.supported_cloud_providers.includes(projectCloudProvider)
if (!isSupported) return []
}
return [
{
...option,
identifier: parsedId.data,
meta: isProjectAddonVariantMeta(option.meta) ? option.meta : undefined,
},
]
}) ?? []
function hasMicroOptionFromApi() {
return (computeAddon?.variants ?? []).some((variant) => variant.identifier === 'ci_micro')
}
// Backwards comp until API is deployed
if (!hasMicroOptionFromApi()) {
// Unshift to push to start of array
computeOptions.unshift({
identifier: 'ci_micro',
name: 'Micro',
price_description: '$0.01344/hour (~$10/month)',
price: 0.01344,
price_interval: 'hourly',
price_type: 'usage',
meta: {
cpu_cores: INSTANCE_MICRO_SPECS.cpu_cores,
cpu_dedicated: INSTANCE_MICRO_SPECS.cpu_dedicated,
memory_gb: INSTANCE_MICRO_SPECS.memory_gb,
baseline_disk_io_mbs: INSTANCE_MICRO_SPECS.baseline_disk_io_mbs,
max_disk_io_mbs: INSTANCE_MICRO_SPECS.max_disk_io_mbs,
connections_direct: INSTANCE_MICRO_SPECS.connections_direct,
connections_pooler: INSTANCE_MICRO_SPECS.connections_pooler,
} as ProjectAddonVariantMeta,
})
}
computeOptions.unshift({
identifier: 'ci_nano',
name: 'Nano',
price_description: '$0/hour (~$0/month)',
price: 0,
price_interval: 'hourly',
price_type: 'usage',
// @ts-ignore API types it as Record<string, never>
meta: {
cpu_cores: INSTANCE_NANO_SPECS.cpu_cores,
cpu_dedicated: INSTANCE_NANO_SPECS.cpu_dedicated,
memory_gb: INSTANCE_NANO_SPECS.memory_gb,
baseline_disk_io_mbs: INSTANCE_NANO_SPECS.baseline_disk_io_mbs,
max_disk_io_mbs: INSTANCE_NANO_SPECS.max_disk_io_mbs,
connections_direct: INSTANCE_NANO_SPECS.connections_direct,
connections_pooler: INSTANCE_NANO_SPECS.connections_pooler,
} as ProjectAddonVariantMeta,
})
return computeOptions
}
export const calculateMaxIopsAllowedForDiskSizeWithGp3 = (totalSize: number) => {
return Math.max(3000, Math.min(500 * totalSize, 16000))
}
export const calculateDiskSizeRequiredForIopsWithGp3 = (iops: number) => {
return Math.max(1, Math.ceil(iops / 500))
}
export const calculateMaxIopsAllowedForDiskSizeWithio2 = (totalSize: number) => {
return Math.min(500 * totalSize, 256000)
}
export const calculateDiskSizeRequiredForIopsWithIo2 = (iops: number) => {
return Math.max(4, Math.ceil(iops / 1000))
}
export const calculateMaxThroughput = (iops: number) => {
return Math.min(0.256 * iops, 1000)
}
export const calculateIopsRequiredForThroughput = (throughput: number) => {
return Math.max(125, Math.ceil(throughput / 0.256))
}
export const calculateBaselineIopsForComputeSize = (computeSize: string): number => {
const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize)
if (!parsed.success) return 0
return COMPUTE_BASELINE_IOPS[parsed.data] ?? 0
}
export const calculateMaxIopsForComputeSize = (computeSize: string): number => {
const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize)
if (!parsed.success) return 0
return COMPUTE_MAX_IOPS[parsed.data] ?? 0
}
export const calculateComputeSizeRequiredForIops = (
iops: number
): ComputeInstanceAddonVariantId | undefined => {
type ComputeSizeMax = { size: ComputeInstanceAddonVariantId; maxIops: number }
const computeSizes: ComputeSizeMax[] = Object.entries(COMPUTE_MAX_IOPS)
.map((entry) => {
const [size, maxIops] = entry
const parsedSize = computeInstanceAddonVariantIdSchema.safeParse(size)
if (!parsedSize.success) return undefined
return { size: parsedSize.data, maxIops: Number(maxIops) }
})
.filter((value): value is ComputeSizeMax => value !== undefined)
.sort((a, b) => a.maxIops - b.maxIops)
for (const { size, maxIops } of computeSizes) {
if (iops <= maxIops) {
return size
}
}
const fallbackSize = computeSizes[computeSizes.length - 1]?.size
if (!fallbackSize) return undefined
return fallbackSize
}
export const calculateDiskSizeRequiredForIops = (provisionedIOPS: number): number | undefined => {
if (!provisionedIOPS) {
console.error('IOPS is required')
return undefined
}
if (isNaN(provisionedIOPS) || provisionedIOPS < 0) {
console.error('IOPS must be a non-negative number')
return undefined
}
if (provisionedIOPS > 256000) {
console.error('Maximum allowed IOPS is 256000')
return undefined
}
return Math.max(1, Math.ceil(provisionedIOPS / 1000))
}
export const formatComputeName = (compute: string) => {
return compute.toUpperCase().replace('CI_', '')
}
const infraToAddonVariant: Record<InfraInstanceSize, ComputeInstanceAddonVariantId> = {
pico: 'ci_nano',
nano: 'ci_nano',
micro: 'ci_micro',
small: 'ci_small',
medium: 'ci_medium',
large: 'ci_large',
xlarge: 'ci_xlarge',
'2xlarge': 'ci_2xlarge',
'4xlarge': 'ci_4xlarge',
'8xlarge': 'ci_8xlarge',
'12xlarge': 'ci_12xlarge',
'16xlarge': 'ci_16xlarge',
'24xlarge': 'ci_24xlarge',
'24xlarge_optimized_memory': 'ci_24xlarge_optimized_memory',
'24xlarge_optimized_cpu': 'ci_24xlarge_optimized_cpu',
'24xlarge_high_memory': 'ci_24xlarge_high_memory',
'48xlarge': 'ci_48xlarge',
'48xlarge_optimized_memory': 'ci_48xlarge_optimized_memory',
'48xlarge_optimized_cpu': 'ci_48xlarge_optimized_cpu',
'48xlarge_high_memory': 'ci_48xlarge_high_memory',
}
const isInfraInstanceSize = (value: string): value is InfraInstanceSize =>
Object.prototype.hasOwnProperty.call(infraToAddonVariant, value)
export const mapComputeSizeNameToAddonVariantId = (
computeSize: ProjectDetail['infra_compute_size']
): ComputeInstanceAddonVariantId => {
const fallback: InfraInstanceSize = 'nano'
const matchedSize = computeSize && isInfraInstanceSize(computeSize) ? computeSize : undefined
const sizeKey = matchedSize ?? fallback
return infraToAddonVariant[sizeKey]
}
// Compute variants below 4XL run on EBS instances that draw on a burst credit
// pool for disk IO, so burst balance metrics only make sense for these. 4XL
// and larger instances have sustained disk IO at their baseline (baseline
// equals max) and don't expose a burst budget.
const BURSTABLE_IO_VARIANTS: ReadonlySet<ComputeInstanceAddonVariantId> = new Set([
'ci_nano',
'ci_micro',
'ci_small',
'ci_medium',
'ci_large',
'ci_xlarge',
'ci_2xlarge',
])
export const hasBurstableIO = (computeSize: ProjectDetail['infra_compute_size']): boolean => {
// Don't fall back to the nano default that mapComputeSizeNameToAddonVariantId
// uses: if compute metadata is missing or unrecognized, treat the project as
// non-burstable so we don't accidentally surface burst-only charts.
if (!computeSize || !isInfraInstanceSize(computeSize)) return false
return BURSTABLE_IO_VARIANTS.has(infraToAddonVariant[computeSize])
}
const addonVariantToComputeSize: Record<ComputeInstanceAddonVariantId, ComputeInstanceSize> = {
ci_nano: 'Nano',
ci_micro: 'Micro',
ci_small: 'Small',
ci_medium: 'Medium',
ci_large: 'Large',
ci_xlarge: 'XL',
ci_2xlarge: '2XL',
ci_4xlarge: '4XL',
ci_8xlarge: '8XL',
ci_12xlarge: '12XL',
ci_16xlarge: '16XL',
ci_24xlarge: '24XL',
ci_24xlarge_optimized_memory: '24XL - Optimized Memory',
ci_24xlarge_optimized_cpu: '24XL - Optimized CPU',
ci_24xlarge_high_memory: '24XL - High Memory',
ci_48xlarge: '48XL',
ci_48xlarge_optimized_memory: '48XL - Optimized Memory',
ci_48xlarge_optimized_cpu: '48XL - Optimized CPU',
ci_48xlarge_high_memory: '48XL - High Memory',
}
export const mapAddOnVariantIdToComputeSize = (
addonVariantId: ComputeInstanceAddonVariantId = 'ci_nano'
): ComputeInstanceSize => {
const parsed = computeInstanceAddonVariantIdSchema.safeParse(addonVariantId)
if (!parsed.success) return addonVariantToComputeSize.ci_nano
return addonVariantToComputeSize[parsed.data]
}
export const formatNumber = (num: number): string => {
return num.toLocaleString('en-US')
}
export const showMicroUpgrade = (plan: PlanId, infraComputeSize: InfraInstanceSize): boolean => {
return plan !== 'free' && infraComputeSize === 'nano'
}