From f2e4557e3401878590918fcb5c634c8452ef710b Mon Sep 17 00:00:00 2001 From: aTurmo Date: Thu, 30 Apr 2026 16:24:25 +0200 Subject: [PATCH] feat(pci-block-storage): add min value to iops and bandwidth for volume * feat(pci-block-storage): add min value to iops and bandwidth for volume ref: #TAPC-6552, #TAPC-6554 Signed-off-by: aTurmo --- .../translations/common/Messages_de_DE.json | 2 + .../translations/common/Messages_en_GB.json | 2 + .../translations/common/Messages_es_ES.json | 2 + .../translations/common/Messages_fr_CA.json | 2 + .../translations/common/Messages_fr_FR.json | 2 + .../translations/common/Messages_it_IT.json | 2 + .../translations/common/Messages_pl_PL.json | 2 + .../translations/common/Messages_pt_PT.json | 2 + .../api/data/catalog-spec-overrides.spec.ts | 140 ++++++++++++++++++ .../src/api/data/catalog-spec-overrides.ts | 47 ++++++ .../pci-block-storage/src/api/data/catalog.ts | 12 +- .../src/api/select/catalog.spec.ts | 133 +++++++++++++++++ .../src/api/select/catalog.ts | 78 ++++++++-- .../VolumeModelTilesInput.component.tsx | 87 +++++++++-- 14 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.spec.ts create mode 100644 packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.ts diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_de_DE.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_de_DE.json index 2cdf266c4c49..1b80622f7980 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_de_DE.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_de_DE.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Verschlüsselung nicht verfügbar", "pci_projects_project_storages_blocks_guaranteed": "garantiert", "pci_projects_project_storages_blocks_up_to": "bis zu", + "pci_projects_project_storages_blocks_iops_base_range": "Basis von {{min}} IOPS zwischen {{minSize}} {{sizeUnit}} und {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Basis von {{min}} {{unit}} zwischen {{minSize}} {{sizeUnit}} und {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guides", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Alle Guides zu Block Storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Erste Schritte mit Volumes", diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_en_GB.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_en_GB.json index f9e1e098a576..26b18f4a58e9 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_en_GB.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_en_GB.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Encryption not available", "pci_projects_project_storages_blocks_guaranteed": "guaranteed", "pci_projects_project_storages_blocks_up_to": "up to", + "pci_projects_project_storages_blocks_iops_base_range": "Base of {{min}} IOPS between {{minSize}} {{sizeUnit}} and {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base of {{min}} {{unit}} between {{minSize}} {{sizeUnit}} and {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guides", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "All block storage guides", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Getting started with volumes", diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_es_ES.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_es_ES.json index 93d2f52a4fd8..13f5507c3046 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_es_ES.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_es_ES.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Cifrado no disponible", "pci_projects_project_storages_blocks_guaranteed": "garantizada", "pci_projects_project_storages_blocks_up_to": "hasta", + "pci_projects_project_storages_blocks_iops_base_range": "Base de {{min}} IOPS entre {{minSize}} {{sizeUnit}} y {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base de {{min}} {{unit}} entre {{minSize}} {{sizeUnit}} y {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guías", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Todas las guías del block storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Primeros pasos con los volúmenes", diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_CA.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_CA.json index d7729cede8a8..a0cfa11121ed 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_CA.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_CA.json @@ -27,6 +27,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Chiffrement indisponible", "pci_projects_project_storages_blocks_guaranteed": "garantie", "pci_projects_project_storages_blocks_up_to": "jusqu'à", + "pci_projects_project_storages_blocks_iops_base_range": "Base de {{min}} IOPS entre {{minSize}} {{sizeUnit}} et {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base de {{min}} {{unit}} entre {{minSize}} {{sizeUnit}} et {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guides", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Tous les guides block storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Premiers pas avec les volumes" diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_FR.json index d7729cede8a8..a0cfa11121ed 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_FR.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_fr_FR.json @@ -27,6 +27,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Chiffrement indisponible", "pci_projects_project_storages_blocks_guaranteed": "garantie", "pci_projects_project_storages_blocks_up_to": "jusqu'à", + "pci_projects_project_storages_blocks_iops_base_range": "Base de {{min}} IOPS entre {{minSize}} {{sizeUnit}} et {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base de {{min}} {{unit}} entre {{minSize}} {{sizeUnit}} et {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guides", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Tous les guides block storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Premiers pas avec les volumes" diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_it_IT.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_it_IT.json index 59ea3393e4a0..aee15934564f 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_it_IT.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_it_IT.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Crittografia non disponibile", "pci_projects_project_storages_blocks_guaranteed": "garantita", "pci_projects_project_storages_blocks_up_to": "fino a", + "pci_projects_project_storages_blocks_iops_base_range": "Base di {{min}} IOPS tra {{minSize}} {{sizeUnit}} e {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base di {{min}} {{unit}} tra {{minSize}} {{sizeUnit}} e {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Guide", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Tutte le guide Block Storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Iniziare a utilizzare i volumi", diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pl_PL.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pl_PL.json index d9ea4acc5be9..2735852191b1 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pl_PL.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pl_PL.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Szyfrowanie niedostępne", "pci_projects_project_storages_blocks_guaranteed": "o gwarantowanej przepustowości", "pci_projects_project_storages_blocks_up_to": "do", + "pci_projects_project_storages_blocks_iops_base_range": "Podstawa {{min}} IOPS między {{minSize}} {{sizeUnit}} a {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Podstawa {{min}} {{unit}} między {{minSize}} {{sizeUnit}} a {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Przewodniki", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Przewodniki dotyczące block storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Pierwsze kroki z wolumenami", diff --git a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pt_PT.json b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pt_PT.json index 20e6605488c2..2591cf1be062 100644 --- a/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pt_PT.json +++ b/packages/manager/apps/pci-block-storage/public/translations/common/Messages_pt_PT.json @@ -26,6 +26,8 @@ "pci_projects_project_storages_blocks_encryption_unavailable": "Encriptação indisponível", "pci_projects_project_storages_blocks_guaranteed": "garantida", "pci_projects_project_storages_blocks_up_to": "até", + "pci_projects_project_storages_blocks_iops_base_range": "Base de {{min}} IOPS entre {{minSize}} {{sizeUnit}} e {{maxSize}} {{sizeUnit}}", + "pci_projects_project_storages_blocks_bandwidth_base_range": "Base de {{min}} {{unit}} entre {{minSize}} {{sizeUnit}} e {{maxSize}} {{sizeUnit}}", "pci_projects_project_storages_blocks_guides_header": "Manuais", "pci_projects_project_storages_blocks_guides_all_block_storage_guides": "Todos os guias Block Storage", "pci_projects_project_storages_blocks_guides_first_steps_with_volumes": "Introdução aos volumes", diff --git a/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.spec.ts b/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.spec.ts new file mode 100644 index 000000000000..38b5cc85b40d --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.spec.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) OVH SAS + +import { describe, expect, it } from 'vitest'; +import { applyHardcodedSpecOverrides } from './catalog-spec-overrides'; +import { TVolumeCatalog, TVolumePricing } from './catalog'; + +const makePricing = ( + overrides: Partial = {}, + bandwidthOverrides: Partial< + NonNullable + > | null = {}, +): TVolumePricing => + ({ + price: 0, + regions: [], + showAvailabilityZones: false, + interval: 'hour', + areIOPSDynamic: true, + isBandwidthDynamic: true, + specs: { + name: 'spec', + maxAttachedInstances: 1, + bandwidth: + bandwidthOverrides === null + ? null + : { + guaranteed: false, + level: 1, + max: 1000, + ...bandwidthOverrides, + }, + volume: { + iops: { + level: 1, + max: 10000, + guaranteed: false, + unit: 'u', + maxUnit: 'u', + ...overrides, + }, + capacity: { max: 4000 }, + }, + }, + } as TVolumePricing); + +const makeCatalog = ( + modelName: string, + pricings: TVolumePricing[], +): TVolumeCatalog => + (({ + filters: { deployment: [], region: [] }, + regions: [], + models: [ + { + name: modelName, + tags: [], + filters: {}, + pricings, + }, + ], + } as unknown) as TVolumeCatalog); + +describe('applyHardcodedSpecOverrides', () => { + it('injects iops min and bandwidth min on high-speed-gen2 when not guaranteed', () => { + const catalog = makeCatalog('high-speed-gen2', [makePricing()]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBe(3000); + expect(pricing.specs.bandwidth?.min).toBe(50); + }); + + it('injects iops min and bandwidth min on high-speed-gen2-luks when not guaranteed', () => { + const catalog = makeCatalog('high-speed-gen2-luks', [makePricing()]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBe(3000); + expect(pricing.specs.bandwidth?.min).toBe(50); + }); + + it('does not override iops or bandwidth when the spec is guaranteed', () => { + const catalog = makeCatalog('high-speed-gen2', [ + makePricing({ guaranteed: true }, { guaranteed: true }), + ]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBeUndefined(); + expect(pricing.specs.bandwidth?.min).toBeUndefined(); + }); + + it('does not override when the API already returned a positive min', () => { + const catalog = makeCatalog('high-speed-gen2', [ + makePricing({ min: 9999 }, { min: 999 }), + ]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBe(9999); + expect(pricing.specs.bandwidth?.min).toBe(999); + }); + + it('overrides when the API returned a non-positive min (0 or null)', () => { + const catalog = makeCatalog('high-speed-gen2', [ + makePricing({ min: 0 }, { min: null }), + ]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBe(3000); + expect(pricing.specs.bandwidth?.min).toBe(50); + }); + + it('leaves non-overridden models untouched', () => { + const catalog = makeCatalog('classic', [makePricing()]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBeUndefined(); + expect(pricing.specs.bandwidth?.min).toBeUndefined(); + }); + + it('handles a null bandwidth spec without throwing', () => { + const catalog = makeCatalog('high-speed-gen2', [makePricing({}, null)]); + + const result = applyHardcodedSpecOverrides(catalog); + + const pricing = result.models[0].pricings[0]; + expect(pricing.specs.volume.iops.min).toBe(3000); + expect(pricing.specs.bandwidth).toBeNull(); + }); +}); diff --git a/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.ts b/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.ts new file mode 100644 index 000000000000..d5e672271272 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/api/data/catalog-spec-overrides.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) OVH SAS + +// TEMPORARY — hard-coded overrides for gen2 / gen2-luks volume specs. +// /cloud/project/{id}/catalog/volume will eventually return a `min` value on +// iops and bandwidth specs for these models. Until then, we inject the values +// client-side here so the rest of the app can consume the catalog as if `min` +// were always provided. +// +// The override never replaces a positive `min` already returned by the API: +// once the catalog ships its own value the patch becomes inert, and removal +// becomes purely cosmetic. +// +// To remove: delete this file and the call in catalog.ts:getVolumeCatalog. + +import { TVolumeCatalog } from './catalog'; + +const HARDCODED_IOPS_MIN = 3000; +const HARDCODED_BANDWIDTH_MIN = 50; // MiB/s +const OVERRIDDEN_MODEL_NAMES = ['high-speed-gen2', 'high-speed-gen2-luks']; + +const hasPositiveMin = (value: number | null | undefined): boolean => + typeof value === 'number' && value > 0; + +export const applyHardcodedSpecOverrides = ( + catalog: TVolumeCatalog, +): TVolumeCatalog => { + catalog.models + .filter((model) => OVERRIDDEN_MODEL_NAMES.includes(model.name)) + .forEach((model) => { + model.pricings.forEach((pricing) => { + const iops = pricing.specs.volume.iops; + if (iops && !iops.guaranteed && !hasPositiveMin(iops.min)) { + iops.min = HARDCODED_IOPS_MIN; + } + const bandwidth = pricing.specs.bandwidth; + if ( + bandwidth && + !bandwidth.guaranteed && + !hasPositiveMin(bandwidth.min) + ) { + bandwidth.min = HARDCODED_BANDWIDTH_MIN; + } + }); + }); + return catalog; +}; diff --git a/packages/manager/apps/pci-block-storage/src/api/data/catalog.ts b/packages/manager/apps/pci-block-storage/src/api/data/catalog.ts index ecdb3f0f2c5d..879fdb70ba09 100644 --- a/packages/manager/apps/pci-block-storage/src/api/data/catalog.ts +++ b/packages/manager/apps/pci-block-storage/src/api/data/catalog.ts @@ -1,6 +1,7 @@ import { TAddon } from '@ovh-ux/manager-pci-common'; import { v6 } from '@ovh-ux/manager-core-api'; import { TRegion } from '@/api/data/regions'; +import { applyHardcodedSpecOverrides } from '@/api/data/catalog-spec-overrides'; export type TCatalogGroup = { name: string; @@ -19,6 +20,7 @@ export type TVolumePricing = Pick & { level: number; /* in GB */ max: number; + min?: number | null; } | null; volume: { iops: { @@ -27,6 +29,7 @@ export type TVolumePricing = Pick & { guaranteed: boolean; unit: string; maxUnit: string; + min?: number | null; }; capacity: { max: number; @@ -62,6 +65,9 @@ export type TVolumeCatalog = { export const getVolumeCatalog = async ( projectId: string, -): Promise => - (await v6.get(`/cloud/project/${projectId}/catalog/volume`)) - .data; +): Promise => { + const { data } = await v6.get( + `/cloud/project/${projectId}/catalog/volume`, + ); + return applyHardcodedSpecOverrides(data); +}; diff --git a/packages/manager/apps/pci-block-storage/src/api/select/catalog.spec.ts b/packages/manager/apps/pci-block-storage/src/api/select/catalog.spec.ts index d78fe231ae8c..53f429775313 100644 --- a/packages/manager/apps/pci-block-storage/src/api/select/catalog.spec.ts +++ b/packages/manager/apps/pci-block-storage/src/api/select/catalog.spec.ts @@ -3,6 +3,7 @@ import { TFunction } from 'i18next'; import { TVolumeAddon, TVolumeCatalog } from '@/api/data/catalog'; import { TRegion } from '@/api/data/regions'; import { + getPricingSpecsFromModelPricings, is3az, mapRetypingVolumeCatalog, mapVolumeCatalog, @@ -172,6 +173,138 @@ describe('select catalog', () => { }); }); + describe('getPricingSpecsFromModelPricings', () => { + const catalogPriceFormatter = (price: number) => `price: ${price}`; + const translator = ((keyValue: string) => keyValue) as TFunction; + + const gen2Pricings = ([ + { + price: 10, + regions: [region.name], + areIOPSDynamic: true, + isBandwidthDynamic: true, + specs: { + name: 'high-speed-gen2-spec', + volume: { + iops: { + level: 100, + max: 50000, + guaranteed: false, + min: 3000, + }, + capacity: { max: 4000 }, + }, + bandwidth: { + level: 1, + max: 1000, + guaranteed: false, + min: 50, + }, + }, + }, + ] as unknown) as TVolumeAddon['pricings']; + + it('exposes iops and bandwidth base range strings when min is set', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + 10, + ); + + expect(result.iopsBaseRange).toBe( + 'common:pci_projects_project_storages_blocks_iops_base_range', + ); + expect(result.bandwidthBaseRange).toBe( + 'common:pci_projects_project_storages_blocks_bandwidth_base_range', + ); + }); + + it('does not expose base range strings when min is missing', () => { + const result = getPricingSpecsFromModelPricings( + createClassicModel().pricings, + catalogPriceFormatter, + translator, + 10, + ); + + expect(result.iopsBaseRange).toBeUndefined(); + expect(result.bandwidthBaseRange).toBeUndefined(); + }); + + describe('conditional floor', () => { + it('floors iops at min when capacity * level falls below min', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + 10, // 10 * 100 = 1000 < min(3000) → floored + ); + + expect(result.iops).toContain('3000 IOPS'); + expect(result.iops).not.toContain('1000 IOPS'); + }); + + it('keeps the raw iops value when capacity * level exceeds min', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + 100, // 100 * 100 = 10000 > min(3000) → kept + ); + + expect(result.iops).toContain('10000 IOPS'); + }); + + it('does NOT floor the per-GB iops display when capacity is undefined', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + // capacity omitted on purpose + ); + + // Should be "100 IOPS/, 50000 IOPS" — never the floor. + expect(result.iops).toContain('100 IOPS'); + expect(result.iops).not.toContain('3000 IOPS'); + }); + + it('floors bandwidth at min when capacity * level falls below min', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + 10, // 10 * 1 = 10 < min(50) → floored + ); + + expect(result.bandwidth).toContain('50'); + }); + + it('keeps the raw bandwidth value when capacity * level exceeds min', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + 200, // 200 * 1 = 200 > min(50) → kept + ); + + expect(result.bandwidth).toContain('200'); + }); + + it('does NOT floor the per-GB bandwidth display when capacity is undefined', () => { + const result = getPricingSpecsFromModelPricings( + gen2Pricings, + catalogPriceFormatter, + translator, + ); + + // Per-GB level is 1 MB/s/GB; floor (50) must NOT replace it. + expect(result.bandwidth).toContain('1'); + expect(result.bandwidth).not.toContain('50 '); + }); + }); + }); + describe('mapRetypingVolumeCatalog', () => { const catalogPriceFormatter = (price: number) => `price: ${price}`; const translator = ((keyValue: string) => keyValue) as TFunction; diff --git a/packages/manager/apps/pci-block-storage/src/api/select/catalog.ts b/packages/manager/apps/pci-block-storage/src/api/select/catalog.ts index cf8d5bcafc05..e27bab6e6030 100644 --- a/packages/manager/apps/pci-block-storage/src/api/select/catalog.ts +++ b/packages/manager/apps/pci-block-storage/src/api/select/catalog.ts @@ -16,6 +16,7 @@ import { isRetypeModel, TVolumeRetypeModel, } from '@/api/hooks/useCatalogWithPreselection'; +import { VOLUME_MIN_SIZE } from '@/constants'; export type TModelName = Readonly<{ name: Opaque; @@ -79,8 +80,10 @@ export type TModelPrice = { isLeastPrice: boolean; }; iops: string; + iopsBaseRange?: string; areIOPSDynamic: boolean; bandwidth: string | null; + bandwidthBaseRange?: string; isBandwidthDynamic: boolean; encrypted: boolean; capacity: { @@ -100,31 +103,63 @@ export const getPricingSpecsFromModelPricings = ( ): TModelPrice => { const pricing = pricings[0]; - let iops = `${Math.min( - (capacity ?? 1) * pricing.specs.volume.iops.level, - pricing.specs.volume.iops.max, - )} IOPS`; + const iopsSpec = pricing.specs.volume.iops; + const iopsMin = + typeof iopsSpec.min === 'number' && iopsSpec.min > 0 ? iopsSpec.min : null; + + const iopsRaw = Math.min((capacity ?? 1) * iopsSpec.level, iopsSpec.max); + // Floor only when computing an absolute value for a known capacity. The + // per-GB initial display (capacity undefined) must keep the raw level. + const iopsValue = + capacity !== undefined && iopsMin !== null + ? Math.max(iopsMin, iopsRaw) + : iopsRaw; + + let iops = `${iopsValue} IOPS`; if (pricing.areIOPSDynamic && capacity === undefined) { iops += [ `/${t(`${NAMESPACES.BYTES}:unit_size_GB`)}`, `${t('common:pci_projects_project_storages_blocks_up_to')} ${ - pricing.specs.volume.iops.max + iopsSpec.max } IOPS`, ].join(', '); - } else if (pricing.specs.volume.iops.guaranteed) { + } else if (iopsSpec.guaranteed) { iops += ` ${t('common:pci_projects_project_storages_blocks_guaranteed')}`; - } else if (!pricing.areIOPSDynamic && !pricing.specs.volume.iops.guaranteed) { + } else if (!pricing.areIOPSDynamic && !iopsSpec.guaranteed) { iops = `${t('common:pci_projects_project_storages_blocks_up_to')} ${iops}`; } + const iopsBaseRange = + iopsMin !== null + ? t('common:pci_projects_project_storages_blocks_iops_base_range', { + min: iopsMin, + minSize: VOLUME_MIN_SIZE, + maxSize: Math.ceil(iopsMin / iopsSpec.level), + sizeUnit: t(`${NAMESPACES.BYTES}:unit_size_GB`), + }) + : undefined; + let bandwidth: TModelPrice['bandwidth'] = null; + let bandwidthBaseRange: string | undefined; if (pricing.specs.bandwidth) { + const bandwidthSpec = pricing.specs.bandwidth; + const bandwidthMin = + typeof bandwidthSpec.min === 'number' && bandwidthSpec.min > 0 + ? bandwidthSpec.min + : null; + + const bandwidthRaw = Math.min( + (capacity ?? 1) * bandwidthSpec.level, + bandwidthSpec.max, + ); + // Same rule as IOPS: floor only when capacity is provided. + const bandwidthValue = + capacity !== undefined && bandwidthMin !== null + ? Math.max(bandwidthMin, bandwidthRaw) + : bandwidthRaw; const level = formatSecondUnit( - `${Math.min( - (capacity ?? 1) * pricing.specs.bandwidth.level, - pricing.specs.bandwidth.max, - )} ${t(`${NAMESPACES.BYTES}:unit_size_MB`)}`, + `${bandwidthValue} ${t(`${NAMESPACES.BYTES}:unit_size_MB`)}`, ); if (pricing.isBandwidthDynamic) { @@ -132,9 +167,7 @@ export const getPricingSpecsFromModelPricings = ( bandwidth = level; } else { const max = formatSecondUnit( - `${pricing.specs.bandwidth.max} ${t( - `${NAMESPACES.BYTES}:unit_size_MB`, - )}`, + `${bandwidthSpec.max} ${t(`${NAMESPACES.BYTES}:unit_size_MB`)}`, ); bandwidth = [ @@ -142,11 +175,24 @@ export const getPricingSpecsFromModelPricings = ( `${t('common:pci_projects_project_storages_blocks_up_to')} ${max}`, ].join(', '); } - } else if (pricing.specs.bandwidth?.guaranteed) { + } else if (bandwidthSpec.guaranteed) { bandwidth = `${level} ${t( 'common:pci_projects_project_storages_blocks_guaranteed', )}`; } + + if (bandwidthMin !== null) { + bandwidthBaseRange = t( + 'common:pci_projects_project_storages_blocks_bandwidth_base_range', + { + min: bandwidthMin, + unit: formatSecondUnit(t(`${NAMESPACES.BYTES}:unit_size_MB`)), + minSize: VOLUME_MIN_SIZE, + maxSize: Math.ceil(bandwidthMin / bandwidthSpec.level), + sizeUnit: t(`${NAMESPACES.BYTES}:unit_size_GB`), + }, + ); + } } return { @@ -167,8 +213,10 @@ export const getPricingSpecsFromModelPricings = ( }).trim(), }, iops, + iopsBaseRange, areIOPSDynamic: pricing.areIOPSDynamic, bandwidth, + bandwidthBaseRange, isBandwidthDynamic: pricing.isBandwidthDynamic, encrypted: pricings.some((p) => p.specs.encrypted), capacity: { diff --git a/packages/manager/apps/pci-block-storage/src/components/VolumeModelTilesInput.component.tsx b/packages/manager/apps/pci-block-storage/src/components/VolumeModelTilesInput.component.tsx index c0f432a91c94..878d1bdb8d0e 100644 --- a/packages/manager/apps/pci-block-storage/src/components/VolumeModelTilesInput.component.tsx +++ b/packages/manager/apps/pci-block-storage/src/components/VolumeModelTilesInput.component.tsx @@ -1,8 +1,17 @@ -import { TilesInput, useBytes } from '@ovh-ux/manager-pci-common'; -import { useCallback, useMemo } from 'react'; +import { + ConfigCardElementProps, + TilesInput, + useBytes, +} from '@ovh-ux/manager-pci-common'; +import { + DetailedHTMLProps, + InputHTMLAttributes, + useCallback, + useMemo, +} from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; -import { Text, TEXT_PRESET } from '@ovhcloud/ods-react'; +import { Divider, Text, TEXT_PRESET } from '@ovhcloud/ods-react'; import { TVolumeModel } from '@/api/hooks/useCatalog'; import { TVolumeRetypeModel } from '@/api/hooks/useCatalogWithPreselection'; import { sortByPreselectedModel } from '@/api/select/catalog'; @@ -34,7 +43,7 @@ export const VolumeModelTilesInput = ({ const getDescription = useCallback( (model: TVolumeModel | TVolumeRetypeModel) => { - const zoneText = model.availabilityZonesCount + const availabilityZonesText = model.availabilityZonesCount ? t( 'add:pci_projects_project_storages_blocks_add_type_availability_zone', { count: model.availabilityZonesCount }, @@ -49,12 +58,41 @@ export const VolumeModelTilesInput = ({ ); if (horizontal) { - return [zoneText, model.iops, model.bandwidth, capacityMax] - .filter(Boolean) - .join('.\n'); + return ( +
+ {availabilityZonesText &&
{availabilityZonesText}
} +
    + {model.iops && ( + <> +
  • {model.iops}
  • + {model.iopsBaseRange && ( + + {model.iopsBaseRange} + + )} + + )} + {model.bandwidth && ( + <> +
  • + {model.bandwidth} +
  • + {model.bandwidthBaseRange && ( + + {model.bandwidthBaseRange} + + )} + + )} + {capacityMax && ( +
  • {capacityMax}
  • + )} +
