DiskManagement.utils.ts442 lines · main
1import {
2 COMPUTE_BASELINE_IOPS,
3 COMPUTE_MAX_IOPS,
4 computeInstanceAddonVariantIdSchema,
5} from 'shared-data'
6
7import {
8 ComputeInstanceAddonVariantId,
9 ComputeInstanceSize,
10 InfraInstanceSize,
11} from './DiskManagement.types'
12import { DISK_LIMITS, DISK_PRICING, DiskType, PLAN_DETAILS } from './ui/DiskManagement.constants'
13import { ProjectDetail } from '@/data/projects/project-detail-query'
14import { PlanId, ProjectAddonVariantMeta } from '@/data/subscriptions/types'
15import { INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from '@/lib/constants'
16
17// Included disk size only applies to primary, not replicas
18export 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
48export 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
85export 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
131export 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
162export 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
172type 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
185const 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
210export 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
284export const calculateMaxIopsAllowedForDiskSizeWithGp3 = (totalSize: number) => {
285 return Math.max(3000, Math.min(500 * totalSize, 16000))
286}
287
288export const calculateDiskSizeRequiredForIopsWithGp3 = (iops: number) => {
289 return Math.max(1, Math.ceil(iops / 500))
290}
291
292export const calculateMaxIopsAllowedForDiskSizeWithio2 = (totalSize: number) => {
293 return Math.min(500 * totalSize, 256000)
294}
295
296export const calculateDiskSizeRequiredForIopsWithIo2 = (iops: number) => {
297 return Math.max(4, Math.ceil(iops / 1000))
298}
299
300export const calculateMaxThroughput = (iops: number) => {
301 return Math.min(0.256 * iops, 1000)
302}
303
304export const calculateIopsRequiredForThroughput = (throughput: number) => {
305 return Math.max(125, Math.ceil(throughput / 0.256))
306}
307
308export 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
314export 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
320export 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
346export 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
365export const formatComputeName = (compute: string) => {
366 return compute.toUpperCase().replace('CI_', '')
367}
368
369const 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
392const isInfraInstanceSize = (value: string): value is InfraInstanceSize =>
393 Object.prototype.hasOwnProperty.call(infraToAddonVariant, value)
394
395export 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
404const 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
426export 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
434export const formatNumber = (num: number): string => {
435 return num.toLocaleString('en-US')
436}
437
438export const showMicroUpgrade = (plan: PlanId, infraComputeSize: InfraInstanceSize): boolean => {
439 return plan !== 'free' && infraComputeSize === 'nano'
440}
441
442export function hasBurstableIO(): boolean { return false; }