DiskManagement.utils.ts442 lines · main
| 1 | import { |
| 2 | COMPUTE_BASELINE_IOPS, |
| 3 | COMPUTE_MAX_IOPS, |
| 4 | computeInstanceAddonVariantIdSchema, |
| 5 | } from 'shared-data' |
| 6 | |
| 7 | import { |
| 8 | ComputeInstanceAddonVariantId, |
| 9 | ComputeInstanceSize, |
| 10 | InfraInstanceSize, |
| 11 | } from './DiskManagement.types' |
| 12 | import { DISK_LIMITS, DISK_PRICING, DiskType, PLAN_DETAILS } from './ui/DiskManagement.constants' |
| 13 | import { ProjectDetail } from '@/data/projects/project-detail-query' |
| 14 | import { PlanId, ProjectAddonVariantMeta } from '@/data/subscriptions/types' |
| 15 | import { INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from '@/lib/constants' |
| 16 | |
| 17 | // Included disk size only applies to primary, not replicas |
| 18 | export const calculateDiskSizePrice = ({ |
| 19 | planId, |
| 20 | oldSize, |
| 21 | oldStorageType, |
| 22 | newSize, |
| 23 | newStorageType, |
| 24 | numReplicas = 0, |
| 25 | }: { |
| 26 | planId: string |
| 27 | oldSize: number |
| 28 | oldStorageType: DiskType |
| 29 | newSize: number |
| 30 | newStorageType: DiskType |
| 31 | numReplicas?: number |
| 32 | }) => { |
| 33 | const oldPricePerGB = DISK_PRICING[oldStorageType]?.storage ?? 0 |
| 34 | const newPricePerGB = DISK_PRICING[newStorageType]?.storage ?? 0 |
| 35 | const { includedDiskGB } = PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] |
| 36 | |
| 37 | const oldPrice = Math.max(oldSize - includedDiskGB[oldStorageType], 0) * oldPricePerGB |
| 38 | const oldPriceReplica = oldSize * 1.25 * oldPricePerGB |
| 39 | const newPrice = Math.max(newSize - includedDiskGB[newStorageType], 0) * newPricePerGB |
| 40 | const newPriceReplica = newSize * 1.25 * newPricePerGB |
| 41 | |
| 42 | return { |
| 43 | oldPrice: (oldPrice + numReplicas * oldPriceReplica).toFixed(2), |
| 44 | newPrice: (newPrice + numReplicas * newPriceReplica).toFixed(2), |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | export const calculateComputeSizePrice = ({ |
| 49 | availableOptions, |
| 50 | oldComputeSize, |
| 51 | newComputeSize, |
| 52 | plan, |
| 53 | }: { |
| 54 | availableOptions: { |
| 55 | identifier: string |
| 56 | price: number |
| 57 | }[] |
| 58 | oldComputeSize: string |
| 59 | newComputeSize: string |
| 60 | plan: PlanId |
| 61 | }) => { |
| 62 | let _oldComputeSize = oldComputeSize |
| 63 | |
| 64 | if (plan !== 'free' && oldComputeSize === 'ci_nano') { |
| 65 | /** |
| 66 | * override the old compute size to micro if the plan is not free |
| 67 | * this is to handle the case in which nano compute is a paid entity |
| 68 | */ |
| 69 | _oldComputeSize = 'ci_micro' |
| 70 | } |
| 71 | |
| 72 | const oldPrice = availableOptions?.find((x) => x.identifier === _oldComputeSize)?.price ?? 0 |
| 73 | const newPrice = availableOptions?.find((x) => x.identifier === newComputeSize)?.price ?? 0 |
| 74 | |
| 75 | const oldPriceMonthly = oldPrice * 720 |
| 76 | const newPriceMonthly = newPrice * 720 |
| 77 | |
| 78 | return { |
| 79 | oldPrice: oldPriceMonthly.toFixed(2), |
| 80 | newPrice: newPriceMonthly.toFixed(2), |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // Included IOPS applies to both primary and replicas |
| 85 | export const calculateIOPSPrice = ({ |
| 86 | oldStorageType, |
| 87 | oldProvisionedIOPS, |
| 88 | newStorageType, |
| 89 | newProvisionedIOPS, |
| 90 | numReplicas = 0, |
| 91 | }: { |
| 92 | oldStorageType: DiskType |
| 93 | oldProvisionedIOPS: number |
| 94 | newStorageType: DiskType |
| 95 | newProvisionedIOPS: number |
| 96 | numReplicas?: number |
| 97 | }) => { |
| 98 | if (newStorageType === DiskType.GP3) { |
| 99 | const oldChargeableIOPS = Math.max( |
| 100 | 0, |
| 101 | oldProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops |
| 102 | ) |
| 103 | const newChargeableIOPS = Math.max( |
| 104 | 0, |
| 105 | newProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops |
| 106 | ) |
| 107 | const oldPrice = oldChargeableIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0) |
| 108 | const newPrice = newChargeableIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0) |
| 109 | |
| 110 | return { |
| 111 | oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), |
| 112 | newPrice: (newPrice * (1 + numReplicas)).toFixed(2), |
| 113 | } |
| 114 | } else { |
| 115 | const oldPrice = |
| 116 | oldStorageType === 'gp3' |
| 117 | ? (oldProvisionedIOPS - DISK_LIMITS[oldStorageType].includedIops) * |
| 118 | DISK_PRICING[oldStorageType].iops |
| 119 | : oldProvisionedIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0) |
| 120 | const newPrice = newProvisionedIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0) |
| 121 | return { |
| 122 | oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), |
| 123 | newPrice: (newPrice * (1 + numReplicas)).toFixed(2), |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // This is only applicable for GP3 storage type, no need to consider IO2 at all |
| 129 | // Also assumes that disk size is > 400 GB (separate requirement to update throughput) |
| 130 | // Also, included throughput applies to both primary and replicas |
| 131 | export const calculateThroughputPrice = ({ |
| 132 | storageType, |
| 133 | newThroughput, |
| 134 | oldThroughput, |
| 135 | numReplicas = 0, |
| 136 | }: { |
| 137 | storageType: DiskType |
| 138 | newThroughput: number |
| 139 | oldThroughput: number |
| 140 | numReplicas?: number |
| 141 | }) => { |
| 142 | if (storageType === DiskType.GP3 && newThroughput) { |
| 143 | const oldChargeableThroughput = Math.max( |
| 144 | 0, |
| 145 | oldThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput |
| 146 | ) |
| 147 | const newChargeableThroughput = Math.max( |
| 148 | 0, |
| 149 | newThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput |
| 150 | ) |
| 151 | const oldPrice = oldChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput |
| 152 | const newPrice = newChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput |
| 153 | |
| 154 | return { |
| 155 | oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), |
| 156 | newPrice: (newPrice * (1 + numReplicas)).toFixed(2), |
| 157 | } |
| 158 | } |
| 159 | return { oldPrice: '0.00', newPrice: '0.00' } |
| 160 | } |
| 161 | |
| 162 | export type ComputeAddonVariant = { |
| 163 | identifier: ComputeInstanceAddonVariantId |
| 164 | name: string |
| 165 | price_description: string |
| 166 | price: number |
| 167 | price_interval: 'hourly' | 'monthly' |
| 168 | price_type: string |
| 169 | meta?: ProjectAddonVariantMeta |
| 170 | } |
| 171 | |
| 172 | type AvailableAddon = { |
| 173 | type: string |
| 174 | variants: Array<{ |
| 175 | identifier: string |
| 176 | name: string |
| 177 | price_description: string |
| 178 | price: number |
| 179 | price_interval: 'hourly' | 'monthly' |
| 180 | price_type: string |
| 181 | meta?: unknown |
| 182 | }> |
| 183 | } |
| 184 | |
| 185 | const isProjectAddonVariantMeta = (meta: unknown): meta is ProjectAddonVariantMeta => { |
| 186 | if (typeof meta !== 'object' || meta === null) return false |
| 187 | |
| 188 | const obj = meta as Record<string, unknown> |
| 189 | |
| 190 | // Validate supported_cloud_providers is an array if present (used at line 200) |
| 191 | if ('supported_cloud_providers' in obj && !Array.isArray(obj.supported_cloud_providers)) { |
| 192 | return false |
| 193 | } |
| 194 | |
| 195 | // Check for at least one expected property to ensure it's likely a real ProjectAddonVariantMeta |
| 196 | const hasExpectedProperty = |
| 197 | 'cpu_cores' in obj || |
| 198 | 'memory_gb' in obj || |
| 199 | 'cpu_dedicated' in obj || |
| 200 | 'baseline_disk_io_mbs' in obj || |
| 201 | 'max_disk_io_mbs' in obj || |
| 202 | 'connections_direct' in obj || |
| 203 | 'connections_pooler' in obj || |
| 204 | 'backup_duration_days' in obj || |
| 205 | 'supported_cloud_providers' in obj |
| 206 | |
| 207 | return hasExpectedProperty |
| 208 | } |
| 209 | |
| 210 | export function getAvailableComputeOptions( |
| 211 | availableAddons: AvailableAddon[], |
| 212 | projectCloudProvider?: string |
| 213 | ) { |
| 214 | const computeAddon = availableAddons.find((addon) => addon.type === 'compute_instance') |
| 215 | const computeOptions: ComputeAddonVariant[] = |
| 216 | computeAddon?.variants.flatMap((option) => { |
| 217 | const parsedId = computeInstanceAddonVariantIdSchema.safeParse(option.identifier) |
| 218 | if (!parsedId.success) return [] |
| 219 | |
| 220 | if (projectCloudProvider && isProjectAddonVariantMeta(option.meta)) { |
| 221 | const isSupported = |
| 222 | !option.meta.supported_cloud_providers || |
| 223 | option.meta.supported_cloud_providers.includes(projectCloudProvider) |
| 224 | if (!isSupported) return [] |
| 225 | } |
| 226 | |
| 227 | return [ |
| 228 | { |
| 229 | ...option, |
| 230 | identifier: parsedId.data, |
| 231 | meta: isProjectAddonVariantMeta(option.meta) ? option.meta : undefined, |
| 232 | }, |
| 233 | ] |
| 234 | }) ?? [] |
| 235 | |
| 236 | function hasMicroOptionFromApi() { |
| 237 | return (computeAddon?.variants ?? []).some((variant) => variant.identifier === 'ci_micro') |
| 238 | } |
| 239 | |
| 240 | // Backwards comp until API is deployed |
| 241 | if (!hasMicroOptionFromApi()) { |
| 242 | // Unshift to push to start of array |
| 243 | computeOptions.unshift({ |
| 244 | identifier: 'ci_micro', |
| 245 | name: 'Micro', |
| 246 | price_description: '$0.01344/hour (~$10/month)', |
| 247 | price: 0.01344, |
| 248 | price_interval: 'hourly', |
| 249 | price_type: 'usage', |
| 250 | meta: { |
| 251 | cpu_cores: INSTANCE_MICRO_SPECS.cpu_cores, |
| 252 | cpu_dedicated: INSTANCE_MICRO_SPECS.cpu_dedicated, |
| 253 | memory_gb: INSTANCE_MICRO_SPECS.memory_gb, |
| 254 | baseline_disk_io_mbs: INSTANCE_MICRO_SPECS.baseline_disk_io_mbs, |
| 255 | max_disk_io_mbs: INSTANCE_MICRO_SPECS.max_disk_io_mbs, |
| 256 | connections_direct: INSTANCE_MICRO_SPECS.connections_direct, |
| 257 | connections_pooler: INSTANCE_MICRO_SPECS.connections_pooler, |
| 258 | } as ProjectAddonVariantMeta, |
| 259 | }) |
| 260 | } |
| 261 | |
| 262 | computeOptions.unshift({ |
| 263 | identifier: 'ci_nano', |
| 264 | name: 'Nano', |
| 265 | price_description: '$0/hour (~$0/month)', |
| 266 | price: 0, |
| 267 | price_interval: 'hourly', |
| 268 | price_type: 'usage', |
| 269 | // @ts-ignore API types it as Record<string, never> |
| 270 | meta: { |
| 271 | cpu_cores: INSTANCE_NANO_SPECS.cpu_cores, |
| 272 | cpu_dedicated: INSTANCE_NANO_SPECS.cpu_dedicated, |
| 273 | memory_gb: INSTANCE_NANO_SPECS.memory_gb, |
| 274 | baseline_disk_io_mbs: INSTANCE_NANO_SPECS.baseline_disk_io_mbs, |
| 275 | max_disk_io_mbs: INSTANCE_NANO_SPECS.max_disk_io_mbs, |
| 276 | connections_direct: INSTANCE_NANO_SPECS.connections_direct, |
| 277 | connections_pooler: INSTANCE_NANO_SPECS.connections_pooler, |
| 278 | } as ProjectAddonVariantMeta, |
| 279 | }) |
| 280 | |
| 281 | return computeOptions |
| 282 | } |
| 283 | |
| 284 | export const calculateMaxIopsAllowedForDiskSizeWithGp3 = (totalSize: number) => { |
| 285 | return Math.max(3000, Math.min(500 * totalSize, 16000)) |
| 286 | } |
| 287 | |
| 288 | export const calculateDiskSizeRequiredForIopsWithGp3 = (iops: number) => { |
| 289 | return Math.max(1, Math.ceil(iops / 500)) |
| 290 | } |
| 291 | |
| 292 | export const calculateMaxIopsAllowedForDiskSizeWithio2 = (totalSize: number) => { |
| 293 | return Math.min(500 * totalSize, 256000) |
| 294 | } |
| 295 | |
| 296 | export const calculateDiskSizeRequiredForIopsWithIo2 = (iops: number) => { |
| 297 | return Math.max(4, Math.ceil(iops / 1000)) |
| 298 | } |
| 299 | |
| 300 | export const calculateMaxThroughput = (iops: number) => { |
| 301 | return Math.min(0.256 * iops, 1000) |
| 302 | } |
| 303 | |
| 304 | export const calculateIopsRequiredForThroughput = (throughput: number) => { |
| 305 | return Math.max(125, Math.ceil(throughput / 0.256)) |
| 306 | } |
| 307 | |
| 308 | export const calculateBaselineIopsForComputeSize = (computeSize: string): number => { |
| 309 | const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize) |
| 310 | if (!parsed.success) return 0 |
| 311 | return COMPUTE_BASELINE_IOPS[parsed.data] ?? 0 |
| 312 | } |
| 313 | |
| 314 | export const calculateMaxIopsForComputeSize = (computeSize: string): number => { |
| 315 | const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize) |
| 316 | if (!parsed.success) return 0 |
| 317 | return COMPUTE_MAX_IOPS[parsed.data] ?? 0 |
| 318 | } |
| 319 | |
| 320 | export const calculateComputeSizeRequiredForIops = ( |
| 321 | iops: number |
| 322 | ): ComputeInstanceAddonVariantId | undefined => { |
| 323 | type ComputeSizeMax = { size: ComputeInstanceAddonVariantId; maxIops: number } |
| 324 | |
| 325 | const computeSizes: ComputeSizeMax[] = Object.entries(COMPUTE_MAX_IOPS) |
| 326 | .map((entry) => { |
| 327 | const [size, maxIops] = entry |
| 328 | const parsedSize = computeInstanceAddonVariantIdSchema.safeParse(size) |
| 329 | if (!parsedSize.success) return undefined |
| 330 | return { size: parsedSize.data, maxIops: Number(maxIops) } |
| 331 | }) |
| 332 | .filter((value): value is ComputeSizeMax => value !== undefined) |
| 333 | .sort((a, b) => a.maxIops - b.maxIops) |
| 334 | |
| 335 | for (const { size, maxIops } of computeSizes) { |
| 336 | if (iops <= maxIops) { |
| 337 | return size |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | const fallbackSize = computeSizes[computeSizes.length - 1]?.size |
| 342 | if (!fallbackSize) return undefined |
| 343 | return fallbackSize |
| 344 | } |
| 345 | |
| 346 | export const calculateDiskSizeRequiredForIops = (provisionedIOPS: number): number | undefined => { |
| 347 | if (!provisionedIOPS) { |
| 348 | console.error('IOPS is required') |
| 349 | return undefined |
| 350 | } |
| 351 | |
| 352 | if (isNaN(provisionedIOPS) || provisionedIOPS < 0) { |
| 353 | console.error('IOPS must be a non-negative number') |
| 354 | return undefined |
| 355 | } |
| 356 | |
| 357 | if (provisionedIOPS > 256000) { |
| 358 | console.error('Maximum allowed IOPS is 256000') |
| 359 | return undefined |
| 360 | } |
| 361 | |
| 362 | return Math.max(1, Math.ceil(provisionedIOPS / 1000)) |
| 363 | } |
| 364 | |
| 365 | export const formatComputeName = (compute: string) => { |
| 366 | return compute.toUpperCase().replace('CI_', '') |
| 367 | } |
| 368 | |
| 369 | const infraToAddonVariant: Record<InfraInstanceSize, ComputeInstanceAddonVariantId> = { |
| 370 | pico: 'ci_nano', |
| 371 | nano: 'ci_nano', |
| 372 | micro: 'ci_micro', |
| 373 | small: 'ci_small', |
| 374 | medium: 'ci_medium', |
| 375 | large: 'ci_large', |
| 376 | xlarge: 'ci_xlarge', |
| 377 | '2xlarge': 'ci_2xlarge', |
| 378 | '4xlarge': 'ci_4xlarge', |
| 379 | '8xlarge': 'ci_8xlarge', |
| 380 | '12xlarge': 'ci_12xlarge', |
| 381 | '16xlarge': 'ci_16xlarge', |
| 382 | '24xlarge': 'ci_24xlarge', |
| 383 | '24xlarge_optimized_memory': 'ci_24xlarge_optimized_memory', |
| 384 | '24xlarge_optimized_cpu': 'ci_24xlarge_optimized_cpu', |
| 385 | '24xlarge_high_memory': 'ci_24xlarge_high_memory', |
| 386 | '48xlarge': 'ci_48xlarge', |
| 387 | '48xlarge_optimized_memory': 'ci_48xlarge_optimized_memory', |
| 388 | '48xlarge_optimized_cpu': 'ci_48xlarge_optimized_cpu', |
| 389 | '48xlarge_high_memory': 'ci_48xlarge_high_memory', |
| 390 | } |
| 391 | |
| 392 | const isInfraInstanceSize = (value: string): value is InfraInstanceSize => |
| 393 | Object.prototype.hasOwnProperty.call(infraToAddonVariant, value) |
| 394 | |
| 395 | export const mapComputeSizeNameToAddonVariantId = ( |
| 396 | computeSize: ProjectDetail['infra_compute_size'] |
| 397 | ): ComputeInstanceAddonVariantId => { |
| 398 | const fallback: InfraInstanceSize = 'nano' |
| 399 | const matchedSize = computeSize && isInfraInstanceSize(computeSize) ? computeSize : undefined |
| 400 | const sizeKey = matchedSize ?? fallback |
| 401 | return infraToAddonVariant[sizeKey] |
| 402 | } |
| 403 | |
| 404 | const addonVariantToComputeSize: Record<ComputeInstanceAddonVariantId, ComputeInstanceSize> = { |
| 405 | ci_nano: 'Nano', |
| 406 | ci_micro: 'Micro', |
| 407 | ci_small: 'Small', |
| 408 | ci_medium: 'Medium', |
| 409 | ci_large: 'Large', |
| 410 | ci_xlarge: 'XL', |
| 411 | ci_2xlarge: '2XL', |
| 412 | ci_4xlarge: '4XL', |
| 413 | ci_8xlarge: '8XL', |
| 414 | ci_12xlarge: '12XL', |
| 415 | ci_16xlarge: '16XL', |
| 416 | ci_24xlarge: '24XL', |
| 417 | ci_24xlarge_optimized_memory: '24XL - Optimized Memory', |
| 418 | ci_24xlarge_optimized_cpu: '24XL - Optimized CPU', |
| 419 | ci_24xlarge_high_memory: '24XL - High Memory', |
| 420 | ci_48xlarge: '48XL', |
| 421 | ci_48xlarge_optimized_memory: '48XL - Optimized Memory', |
| 422 | ci_48xlarge_optimized_cpu: '48XL - Optimized CPU', |
| 423 | ci_48xlarge_high_memory: '48XL - High Memory', |
| 424 | } |
| 425 | |
| 426 | export const mapAddOnVariantIdToComputeSize = ( |
| 427 | addonVariantId: ComputeInstanceAddonVariantId = 'ci_nano' |
| 428 | ): ComputeInstanceSize => { |
| 429 | const parsed = computeInstanceAddonVariantIdSchema.safeParse(addonVariantId) |
| 430 | if (!parsed.success) return addonVariantToComputeSize.ci_nano |
| 431 | return addonVariantToComputeSize[parsed.data] |
| 432 | } |
| 433 | |
| 434 | export const formatNumber = (num: number): string => { |
| 435 | return num.toLocaleString('en-US') |
| 436 | } |
| 437 | |
| 438 | export const showMicroUpgrade = (plan: PlanId, infraComputeSize: InfraInstanceSize): boolean => { |
| 439 | return plan !== 'free' && infraComputeSize === 'nano' |
| 440 | } |
| 441 | |
| 442 | export function hasBurstableIO(): boolean { return false; } |