+
+ ); } - return zoneText; + return availabilityZonesText; }, [t, formatBytes], ); @@ -64,14 +102,36 @@ export const VolumeModelTilesInput = ({ if (horizontal) return []; return [ - m.iops, + <> + {m.iops} + {m.iopsBaseRange && ( + <> +
+ {m.iopsBaseRange} + + )} + , t( 'add:pci_projects_project_storages_blocks_add_type_addon_capacity_max', { capacity: formatBytes(m.capacity.max), }, ), - ...(m.bandwidth ? [m.bandwidth] : []), + ...(m.bandwidth + ? [ + <> + {m.bandwidth} + {m.bandwidthBaseRange && ( + <> +
+ + {m.bandwidthBaseRange} + + + )} + , + ] + : []), ]; }, [t, formatBytes], @@ -82,7 +142,7 @@ export const VolumeModelTilesInput = ({ sortByPreselectedModel(volumeModels).map((model) => ({ ...model, label: capitalizeFirstLetter(model.displayName), - description: getDescription(model), + description: getDescription(model) as string, badges: hideBadges ? [] : [ @@ -97,7 +157,7 @@ export const VolumeModelTilesInput = ({ icon: 'lock' as const, }, ], - features: getFeatures(model), + features: getFeatures(model) as string[], price: model.hourlyPrice, })), [volumeModels, t, getDescription, getFeatures], @@ -121,7 +181,8 @@ export const VolumeModelTilesInput = ({ return (