diff --git a/packages/api-v4/.changeset/pr-10265-added-1711554883610.md b/packages/api-v4/.changeset/pr-10265-added-1711554883610.md new file mode 100644 index 00000000000..da26e0ebc06 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10265-added-1711554883610.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint and type for `nodebalancers/types` ([#10265](https://github.com/linode/manager/pull/10265)) diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index 42401c3fb4c..c3039693740 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -10,8 +10,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page, PriceType } from '../types'; +import type { CreateNodeBalancerPayload, NodeBalancer, NodeBalancerStats, @@ -130,3 +130,16 @@ export const getNodeBalancerFirewalls = ( setXFilter(filter), setParams(params) ); + +/** + * getNodeBalancerTypes + * + * Return a paginated list of available NodeBalancer types; used for pricing. + * This endpoint does not require authentication. + */ +export const getNodeBalancerTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/nodebalancers/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/types.ts b/packages/api-v4/src/types.ts index e25f09fd38f..13d8df47e77 100644 --- a/packages/api-v4/src/types.ts +++ b/packages/api-v4/src/types.ts @@ -1,3 +1,5 @@ +import type { PriceObject, RegionPriceObject } from './linodes/types'; + export interface APIError { field?: string; reason: string; @@ -115,3 +117,11 @@ export interface RequestHeaders { 'User-Agent'?: string; 'Content-Type'?: RequestContentType; } + +export interface PriceType { + id: string; + label: string; + price: PriceObject; + region_prices: RegionPriceObject[]; + transfer: number; +} diff --git a/packages/manager/.changeset/pr-10265-tech-stories-1711555076781.md b/packages/manager/.changeset/pr-10265-tech-stories-1711555076781.md new file mode 100644 index 00000000000..56726250c6b --- /dev/null +++ b/packages/manager/.changeset/pr-10265-tech-stories-1711555076781.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Price NodeBalancers dynamically with `nodebalancers/types` endpoint ([#10265](https://github.com/linode/manager/pull/10265)) diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 280e2bf9025..50afa4873ef 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,6 +1,7 @@ -import { LinodeType } from '@linode/api-v4/lib/linodes/types'; import * as Factory from 'factory.ts'; +import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; +import type { PriceType } from '@linode/api-v4/src/types'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import type { ExtendedType } from 'src/utilities/extendType'; @@ -114,3 +115,25 @@ export const extendedTypeFactory = Factory.Sync.makeFactory({ transfer: typeFactory.build().transfer, vcpus: typeFactory.build().vcpus, }); + +export const nodeBalancerTypeFactory = Factory.Sync.makeFactory({ + id: 'nodebalancer', + label: 'NodeBalancer', + price: { + hourly: 0.015, + monthly: 10.0, + }, + region_prices: [ + { + hourly: 0.018, + id: 'id-cgk', + monthly: 12.0, + }, + { + hourly: 0.021, + id: 'br-gru', + monthly: 14.0, + }, + ], + transfer: 0, +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index ef389c2c43a..b01a09ddf86 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -38,16 +38,19 @@ import { useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; -import { useNodebalancerCreateMutation } from 'src/queries/nodebalancers'; +import { + useNodeBalancerTypesQuery, + useNodebalancerCreateMutation, +} from 'src/queries/nodebalancers'; import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { NODEBALANCER_PRICE } from 'src/utilities/pricing/constants'; +import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; import { - getDCSpecificPrice, + getDCSpecificPriceByType, renderMonthlyPriceToCorrectDecimalPlace, } from 'src/utilities/pricing/dynamicPricing'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -99,6 +102,7 @@ const NodeBalancerCreate = () => { const { data: agreements } = useAccountAgreements(); const { data: profile } = useProfile(); const { data: regions } = useRegionsQuery(); + const { data: types } = useNodeBalancerTypesQuery(); const { error, @@ -418,10 +422,11 @@ const NodeBalancerCreate = () => { const regionLabel = regions?.find((r) => r.id === nodeBalancerFields.region) ?.label; - const price = getDCSpecificPrice({ - basePrice: NODEBALANCER_PRICE, + const price = getDCSpecificPriceByType({ regionId: nodeBalancerFields.region, + type: types?.[0], }); + const isInvalidPrice = Boolean(nodeBalancerFields.region && !price); const summaryItems = []; @@ -448,7 +453,9 @@ const NodeBalancerCreate = () => { if (nodeBalancerFields.region) { summaryItems.unshift({ - title: `$${renderMonthlyPriceToCorrectDecimalPlace(Number(price))}/month`, + title: `$${renderMonthlyPriceToCorrectDecimalPlace( + price ? Number(price) : undefined + )}/month`, }); } @@ -642,15 +649,20 @@ const NodeBalancerCreate = () => { justifyContent={'flex-end'} > diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6a2240bf840..39d8a0a25f2 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -70,6 +70,7 @@ import { nodeBalancerConfigFactory, nodeBalancerConfigNodeFactory, nodeBalancerFactory, + nodeBalancerTypeFactory, nodePoolFactory, notificationFactory, objectStorageBucketFactory, @@ -893,6 +894,10 @@ export const handlers = [ const nodeBalancers = nodeBalancerFactory.buildList(1); return HttpResponse.json(makeResourcePage(nodeBalancers)); }), + http.get('*/v4/nodebalancers/types', () => { + const nodeBalancerTypes = nodeBalancerTypeFactory.buildList(1); + return HttpResponse.json(makeResourcePage(nodeBalancerTypes)); + }), http.get('*/v4/nodebalancers/:nodeBalancerID', ({ params }) => { const nodeBalancer = nodeBalancerFactory.build({ id: Number(params.nodeBalancerID), diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 7accfedaabe..6dfdb690d7d 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -13,16 +13,12 @@ import { getNodeBalancerConfigs, getNodeBalancerFirewalls, getNodeBalancerStats, + getNodeBalancerTypes, getNodeBalancers, updateNodeBalancer, updateNodeBalancerConfig, } from '@linode/api-v4'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useMutation, @@ -40,11 +36,31 @@ import { queryPresets } from './base'; import { itemInListCreationHandler, itemInListMutationHandler } from './base'; import { profileQueries } from './profile'; +import type { + APIError, + Filter, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; + export const queryKey = 'nodebalancers'; export const NODEBALANCER_STATS_NOT_READY_API_MESSAGE = 'Stats are unavailable at this time.'; +const getAllNodeBalancerTypes = () => + getAll((params) => getNodeBalancerTypes(params))().then( + (results) => results.data + ); + +export const typesQueries = createQueryKeys('types', { + nodebalancers: { + queryFn: getAllNodeBalancerTypes, + queryKey: null, + }, +}); + const getIsTooEarlyForStats = (created?: string) => { if (!created) { return false; @@ -242,3 +258,9 @@ export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => () => getNodeBalancerFirewalls(nodebalancerId), queryPresets.oneTimeFetch ); + +export const useNodeBalancerTypesQuery = () => + useQuery({ + ...queryPresets.oneTimeFetch, + ...typesQueries.nodebalancers, + }); diff --git a/packages/manager/src/utilities/pricing/constants.ts b/packages/manager/src/utilities/pricing/constants.ts index a25f8ad2cc7..190b1b7d824 100644 --- a/packages/manager/src/utilities/pricing/constants.ts +++ b/packages/manager/src/utilities/pricing/constants.ts @@ -6,7 +6,6 @@ export interface ObjStoragePriceObject { // These values will eventually come from the API, but for now they are hardcoded and // used to generate the region based dynamic pricing. -export const NODEBALANCER_PRICE = 10; export const LKE_HA_PRICE = 60; export const OBJ_STORAGE_PRICE: ObjStoragePriceObject = { monthly: 5.0, diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts index 5ff20fc9745..54c04a177f0 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts @@ -1,7 +1,9 @@ +import { nodeBalancerTypeFactory } from 'src/factories'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getDCSpecificPrice, + getDCSpecificPriceByType, renderMonthlyPriceToCorrectDecimalPlace, } from './dynamicPricing'; import { getDynamicVolumePrice } from './dynamicVolumePrice'; @@ -51,6 +53,53 @@ describe('getDCSpecificPricingDisplay', () => { }); }); +describe('getDCSpecificPricingByType', () => { + const mockNodeBalancerType = nodeBalancerTypeFactory.build(); + + it('calculates dynamic pricing for a region without an increase', () => { + expect( + getDCSpecificPriceByType({ + regionId: 'us-east', + type: mockNodeBalancerType, + }) + ).toBe('10.00'); + }); + + it('calculates dynamic pricing for a region with an increase', () => { + expect( + getDCSpecificPriceByType({ + regionId: 'id-cgk', + type: mockNodeBalancerType, + }) + ).toBe('12.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'br-gru', + type: mockNodeBalancerType, + }) + ).toBe('14.00'); + }); + + it('handles an invalid price if region is not available', () => { + expect( + getDCSpecificPriceByType({ + regionId: 'us-east', + type: undefined, + }) + ).toBe(undefined); + }); + + it('handles an invalid price if type is not available', () => { + expect( + getDCSpecificPriceByType({ + regionId: undefined, + type: mockNodeBalancerType, + }) + ).toBe(undefined); + }); +}); + describe('renderMonthlyPriceToCorrectDecimalPlace', () => { it('renders monthly price to two decimal places if the price includes a decimal', () => { expect(renderMonthlyPriceToCorrectDecimalPlace(12.2)).toBe('12.20'); diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index 5373a1efc3e..3aacf9ac72f 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -1,7 +1,12 @@ import { UNKNOWN_PRICE } from './constants'; -import type { Region } from '@linode/api-v4'; +import type { PriceType, Region, RegionPriceObject } from '@linode/api-v4'; +export interface RegionPrice extends RegionPriceObject { + id: string; +} + +// TODO: Delete once all products are using /types endpoints. export interface DataCenterPricingOptions { /** * The base price for an entity. @@ -15,6 +20,18 @@ export interface DataCenterPricingOptions { regionId: Region['id'] | undefined; } +export interface DataCenterPricingByTypeOptions { + /** + * The `id` of the region we intended to get the price for. + * @example us-east + */ + regionId: Region['id'] | undefined; + /** + * The type data from a product's /types endpoint. + */ + type: PriceType | undefined; +} + // The key is a region id and the value is the percentage increase in price. export const priceIncreaseMap = { 'br-gru': 0.4, // Sao Paulo @@ -61,6 +78,32 @@ export const getDCSpecificPrice = ({ return basePrice.toFixed(2); }; +/** + * This function is used to calculate the dynamic pricing for a given entity, based on potential region increased costs. + * @example + * const price = getDCSpecificPrice({ + * type: volumeType, // From the volumes/types endpoint + * regionId: 'us-east', + * }); + * @returns a data center specific price or undefined if this cannot be calculated + */ +export const getDCSpecificPriceByType = ({ + regionId, + type, +}: DataCenterPricingByTypeOptions): string | undefined => { + if (!regionId || !type) { + return undefined; + } + + // Apply the DC-specific price if it exists; otherwise, use the base price. + const price = + type.region_prices.find((region_price: RegionPrice) => { + return region_price.id === regionId; + })?.monthly ?? type.price.monthly; + + return price?.toFixed(2) ?? undefined; +}; + export const renderMonthlyPriceToCorrectDecimalPlace = ( monthlyPrice: null | number | undefined ) => {