Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: [M3-7382] - Use object-storage/types endpoint for pricing data #10468

Merged
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-10468-added-1716219170108.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Added
---

New endpoint for `object-storage/types` ([#10468](https://github.com/linode/manager/pull/10468))
2 changes: 2 additions & 0 deletions packages/api-v4/src/object-storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export * from './objects';
export * from './objectStorageKeys';

export * from './types';

export * from './prices';
16 changes: 16 additions & 0 deletions packages/api-v4/src/object-storage/prices.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of the other object-storage endpoint files seemed like a good fit for this, so I created a new file, prices.ts. I didn't name it types.ts, since that's another file. This seemed like the most straight-forward way to add the new endpoint.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Params, PriceType, ResourcePage } from 'src/types';
import { API_ROOT } from '../constants';
import Request, { setMethod, setParams, setURL } from '../request';

/**
* getObjectStorageTypes
*
* Return a paginated list of available Object Storage types; used for pricing.
* This endpoint does not require authentication.
*/
export const getObjectStorageTypes = (params?: Params) =>
Request<ResourcePage<PriceType>>(
setURL(`${API_ROOT}/object-storage/types`),
setMethod('GET'),
setParams(params)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Use dynamic pricing with `object-storage/types` endpoint ([#10468](https://github.com/linode/manager/pull/10468))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these constants are old and can be cleaned up. They were leftovers from earlier stages of the project, pre-full GA.

Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,6 @@ import { makeFeatureFlagData } from 'support/util/feature-flags';
// Various messages, notes, and warnings that may be shown when enabling Object Storage
// under different circumstances.
const objNotes = {
// When enabling OBJ using a region with a regular pricing structure, when OBJ DC-specific pricing is disabled.
regularPricing: /Linode Object Storage costs a flat rate of \$5\/month, and includes 250 GB of storage and 1 TB of outbound data transfer. Beyond that, it.*s \$0.02 per GB per month./,

// When enabling OBJ using a region with special pricing during the free beta period (OBJ DC-specific pricing is disabled).
dcSpecificBetaPricing: /Object Storage for .* is currently in beta\. During the beta period, Object Storage in these regions is free\. After the beta period, customers will be notified before charges for this service begin./,

// When enabling OBJ without having selected a region, when OBJ DC-specific pricing is disabled.
dcPricingGenericExplanation:
'Pricing for monthly rate and overage costs will depend on the data center you select for deployment.',

// When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled.
objDCPricing:
'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.',
Expand Down
46 changes: 46 additions & 0 deletions packages/manager/src/factories/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,49 @@ export const volumeTypeFactory = Factory.Sync.makeFactory<PriceType>({
],
transfer: 0,
});

export const objectStorageTypeFactory = Factory.Sync.makeFactory<PriceType>({
id: 'objectstorage',
label: 'Object Storage',
price: {
hourly: 0.0075,
monthly: 5.0,
},
region_prices: [
{
hourly: 0.0075,
id: 'id-cgk',
monthly: 5.0,
},
{
hourly: 0.0075,
id: 'br-gru',
monthly: 5.0,
},
],
transfer: 1000,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not specific to your work, just curious to know what transfer represents in the payload?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the 1TB transfer quota (added to the global network transfer pool) that comes with enabling Object Storage. (Docs)

});

export const objectStorageOverageTypeFactory = Factory.Sync.makeFactory<PriceType>(
{
id: 'objectstorage-overage',
label: 'Object Storage Overage',
price: {
hourly: 0.02,
monthly: null,
},
region_prices: [
{
hourly: 0.024,
id: 'id-cgk',
monthly: null,
},
{
hourly: 0.028,
id: 'br-gru',
monthly: null,
},
],
transfer: 0,
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { AccountSettings } from '@linode/api-v4/lib/account';
import { cancelObjectStorage } from '@linode/api-v4/lib/object-storage';
import { APIError } from '@linode/api-v4/lib/types';
import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import * as React from 'react';

import { Accordion } from 'src/components/Accordion';
import { Button } from 'src/components/Button/Button';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useCreateBucketMutation,
useObjectStorageBuckets,
useObjectStorageClusters,
useObjectStorageTypesQuery,
} from 'src/queries/objectStorage';
import { useProfile } from 'src/queries/profile';
import { useRegionsQuery } from 'src/queries/regions/regions';
Expand All @@ -30,6 +31,7 @@ import { getGDPRDetails } from 'src/utilities/formatRegion';
import { EnableObjectStorageModal } from '../EnableObjectStorageModal';
import ClusterSelect from './ClusterSelect';
import { OveragePricing } from './OveragePricing';
import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants';

interface Props {
isOpen: boolean;
Expand Down Expand Up @@ -74,6 +76,14 @@ export const CreateBucketDrawer = (props: Props) => {
: undefined,
});

const {
data: types,
isError: isErrorTypes,
isLoading: isLoadingTypes,
} = useObjectStorageTypesQuery();
mjac0bs marked this conversation as resolved.
Show resolved Hide resolved

const isInvalidPrice = !types || isErrorTypes;

const {
error,
isLoading,
Expand Down Expand Up @@ -199,9 +209,15 @@ export const CreateBucketDrawer = (props: Props) => {
'data-testid': 'create-bucket-button',
disabled:
!formik.values.cluster ||
(showGDPRCheckbox && !hasSignedAgreement),
(showGDPRCheckbox && !hasSignedAgreement) ||
isErrorTypes,
label: 'Create Bucket',
loading: isLoading,
loading:
isLoading || Boolean(clusterRegion?.[0]?.id && isLoadingTypes),
tooltipText:
!isLoadingTypes && isInvalidPrice
? PRICES_RELOAD_ERROR_NOTICE_TEXT
: '',
type: 'submit',
}}
secondaryButtonProps={{ label: 'Cancel', onClick: onClose }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { fireEvent } from '@testing-library/react';
import React from 'react';

import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants';
import {
objectStorageOverageTypeFactory,
objectStorageTypeFactory,
} from 'src/factories';
import {
OBJ_STORAGE_PRICE,
UNKNOWN_PRICE,
} from 'src/utilities/pricing/constants';
import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing';
import { renderWithTheme } from 'src/utilities/testHelpers';

Expand All @@ -11,23 +18,47 @@ import {
OveragePricing,
} from './OveragePricing';

describe('OveragePricing', () => {
const mockObjectStorageTypes = [
objectStorageTypeFactory.build(),
objectStorageOverageTypeFactory.build(),
];

const queryMocks = vi.hoisted(() => ({
useObjectStorageTypesQuery: vi.fn().mockReturnValue({}),
}));

vi.mock('src/queries/objectStorage', async () => {
const actual = await vi.importActual('src/queries/objectStorage');
return {
...actual,
useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery,
};
});

describe('OveragePricing', async () => {
beforeAll(() => {
queryMocks.useObjectStorageTypesQuery.mockReturnValue({
data: mockObjectStorageTypes,
});
});

it('Renders base overage pricing for a region without price increases', () => {
const { getByText } = renderWithTheme(
<OveragePricing regionId="us-east" />
);
getByText(`$${OBJ_STORAGE_PRICE.storage_overage} per GB`, { exact: false });
getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, {
exact: false,
});
getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, {
exact: false,
});
});

it('Renders DC-specific overage pricing for a region with price increases', () => {
const { getByText } = renderWithTheme(<OveragePricing regionId="br-gru" />);
getByText(
`$${objectStoragePriceIncreaseMap['br-gru'].storage_overage} per GB`,
{ exact: false }
);
getByText(`$${mockObjectStorageTypes[1].region_prices[1].hourly} per GB`, {
exact: false,
});
getByText(
`$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`,
{ exact: false }
Expand Down Expand Up @@ -59,4 +90,40 @@ describe('OveragePricing', () => {
expect(tooltip).toBeInTheDocument();
expect(getByText(GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT)).toBeVisible();
});

it('Renders a loading state while prices are loading', () => {
queryMocks.useObjectStorageTypesQuery.mockReturnValue({
isLoading: true,
});

const { getByRole } = renderWithTheme(
<OveragePricing regionId="us-east" />
);

expect(getByRole('progressbar')).toBeVisible();
});

it('Renders placeholder unknown pricing when there is an error', () => {
queryMocks.useObjectStorageTypesQuery.mockReturnValue({
isError: true,
});

const { getAllByText } = renderWithTheme(
<OveragePricing regionId="us-east" />
);

expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1);
});

it('Renders placeholder unknown pricing when prices are undefined', () => {
queryMocks.useObjectStorageTypesQuery.mockReturnValue({
data: undefined,
});

const { getAllByText } = renderWithTheme(
<OveragePricing regionId="us-east" />
);

expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import { Region } from '@linode/api-v4';
import { styled } from '@mui/material/styles';
import React from 'react';

import { CircularProgress } from 'src/components/CircularProgress';
import { TextTooltip } from 'src/components/TextTooltip';
import { Typography } from 'src/components/Typography';
import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants';
import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing';
import { useObjectStorageTypesQuery } from 'src/queries/objectStorage';
import {
OBJ_STORAGE_PRICE,
UNKNOWN_PRICE,
} from 'src/utilities/pricing/constants';
import {
getDCSpecificPriceByType,
objectStoragePriceIncreaseMap,
} from 'src/utilities/pricing/dynamicPricing';

interface Props {
regionId: Region['id'];
Expand All @@ -18,23 +26,40 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT =

export const OveragePricing = (props: Props) => {
const { regionId } = props;

const { data: types, isError, isLoading } = useObjectStorageTypesQuery();

const overageType = types?.find(
(type) => type.id === 'objectstorage-overage'
);

const storageOveragePrice = getDCSpecificPriceByType({
decimalPrecision: 3,
interval: 'hourly',
regionId,
type: overageType,
});

const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty(
regionId
);

return (
return isLoading ? (
<CircularProgress size={16} sx={{ marginTop: 2 }} />
) : (
<>
<StyledTypography>
For this region, additional storage costs{' '}
<strong>
$
{isDcSpecificPricingRegion
? objectStoragePriceIncreaseMap[regionId].storage_overage
: OBJ_STORAGE_PRICE.storage_overage}{' '}
{storageOveragePrice && !isError
? parseFloat(storageOveragePrice)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using parseFloat here to truncate trailing zeros - e.g. we will display $0.02 and not $0.020, but otherwise display to 3 decimal places for the other overage prices.

: UNKNOWN_PRICE}{' '}
per GB
</strong>
.
</StyledTypography>

<StyledTypography>
Outbound transfer will cost{' '}
<strong>
Expand Down
Loading
Loading