diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index e32c94bd071..0849e7f6f3f 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -1,7 +1,7 @@ import { BETA_API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; -import type { Quota, QuotaType, QuotaUsage } from './types'; +import type { Quota, QuotaServiceType, QuotaUsage } from './types'; import type { Filter, ResourcePage as Page, Params } from 'src/types'; /** @@ -9,34 +9,40 @@ import type { Filter, ResourcePage as Page, Params } from 'src/types'; * * Returns the details for a single quota within a particular service specified by `type`. * - * @param type { QuotaType } retrieve a quota within this service type. + * @param quotaService { QuotaServiceType } retrieve a quota within this service type. * @param id { number } the quota ID to look up. - * @param collection { string } quota collection name (quotas/global-quotas). + * @param apiCollection { string } quota collection name (quotas/global-quotas). */ -export const getQuota = (type: QuotaType, collection: string, id: number) => +export const getQuota = ( + quotaService: QuotaServiceType, + apiCollection: string, + id: number, +) => Request( - setURL(`${BETA_API_ROOT}/${type}/${collection}/${id}`), + setURL(`${BETA_API_ROOT}/${quotaService}/${apiCollection}/${id}`), setMethod('GET'), ); /** * getQuotas * - * Returns a paginated list of quotas for a particular service specified by `type`. + * Returns a paginated list of quotas for a particular service specified by `quotaService`. * * This request can be filtered on `quota_name`, `service_name` and `scope`. * - * @param type { QuotaType } retrieve quotas within this service type. - * @param collection { string } quota collection name (quotas/global-quotas). + * @param quotaService { QuotaServiceType } retrieve quotas within this service quotaService. + * @param apiCollection { string } quota API collection name (e.g. quotas, global-quotas, etc.). + * @param params { Params } query params to include in the request. + * @param filter { Filter } filters to include in the request. */ export const getQuotas = ( - type: QuotaType, - collection: string, + quotaService: QuotaServiceType, + apiCollection: string, params: Params = {}, filter: Filter = {}, ) => Request>( - setURL(`${BETA_API_ROOT}/${type}/${collection}`), + setURL(`${BETA_API_ROOT}/${quotaService}/${apiCollection}`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -47,16 +53,16 @@ export const getQuotas = ( * * Returns the usage for a single quota within a particular service specified by `type`. * - * @param type { QuotaType } retrieve a quota within this service type. - * @param collection { string } quota collection name (quotas/global-quotas). + * @param quotaService { QuotaServiceType } retrieve a quota within this service type. + * @param apiCollection { string } quota collection name (quotas/global-quotas). * @param id { string } the quota ID to look up. */ export const getQuotaUsage = ( - type: QuotaType, - collection: string, + quotaService: QuotaServiceType, + apiCollection: string, id: string, ) => Request( - setURL(`${BETA_API_ROOT}/${type}/${collection}/${id}/usage`), + setURL(`${BETA_API_ROOT}/${quotaService}/${apiCollection}/${id}/usage`), setMethod('GET'), ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index c21a9beff78..33d5fd80711 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -1,40 +1,22 @@ import type { ObjectStorageEndpointTypes } from 'src/object-storage'; import type { Region } from 'src/regions'; -export enum QuotaResourceMetrics { - BUCKET = 'bucket', - BYTE = 'byte', - BYTE_PER_SECOND = 'byte_per_second', - CLUSTER = 'cluster', - CPU = 'CPU', - GPU = 'GPU', - OBJECT = 'object', - REQUEST = 'request', - VPU = 'VPU', -} +export type LinodeQuotaResourceMetric = 'CPU' | 'GPU' | 'VPU'; +export type LkeQuotaResourceMetric = 'cluster'; +export type ObjectStorageEndpointQuotaResourceMetric = + | 'bucket' + | 'byte' + | 'byte_per_second' + | 'object' + | 'request'; +export type ObjectStorageGlobalQuotaResourceMetric = 'key'; -/** - * A Quota is a service used limit that is rated based on service metrics such - * as vCPUs used, instances or storage size. - */ -export interface Quota { +interface QuotaCommon { /** * Longer explanatory description for the quota. */ description: string; - /** - * The OBJ endpoint type to which this limit applies. - * - * For OBJ limits only. - */ - endpoint_type?: ObjectStorageEndpointTypes; - - /** - * Sets usage column to be n/a when value is false. - */ - has_usage?: boolean; - /** * A unique identifier for the quota. */ @@ -52,31 +34,80 @@ export interface Quota { quota_name: string; /** - * Customer facing id describing the quota. + * The unit of measurement for this service limit. */ - quota_type: string; + resource_metric: T; +} +interface QuotaCommonWithRegionApplied extends QuotaCommon { /** * The region slug to which this limit applies. * * OBJ limits are applied by endpoint, not region. * This below really just is a `string` type but being verbose helps with reading comprehension. */ - region_applied?: 'global' | Region['id']; + region_applied: 'global' | Region['id']; +} +interface QuotaCommonWithUsage extends QuotaCommon { /** - * The unit of measurement for this service limit. + * Determines whether usage information is provided for this quota. + */ + has_usage: boolean; +} + +export type LinodeQuota = + QuotaCommonWithRegionApplied; + +export type LkeQuota = QuotaCommonWithRegionApplied; + +export interface ObjectStorageGlobalQuota + extends QuotaCommon { + /** + * Represents the quota type. + */ + quota_type: 'keys'; +} + +export interface ObjectStorageEndpointQuota + extends QuotaCommonWithUsage { + /** + * The OBJ endpoint type to which this limit applies. + * + */ + endpoint_type: ObjectStorageEndpointTypes; + + /** + * Represents the quota type. */ - resource_metric: QuotaResourceMetrics; + quota_type: + | 'obj-buckets' + | 'obj-bytes' + | 'obj-objects' + | 'obj-per-ip-concurrent-requests' + | 'obj-per-ip-egress-throughput' + | 'obj-per-ip-ingress-throughput' + | 'obj-total-concurrent-requests' + | 'obj-total-egress-throughput' + | 'obj-total-ingress-throughput'; /** * The S3 endpoint URL to which this limit applies. * - * For OBJ limits only. */ - s3_endpoint?: string; + s3_endpoint: string; } +/** + * A Quota is a service used limit that is rated based on service metrics such + * as vCPUs used, instances or storage size. + */ +export type Quota = + | LinodeQuota + | LkeQuota + | ObjectStorageEndpointQuota + | ObjectStorageGlobalQuota; + /** * A usage limit for a given Quota based on service metrics such * as vCPUs, instances or storage size. @@ -97,10 +128,8 @@ export interface QuotaUsage { usage: null | number; } -export const quotaTypes = { - linode: 'Linodes', - lke: 'Kubernetes', - 'object-storage': 'Object Storage', -} as const; - -export type QuotaType = keyof typeof quotaTypes; +/** + * Represents the type of service for a given quota, e.g. Linodes, Object Storage, etc. + * The type must match the service part of the quota endpoint paths. + */ +export type QuotaServiceType = 'linode' | 'lke' | 'object-storage'; diff --git a/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts b/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts index 4962af2027d..3f8a7d5461a 100644 --- a/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts @@ -1,4 +1,3 @@ -import { QuotaResourceMetrics } from '@linode/api-v4'; import { regionFactory } from '@linode/utilities'; import { profileFactory } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -20,10 +19,14 @@ import { randomDomainName, randomLabel } from 'support/util/random'; import { supportTicketFactory } from 'src/factories'; import { objectStorageEndpointsFactory } from 'src/factories'; -import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; +import { + objEndpointQuotaFactory, + quotaUsageFactory, +} from 'src/factories/quotas'; +import { objectStorageQuotaService } from 'src/features/Account/Quotas/quotaServices'; import { getQuotaIncreaseMessage } from 'src/features/Account/Quotas/utils'; -import type { Quota } from '@linode/api-v4'; +import type { ObjectStorageEndpointQuota, Quota } from '@linode/api-v4'; const mockFeatureFlags = { limitsEvolution: { @@ -33,7 +36,7 @@ const mockFeatureFlags = { objectStorageGlobalQuotas: false, }; -const placeholderText = 'Select an Object Storage S3 endpoint'; +const placeholderText = 'Select an Object Storage endpoint'; const mockDomain = randomDomainName(); @@ -68,34 +71,34 @@ const mockSelectedEndpoint = mockEndpoints[1]; const selectedDomain = mockSelectedEndpoint.s3_endpoint || ''; const mockQuotas = [ - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-bytes-${selectedDomain}`, quota_type: 'obj-bytes', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, quota_limit: 10, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.BYTE, + resource_metric: 'byte', s3_endpoint: selectedDomain, }), - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-buckets-${selectedDomain}`, quota_type: 'obj-buckets', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, quota_limit: 78, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.BUCKET, + resource_metric: 'bucket', s3_endpoint: selectedDomain, }), - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-objects-${selectedDomain}`, quota_type: 'obj-objects', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, quota_limit: 400, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.OBJECT, + resource_metric: 'object', s3_endpoint: selectedDomain, }), ]; @@ -151,7 +154,7 @@ describe('Quota workflow tests', () => { describe('Quota storage table', () => { it('Quotas and quota usages display properly', () => { - cy.visitWithLogin('/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); @@ -175,7 +178,7 @@ describe('Quota workflow tests', () => { cy.wait(['@getQuotas', '@getQuotaUsages']); - cy.get('table[data-testid="table-endpoint-quotas"]') + cy.get('[data-testid="quotas-table-obj-endpoint"]') .find('tbody') .within(() => { cy.get('[data-testid="table-row-empty"]').should('not.exist'); @@ -223,34 +226,34 @@ describe('Quota workflow tests', () => { const updatedEndpoint = mockEndpoints[mockEndpoints.length - 1]; const updatedDomain = updatedEndpoint.s3_endpoint || ''; const updatedQuotas = [ - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-bytes-${updatedDomain}`, quota_type: 'obj-bytes', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 20, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.BYTE, + resource_metric: 'byte', s3_endpoint: updatedDomain, }), - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-buckets-${updatedDomain}`, quota_type: 'obj-buckets', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 122, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.BUCKET, + resource_metric: 'bucket', s3_endpoint: updatedDomain, }), - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-objects-${updatedDomain}`, quota_type: 'obj-objects', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 450, quota_name: randomLabel(15), - resource_metric: QuotaResourceMetrics.OBJECT, + resource_metric: 'object', s3_endpoint: updatedDomain, }), ]; @@ -295,10 +298,7 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .clear(); - ui.autocomplete - .findByLabel('Object Storage Endpoint') - .type(updatedDomain); + .click(); ui.autocompletePopper .findByTitle(updatedDomain, { exact: false }) .should('be.visible') @@ -306,7 +306,7 @@ describe('Quota workflow tests', () => { cy.wait(['@getUpdatedQuotas', '@getUpdatedQuotaUsages']); - cy.get('table[data-testid="table-endpoint-quotas"]') + cy.get('[data-testid="quotas-table-obj-endpoint"]') .find('tbody') .within(() => { cy.get('[data-testid="table-row-empty"]').should('not.exist'); @@ -354,7 +354,7 @@ describe('Quota workflow tests', () => { const errorMsg = 'Request failed.'; mockGetObjectStorageQuotaError(errorMsg).as('getQuotasError'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete @@ -366,7 +366,7 @@ describe('Quota workflow tests', () => { .should('be.visible') .click(); cy.wait('@getQuotasError'); - cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-testid="quotas-table-obj-endpoint"]').within(() => { cy.get('[data-qa-error-msg="true"]') .should('be.visible') .should('have.text', errorMsg); @@ -403,7 +403,7 @@ describe('Quota workflow tests', () => { }, ]; mockQuotas.forEach((mockQuota: Quota, index: number) => { - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait([ '@getFeatureFlags', '@getProfile', @@ -422,10 +422,9 @@ describe('Quota workflow tests', () => { profile: mockProfile, quantity: expectedResults[index].newQuotaLimit, quota: mockQuota, - selectedService: { - label: 'Object Storage', - value: 'object-storage', - }, + service: objectStorageQuotaService(), + scope: 'obj-endpoint', + scopeValue: (mockQuota as ObjectStorageEndpointQuota).s3_endpoint, }).description, }); cy.findByPlaceholderText(placeholderText) @@ -524,7 +523,7 @@ describe('Quota workflow tests', () => { it('Quota error results in error message being displayed', () => { const errorMsg = 'Request failed.'; mockGetObjectStorageQuotaError(errorMsg).as('getQuotasError'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait('@getObjectStorageEndpoints'); @@ -538,7 +537,7 @@ describe('Quota workflow tests', () => { .click(); cy.wait('@getQuotasError'); - cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-testid="quotas-table-obj-endpoint"]').within(() => { cy.get('[data-qa-error-msg="true"]') .should('be.visible') .should('have.text', errorMsg); @@ -551,7 +550,7 @@ describe('Quota workflow tests', () => { mockApiInternalUser(); const errorMessage = 'Ticket creation failed.'; mockCreateSupportTicketError(errorMessage).as('createTicketError'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); @@ -601,7 +600,7 @@ describe('Quota workflow tests', () => { requestForIncreaseDisabledForInternalAccountsOnly: false, }, }).as('getFeatureFlags'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') @@ -635,7 +634,7 @@ describe('Quota workflow tests', () => { mockApiInternalUser(); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') @@ -665,7 +664,7 @@ describe('Quota workflow tests', () => { requestForIncreaseDisabledForAll: true, }, }).as('getFeatureFlags'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') @@ -696,7 +695,7 @@ describe('Quota workflow tests', () => { }, }).as('getFeatureFlags'); mockApiInternalUser(); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') @@ -727,7 +726,7 @@ describe('Quota workflow tests', () => { requestForIncreaseDisabledForInternalAccountsOnly: true, }, }).as('getFeatureFlags'); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') @@ -759,7 +758,7 @@ describe('Quota workflow tests', () => { }, }).as('getFeatureFlags'); mockApiInternalUser(); - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas?service=object-storage'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); ui.autocomplete .findByLabel('Object Storage Endpoint') diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts index 49758107569..6f9e980b878 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts @@ -1,4 +1,3 @@ -import { QuotaResourceMetrics } from '@linode/api-v4'; import { regionFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -11,7 +10,10 @@ import { ui } from 'support/ui'; import { randomDomainName, randomLabel } from 'support/util/random'; import { objectStorageEndpointsFactory } from 'src/factories'; -import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; +import { + objEndpointQuotaFactory, + quotaUsageFactory, +} from 'src/factories/quotas'; const mockFeatureFlags = { objSummaryPage: true, @@ -51,35 +53,36 @@ const mockEndpoints = [ const mockSelectedEndpoint = mockEndpoints[1]; const selectedDomain = mockSelectedEndpoint.s3_endpoint || ''; +// the order must match the display order in the UI const mockQuotas = [ - quotaFactory.build({ + objEndpointQuotaFactory.build({ quota_id: `obj-bytes-${selectedDomain}`, quota_type: 'obj-bytes', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, quota_limit: 10, quota_name: 'Total Capacity', - resource_metric: QuotaResourceMetrics.BYTE, + resource_metric: 'byte', s3_endpoint: selectedDomain, }), - quotaFactory.build({ - quota_id: `obj-buckets-${selectedDomain}`, - quota_type: 'obj-buckets', + objEndpointQuotaFactory.build({ + quota_id: `obj-objects-${selectedDomain}`, + quota_type: 'obj-objects', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, - quota_limit: 78, + quota_limit: 400, quota_name: 'Number of Objects', - resource_metric: QuotaResourceMetrics.BUCKET, + resource_metric: 'object', s3_endpoint: selectedDomain, }), - quotaFactory.build({ - quota_id: `obj-objects-${selectedDomain}`, - quota_type: 'obj-objects', + objEndpointQuotaFactory.build({ + quota_id: `obj-buckets-${selectedDomain}`, + quota_type: 'obj-buckets', description: randomLabel(50), endpoint_type: mockSelectedEndpoint.endpoint_type, - quota_limit: 400, + quota_limit: 78, quota_name: 'Number of Buckets', - resource_metric: QuotaResourceMetrics.OBJECT, + resource_metric: 'bucket', s3_endpoint: selectedDomain, }), ]; @@ -108,30 +111,25 @@ describe('Object storage summary page test', () => { 'getObjectStorageEndpoints' ); - cy.wrap(selectedDomain).as('selectedDomain'); - cy.wrap(mockEndpoints).as('mockEndpoints'); - cy.wrap(mockQuotas).as('mockQuotas'); - cy.wrap(mockQuotaUsages).as('mockQuotaUsages'); - mockGetObjectStorageQuotas(selectedDomain, mockQuotas).as('getQuotas'); mockGetObjectStorageQuotaUsages( selectedDomain, 'bytes', mockQuotaUsages[0] - ); + ).as('getQuotaUsageBytes'); mockGetObjectStorageQuotaUsages( selectedDomain, - 'buckets', + 'objects', mockQuotaUsages[1] - ); + ).as('getQuotaUsageObjects'); mockGetObjectStorageQuotaUsages( selectedDomain, - 'objects', + 'buckets', mockQuotaUsages[2] - ).as('getQuotaUsages'); + ).as('getQuotaUsageBuckets'); }); it('should display table with user quotas', () => { @@ -152,7 +150,12 @@ describe('Object storage summary page test', () => { .click(); endpointSelect.click(); - cy.wait(['@getQuotas', '@getQuotaUsages']); + cy.wait([ + '@getQuotas', + '@getQuotaUsageBytes', + '@getQuotaUsageBuckets', + '@getQuotaUsageObjects', + ]); cy.findByTestId('table-endpoint-summary') .find('tbody') diff --git a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx index 51c230bdc35..1eb39ebc1bc 100644 --- a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx +++ b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx @@ -1,4 +1,3 @@ -import { QuotaResourceMetrics } from '@linode/api-v4'; import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -44,11 +43,7 @@ describe('QuotaUsageBanner', () => { 'should display correct byte quota usage text for $usage bytes used out of $limit bytes', ({ usage, limit, expectedText }) => { const { getByText } = renderWithTheme( - + ); const quotaUsageText = getByText(expectedText); expect(quotaUsageText).toBeVisible(); diff --git a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx index e5acc41f6cc..eede34e761a 100644 --- a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx +++ b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx @@ -4,11 +4,11 @@ import * as React from 'react'; import { BarPercent } from 'src/components/BarPercent'; import { convertResourceMetric } from 'src/features/Account/Quotas/utils'; -import type { QuotaResourceMetrics } from '@linode/api-v4'; +import type { Quota } from '@linode/api-v4'; interface Props { limit: number; - resourceMetric: QuotaResourceMetrics; + resourceMetric: Quota['resource_metric']; usage: number; } diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts index 50d2d2dbc3d..0a3d613e47a 100644 --- a/packages/manager/src/factories/quotas.ts +++ b/packages/manager/src/factories/quotas.ts @@ -1,19 +1,43 @@ -import { QuotaResourceMetrics } from '@linode/api-v4/lib/quotas/types'; import { Factory } from '@linode/utilities'; -import type { Quota, QuotaUsage } from '@linode/api-v4/lib/quotas/types'; +import type { + LinodeQuota, + LkeQuota, + ObjectStorageEndpointQuota, +} from '@linode/api-v4'; +import type { QuotaUsage } from '@linode/api-v4/lib/quotas/types'; -export const quotaFactory = Factory.Sync.makeFactory({ - description: 'Maximimum number of vCPUs allowed', +export const linodeQuotaFactory = Factory.Sync.makeFactory({ + description: 'Maximum number of vCPUs allowed', quota_id: Factory.each((id) => id.toString()), quota_limit: 50, quota_name: 'Linode Dedicated vCPUs', - quota_type: 'linode-dedicated-cpus', region_applied: 'us-east', - resource_metric: QuotaResourceMetrics.CPU, - has_usage: true, + resource_metric: 'CPU', }); +export const lkeQuotaFactory = Factory.Sync.makeFactory({ + description: 'Maximum allowed number of Clusters', + quota_id: Factory.each((id) => id.toString()), + quota_limit: 50, + quota_name: 'LKE Clusters', + region_applied: 'us-east', + resource_metric: 'cluster', +}); + +export const objEndpointQuotaFactory = + Factory.Sync.makeFactory({ + description: 'Max number of buckets allowed', + quota_id: Factory.each((id) => id.toString()), + quota_limit: 1000, + quota_name: 'Buckets', + quota_type: 'obj-buckets', + resource_metric: 'bucket', + has_usage: true, + s3_endpoint: 'endpoint-1.linodeobjects.com', + endpoint_type: 'E1', + }); + export const quotaUsageFactory = Factory.Sync.makeFactory({ quota_limit: 50, usage: 25, diff --git a/packages/manager/src/features/Account/Quotas/QuotaServicePanel.tsx b/packages/manager/src/features/Account/Quotas/QuotaServicePanel.tsx new file mode 100644 index 00000000000..1238241f4d5 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/QuotaServicePanel.tsx @@ -0,0 +1,124 @@ +import { CircleProgress, Notice, Paper, Select, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import type { QuotaServiceType } from '@linode/api-v4'; +import type { SelectOption } from '@linode/ui'; +import type { Theme } from '@mui/material'; +import type { QuotaService } from 'src/features/Account/Quotas/quotaServices'; + +type ServiceSelectOption = SelectOption; + +type ServicePanelProps = { + availableServices: null | QuotaService[]; + isFetchingServices: boolean; + onServiceChange: (service: null | QuotaService) => void; + selectedService: null | QuotaService; +}; + +export const QuotaServicePanel: React.FC = ({ + availableServices, + selectedService, + isFetchingServices, + onServiceChange, +}) => { + const servicesByType = React.useMemo(() => { + if (!availableServices) { + return null; + } + return new Map( + availableServices.map((service) => [service.type, service]) + ); + }, [availableServices]); + + const serviceOptions: ServiceSelectOption[] | undefined = React.useMemo( + () => + availableServices?.map((service) => ({ + label: service.label, + value: service.type, + })) ?? [], + [availableServices] + ); + + const selectedOption = React.useMemo( + () => + serviceOptions.find( + (service) => service.value === selectedService?.type + ) ?? null, + [selectedService, serviceOptions] + ); + + React.useEffect(() => { + if ( + selectedService && + servicesByType && + !servicesByType.has(selectedService.type) + ) { + onServiceChange(null); + } + }, [onServiceChange, selectedService, servicesByType]); + + return ( + ({ + marginTop: theme.spacingFunction(16), + })} + variant="outlined" + > + {isFetchingServices ? ( +
+ +
+ ) : availableServices?.length === 0 ? ( + + There are no services that have quotas available at this time. + + ) : ( + <> + Select a service to view your quotas below. + + isOptionEqualToValue={(option, value) => + option.value === value.value + } + label="Service" + onChange={(_event, value) => { + if (!value) { + onServiceChange(null); + return; + } + + onServiceChange(servicesByType?.get(value.value) ?? null); + }} + options={serviceOptions} + placeholder="Select a Service" + value={selectedOption} + /> + {selectedService?.type === 'object-storage' && ( + + + Details on{' '} + + {' '} + account Quotas + {' '} + and{' '} + + Object Storage quotas and limits + {' '} + can be found in our product documentation. + + + )} + + )} +
+ ); +}; diff --git a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx index 0f1e09932fc..fb82527f60f 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx @@ -1,128 +1,125 @@ -import { QueryClient } from '@tanstack/react-query'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Quotas } from './Quotas'; +import { objectStorageQuotaService } from './quotaServices'; -import type { Quota, QuotaUsage } from '@linode/api-v4'; - -const queryMocks = vi.hoisted(() => ({ - getQuotasFilters: vi.fn().mockReturnValue({}), - getQuotaVisibilityFilter: vi - .fn() - .mockReturnValue({ isVisible: (quota: Quota) => true }), - getQuotaMapper: vi - .fn() - .mockReturnValue({ mapQuota: (quota: Quota, usage: QuotaUsage) => quota }), - useFlags: vi.fn().mockReturnValue({}), - useGetLocationsForQuotaService: vi.fn().mockReturnValue({}), - useObjectStorageEndpoints: vi.fn().mockReturnValue({}), - convertResourceMetric: vi.fn().mockReturnValue({}), - pluralizeMetric: vi.fn().mockReturnValue({}), +const mocks = vi.hoisted(() => ({ + useQuotaServices: vi.fn(), + useNavigate: vi.fn(), + useSearch: vi.fn(), })); -vi.mock('src/hooks/useFlags', () => { - const actual = vi.importActual('src/hooks/useFlags'); - return { - ...actual, - useFlags: queryMocks.useFlags, - }; -}); +vi.mock('src/features/Account/Quotas/hooks/useQuotaServices', () => ({ + useQuotaServices: mocks.useQuotaServices, +})); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, - useObjectStorageEndpoints: queryMocks.useObjectStorageEndpoints, + useNavigate: mocks.useNavigate, + useSearch: mocks.useSearch, }; }); -vi.mock('./utils', () => ({ - getQuotasFilters: queryMocks.getQuotasFilters, - getQuotaVisibilityFilter: queryMocks.getQuotaVisibilityFilter, - getQuotaMapper: queryMocks.getQuotaMapper, - useGetLocationsForQuotaService: queryMocks.useGetLocationsForQuotaService, - convertResourceMetric: queryMocks.convertResourceMetric, - pluralizeMetric: queryMocks.pluralizeMetric, - QUOTA_ROW_MIN_HEIGHT: 58, +vi.mock('src/features/Account/Quotas/QuotaServicePanel', () => ({ + QuotaServicePanel: (props: Record) => ( +
+ isFetching:{String(props.isFetchingServices)} + selectedService:{String(Boolean(props.selectedService))} + availableServicesLength:{String(props.availableServices?.length ?? 0)} +
+ ), +})); + +vi.mock('src/features/Account/Quotas/QuotasPanel/QuotasPanel', () => ({ + QuotasPanel: (props: Record) => ( +
scope:{String(props.scope)}
+ ), })); describe('Quotas', () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, + beforeEach(() => { + mocks.useQuotaServices.mockReset(); + mocks.useNavigate.mockReset(); + mocks.useSearch.mockReset(); }); - it('renders the component with initial state', async () => { - const { getByText } = renderWithTheme(, { - queryClient, - }); + it('renders loading state when services are being fetched', () => { + mocks.useQuotaServices.mockReturnValue({ data: null, isFetching: true }); + // useSearch not used in this branch but stub anyway + mocks.useSearch.mockReturnValue({}); + mocks.useNavigate.mockReturnValue(vi.fn()); + + const { getByTestId, queryByTestId } = renderWithTheme(); - expect(getByText('Quotas')).toBeInTheDocument(); - expect(getByText('Learn more about quotas')).toBeInTheDocument(); - expect(getByText('Object Storage Endpoint')).toBeInTheDocument(); - expect( - screen.getByPlaceholderText('Select an Object Storage S3 endpoint') - ).toBeInTheDocument(); - expect( - getByText('Apply filters above to see quotas and current usage.') - ).toBeInTheDocument(); + expect(getByTestId('quota-service-panel')).toHaveTextContent( + 'isFetching:true' + ); + expect(queryByTestId('quotas-panel')).toBeNull(); }); - it('allows endpoint selection', async () => { - queryMocks.useGetLocationsForQuotaService.mockReturnValue({ - isFetchingS3Endpoints: false, - regions: null, - s3Endpoints: [{ label: 'endpoint1 (Standard E0)', value: 'endpoint1' }], - service: 'object-storage', + it('renders service panel when services available and no service selected', () => { + const service = objectStorageQuotaService(); + mocks.useQuotaServices.mockReturnValue({ + data: [service], + isFetching: false, }); + mocks.useSearch.mockReturnValue({}); + mocks.useNavigate.mockReturnValue(vi.fn()); - const { getByPlaceholderText, getByRole } = renderWithTheme(, { - queryClient, - }); + const { getByTestId, queryByTestId } = renderWithTheme(); - const endpointSelect = getByPlaceholderText( - 'Select an Object Storage S3 endpoint' - ); + const panel = getByTestId('quota-service-panel'); + expect(panel).toHaveTextContent('isFetching:false'); + expect(panel).toHaveTextContent('selectedService:false'); + expect(panel).toHaveTextContent('availableServicesLength:1'); - await waitFor(() => { - expect(endpointSelect).not.toHaveValue(null); - }); + // no QuotasPanel rendered because no service selected via search + expect(queryByTestId('quotas-panel')).toBeNull(); + }); - await waitFor(() => { - expect(endpointSelect).toBeInTheDocument(); + it('renders QuotasPanel for each available scope when a service is selected via search', () => { + const service = objectStorageQuotaService(); + mocks.useQuotaServices.mockReturnValue({ + data: [service], + isFetching: false, }); + mocks.useSearch.mockReturnValue({ service: service.type }); + mocks.useNavigate.mockReturnValue(vi.fn()); - await userEvent.click(endpointSelect); - await waitFor(async () => { - const endpointOption = getByRole('option', { - name: 'endpoint1 (Standard E0)', - }); - await userEvent.click(endpointOption); - }); + const { getAllByTestId } = renderWithTheme(); - await waitFor(() => { - expect(endpointSelect).toHaveValue('endpoint1 (Standard E0)'); - }); + // objectStorageQuotaService defines scopes that include 'obj-endpoint' (and possibly 'global') depending on flag + const panels = getAllByTestId('quotas-panel'); + // at least one panel should be rendered for the service scopes + expect(panels.length).toBeGreaterThanOrEqual(1); + // ensure the first panel contains its scope prop + expect(panels[0]).toHaveTextContent('scope:'); }); - it('shows loading state when fetching data', async () => { - queryMocks.useGetLocationsForQuotaService.mockReturnValue({ - isFetchingS3Endpoints: true, - s3Endpoints: null, - service: 'object-storage', + it('resets search if the provided service query param is not available', () => { + const service = objectStorageQuotaService(); + // available services do not include the invalid type + mocks.useQuotaServices.mockReturnValue({ + data: [service], + isFetching: false, }); + mocks.useSearch.mockReturnValue({ service: 'unknown-service' }); - const { getByPlaceholderText } = renderWithTheme(, { - queryClient, - }); + const navigateMock = vi.fn(); + mocks.useNavigate.mockReturnValue(navigateMock); + + renderWithTheme(); - expect(getByPlaceholderText('Loading S3 endpoints...')).toBeInTheDocument(); + // useEffect should call navigate to update search and clear the invalid service param + expect(navigateMock).toHaveBeenCalled(); + // ensure it was called with an object that includes a `search` function + const calledWith = navigateMock.mock.calls[0][0]; + expect(typeof calledWith.search).toBe('function'); + // invoking the provided search updater should not throw (it returns an object) + expect(() => calledWith.search(() => ({}))).not.toThrow(); }); }); diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index c5e3560f0f6..6f3ac41c632 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -1,151 +1,86 @@ -import { - Box, - Divider, - Notice, - Paper, - Select, - Stack, - Typography, -} from '@linode/ui'; -import { useNavigate } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { Link } from 'src/components/Link'; -import { useFlags } from 'src/hooks/useFlags'; +import { useQuotaServices } from 'src/features/Account/Quotas/hooks/useQuotaServices'; +import { QuotaServicePanel } from 'src/features/Account/Quotas/QuotaServicePanel'; +import { QuotasPanel } from 'src/features/Account/Quotas/QuotasPanel/QuotasPanel'; -import { QuotasTable } from './QuotasTable/QuotasTable'; -import { useGetLocationsForQuotaService } from './utils'; - -import type { Quota } from '@linode/api-v4'; -import type { SelectOption } from '@linode/ui'; -import type { Theme } from '@mui/material'; +import type { QuotaServiceType } from '@linode/api-v4'; +import type { + QuotaScope, + QuotaService, +} from 'src/features/Account/Quotas/quotaServices'; export const Quotas = () => { - const navigate = useNavigate(); - const { objectStorageGlobalQuotas } = useFlags(); - const [selectedLocation, setSelectedLocation] = - React.useState>(null); - const locationData = useGetLocationsForQuotaService('object-storage'); - const { s3Endpoints } = locationData; - const isFetchingLocations = - 'isFetchingS3Endpoints' in locationData - ? locationData.isFetchingS3Endpoints - : locationData.isFetchingRegions; + const { data: availableServices, isFetching: isFetchingServices } = + useQuotaServices(); + const navigate = useNavigate({ from: '/quotas' }); + const search = useSearch({ from: '/quotas' }); - const sortedS3Endpoints = React.useMemo(() => { - return s3Endpoints?.sort((a, b) => a.label.localeCompare(b.label)); - }, [s3Endpoints]); + const servicesByType = React.useMemo(() => { + if (!availableServices) { + return null; + } + return new Map( + availableServices?.map((service) => [service.type, service]) + ); + }, [availableServices]); - return ( - <> - + const selectedServiceType = (search.service ?? + null) as null | QuotaServiceType; + + const selectedService = React.useMemo(() => { + return selectedServiceType && servicesByType + ? (servicesByType.get(selectedServiceType) ?? null) + : null; + }, [selectedServiceType, servicesByType]); - {objectStorageGlobalQuotas && ( - ({ - marginTop: theme.spacingFunction(16), - })} - variant="outlined" - > - Object Storage: global + const updateSearchService = React.useCallback( + (service: null | QuotaService) => { + navigate({ + search: (prev) => ({ + ...prev, + service: service ? service.type : undefined, + }), + replace: true, + }); + }, + [navigate] + ); + + // reset service query param if the provided service is not available to the user + React.useEffect(() => { + if (!isFetchingServices && selectedServiceType && !selectedService) { + updateSearchService(null); + } + }, [ + isFetchingServices, + selectedServiceType, + selectedService, + updateSearchService, + ]); - - - )} + const availableScopes = React.useMemo(() => { + return selectedService + ? (Object.keys(selectedService.scopes) as QuotaScope[]) + : []; + }, [selectedService]); - ({ - marginTop: theme.spacingFunction(16), - })} - variant="outlined" - > - - - Object Storage{objectStorageGlobalQuotas ? ': per-endpoint' : ''} - - - - - View your Object Storage quotas by applying the endpoint filter - below.{' '} - - Learn more about quotas - - . - - - - -