diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index 25f1ee8f34d..019ba0ff0e0 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -52,3 +52,38 @@ export const getQuotaUsage = (type: QuotaType, id: string) => setURL(`${BETA_API_ROOT}/${type}/quotas/${id}/usage`), setMethod('GET'), ); + +/** + * getGlobalQuotas + * + * Returns a paginated list of global quotas for a particular service specified by `type`. + * + * This request can be filtered on `quota_name`, `service_name` and `scope`. + * + * @param type { QuotaType } retrieve quotas within this service type. + */ +export const getGlobalQuotas = ( + type: QuotaType, + params: Params = {}, + filter: Filter = {}, +) => + Request>( + setURL(`${BETA_API_ROOT}/${type}/global-quotas`), + setMethod('GET'), + setXFilter(filter), + setParams(params), + ); + +/** + * getGlobalQuotaUsage + * + * Returns the usage for a single global quota within a particular service specified by `type`. + * + * @param type { QuotaType } retrieve a quota within this service type. + * @param id { string } the quota ID to look up. + */ +export const getGlobalQuotaUsage = (type: QuotaType, id: string) => + Request( + setURL(`${BETA_API_ROOT}/${type}/global-quotas/${id}/usage`), + setMethod('GET'), + ); 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 2dcd97bf324..efcad4c7da7 100644 --- a/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts @@ -100,10 +100,12 @@ describe('Quota workflow tests', () => { usage: Math.round(mockQuotas[2].quota_limit * 0.1), }), ]; + cy.wrap(selectedDomain).as('selectedDomain'); cy.wrap(mockEndpoints).as('mockEndpoints'); cy.wrap(mockQuotas).as('mockQuotas'); cy.wrap(mockQuotaUsages).as('mockQuotaUsages'); + mockGetObjectStorageQuotaUsages( selectedDomain, 'bytes', @@ -134,6 +136,7 @@ describe('Quota workflow tests', () => { }, }).as('getFeatureFlags'); }); + it('Quotas and quota usages display properly', function () { cy.visitWithLogin('/account/quotas'); cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); @@ -332,9 +335,11 @@ describe('Quota workflow tests', () => { .should('be.visible') .click(); cy.wait('@getQuotasError'); - cy.get('[data-qa-error-msg="true"]') - .should('be.visible') - .should('have.text', errorMsg); + cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-qa-error-msg="true"]') + .should('be.visible') + .should('have.text', errorMsg); + }); }); }); @@ -508,9 +513,12 @@ describe('Quota workflow tests', () => { .should('be.visible') .click(); cy.wait('@getQuotasError'); - cy.get('[data-qa-error-msg="true"]') - .should('be.visible') - .should('have.text', errorMsg); + + cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-qa-error-msg="true"]') + .should('be.visible') + .should('have.text', errorMsg); + }); }); // this test executed in context of internal user, using mockApiInternalUser() diff --git a/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTable.tsx b/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTable.tsx new file mode 100644 index 00000000000..46a273390fc --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTable.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { Table } from 'src/components/Table/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; + +import { useGetObjGlobalQuotasWithUsage } from '../hooks/useGetObjGlobalQuotasWithUsage'; +import { QUOTA_ROW_MIN_HEIGHT } from '../utils'; +import { GlobalQuotasTableRow } from './GlobalQuotasTableRow'; + +export const GlobalQuotasTable = () => { + const { + data: globalQuotasWithUsage, + isFetching: isFetchingGlobalQuotas, + isError: globalQuotasError, + } = useGetObjGlobalQuotasWithUsage(); + + return ( + ({ + marginTop: theme.spacingFunction(16), + minWidth: theme.breakpoints.values.sm, + })} + > + + + Quota Name + Account Quota Value + Usage + + + + + {isFetchingGlobalQuotas ? ( + + ) : globalQuotasError ? ( + + ) : globalQuotasWithUsage.length === 0 ? ( + + ) : ( + globalQuotasWithUsage.map((globalQuota, index) => { + return ( + + ); + }) + )} + +
+ ); +}; diff --git a/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTableRow.tsx new file mode 100644 index 00000000000..457073ffc19 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/GlobalQuotasTable/GlobalQuotasTableRow.tsx @@ -0,0 +1,77 @@ +import { Box, TooltipIcon, Typography } from '@linode/ui'; +import React from 'react'; + +import { QuotaUsageBar } from 'src/components/QuotaUsageBar/QuotaUsageBar'; +import { TableCell } from 'src/components/TableCell/TableCell'; +import { TableRow } from 'src/components/TableRow/TableRow'; + +import { + convertResourceMetric, + pluralizeMetric, + QUOTA_ROW_MIN_HEIGHT, +} from '../utils'; + +import type { Quota, QuotaUsage } from '@linode/api-v4'; + +interface GlobalQuotaWithUsage extends Quota { + usage?: QuotaUsage; +} +interface Params { + globalQuota: GlobalQuotaWithUsage; +} + +export const GlobalQuotasTableRow = ({ globalQuota }: Params) => { + const { convertedLimit, convertedResourceMetric } = convertResourceMetric({ + initialResourceMetric: pluralizeMetric( + globalQuota.quota_limit, + globalQuota.resource_metric + ), + initialUsage: globalQuota.usage?.usage ?? 0, + initialLimit: globalQuota.quota_limit, + }); + + return ( + + + + + {globalQuota.quota_name} + + + + + + + {convertedLimit?.toLocaleString() ?? 'unknown'}{' '} + {convertedResourceMetric} + + + + + {globalQuota.usage?.usage ? ( + + ) : ( + n/a + )} + + + + ); +}; diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index c8134935c23..49c2543ce74 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -13,7 +13,8 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; -import { QuotasTable } from './QuotasTable'; +import { GlobalQuotasTable } from './GlobalQuotasTable/GlobalQuotasTable'; +import { QuotasTable } from './QuotasTable/QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; import type { Quota } from '@linode/api-v4'; @@ -39,6 +40,18 @@ export const Quotas = () => { return ( <> + + ({ + marginTop: theme.spacingFunction(16), + })} + variant="outlined" + > + Object Storage: global + + + + ({ marginTop: theme.spacingFunction(16), @@ -46,7 +59,7 @@ export const Quotas = () => { variant="outlined" > - Object Storage + Object Storage: per-endpoint @@ -105,7 +118,12 @@ export const Quotas = () => { . - + + ; selectedService: SelectOption; @@ -127,19 +125,19 @@ export const QuotasTable = (props: QuotasTableProps) => { ) : !selectedLocation ? ( ) : quotasWithUsage.length === 0 ? ( ) : ( quotasWithUsage.map((quota, index) => { diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx similarity index 96% rename from packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx rename to packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx index 1ce94846b28..d27c44df1ff 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx @@ -9,7 +9,12 @@ import { TableRow } from 'src/components/TableRow/TableRow'; import { useFlags } from 'src/hooks/useFlags'; import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; -import { convertResourceMetric, getQuotaError, pluralizeMetric } from './utils'; +import { + convertResourceMetric, + getQuotaError, + pluralizeMetric, + QUOTA_ROW_MIN_HEIGHT, +} from '../utils'; import type { Quota, QuotaUsage } from '@linode/api-v4'; import type { UseQueryResult } from '@tanstack/react-query'; @@ -32,8 +37,6 @@ interface QuotasTableRowProps { setSupportModalOpen: (open: boolean) => void; } -const quotaRowMinHeight = 58; - export const QuotasTableRow = (props: QuotasTableRowProps) => { const { hasQuotaUsage, @@ -77,7 +80,7 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { }; return ( - + quota.quota_id) ?? []; + const globalQuotaUsageQueries = useQueries({ + queries: globalQuotaIds.map((quotaId) => + globalQuotaQueries.service(SERVICE)._ctx.usage(quotaId) + ), + }); + + // Combine the quotas with their usage + const globalQuotasWithUsage = React.useMemo( + () => + globalQuotas?.data.map((quota, index) => ({ + ...quota, + usage: globalQuotaUsageQueries?.[index]?.data, + })) ?? [], + [globalQuotas, globalQuotaUsageQueries] + ); + + return { + data: globalQuotasWithUsage, + isError: + globalQuotasError || + globalQuotaUsageQueries.some((query) => query.isError), + isFetching: + isFetchingGlobalQuotas || + globalQuotaUsageQueries.some((query) => query.isFetching), + }; +} diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index 3ea45ecb21f..13cd4e3776d 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -17,6 +17,8 @@ import type { import type { SelectOption } from '@linode/ui'; import type { UseQueryResult } from '@tanstack/react-query'; +export const QUOTA_ROW_MIN_HEIGHT = 58; + type UseGetLocationsForQuotaService = | { isFetchingRegions: boolean; diff --git a/packages/queries/src/quotas/keys.ts b/packages/queries/src/quotas/keys.ts index bc483878472..de3d02c33b8 100644 --- a/packages/queries/src/quotas/keys.ts +++ b/packages/queries/src/quotas/keys.ts @@ -1,4 +1,10 @@ -import { getQuota, getQuotas, getQuotaUsage } from '@linode/api-v4'; +import { + getGlobalQuotas, + getGlobalQuotaUsage, + getQuota, + getQuotas, + getQuotaUsage, +} from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { getAllQuotas } from './requests'; @@ -28,3 +34,19 @@ export const quotaQueries = createQueryKeys('quotas', { queryKey: [type], }), }); + +export const globalQuotaQueries = createQueryKeys('global-quotas', { + service: (type: QuotaType) => ({ + contextQueries: { + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getGlobalQuotas(type, params, filter), + queryKey: [params, filter], + }), + usage: (id: string) => ({ + queryFn: () => getGlobalQuotaUsage(type, id), + queryKey: [id], + }), + }, + queryKey: [type], + }), +}); diff --git a/packages/queries/src/quotas/quotas.ts b/packages/queries/src/quotas/quotas.ts index abb92b6e947..205b1472d5b 100644 --- a/packages/queries/src/quotas/quotas.ts +++ b/packages/queries/src/quotas/quotas.ts @@ -1,6 +1,6 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { quotaQueries } from './keys'; +import { globalQuotaQueries, quotaQueries } from './keys'; import type { APIError, @@ -50,3 +50,15 @@ export const useQuotaUsageQuery = ( ...quotaQueries.service(service)._ctx.usage(id), enabled, }); + +export const useGlobalQuotasQuery = ( + service: QuotaType, + params: Params = {}, + filter: Filter = {}, + enabled = true, +) => + useQuery, APIError[]>({ + ...globalQuotaQueries.service(service)._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + });