diff --git a/src/billing/ConsolidatedSpendReport.test.tsx b/src/billing/ConsolidatedSpendReport.test.tsx new file mode 100644 index 0000000000..dbef0079b7 --- /dev/null +++ b/src/billing/ConsolidatedSpendReport.test.tsx @@ -0,0 +1,254 @@ +import { asMockedFn } from '@terra-ui-packages/test-utils'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Billing, BillingContract } from 'src/libs/ajax/billing/Billing'; +import { renderWithAppContexts } from 'src/testing/test-utils'; +import { WorkspaceWrapper } from 'src/workspaces/utils'; + +import { ConsolidatedSpendReport } from './ConsolidatedSpendReport'; + +jest.mock('src/libs/ajax/billing/Billing', () => ({ Billing: jest.fn() })); + +jest.mock('src/libs/ajax/Metrics'); +type NavExports = typeof import('src/libs/nav'); +jest.mock( + 'src/libs/nav', + (): NavExports => ({ + ...jest.requireActual('src/libs/nav'), + getLink: jest.fn(() => '/'), + goToPath: jest.fn(), + useRoute: jest.fn().mockReturnValue({ query: {} }), + updateSearch: jest.fn(), + }) +); + +const spendReport = { + spendSummary: { + cost: '1.20', + credits: '0.00', + currency: 'USD', + endTime: '2024-12-11T23:59:59.999Z', + startTime: '2024-11-11T00:00:00.000Z', + }, + spendDetails: [ + { + aggregationKey: 'Workspace', + spendData: [ + { + cost: '0.11', + credits: '0.00', + currency: 'USD', + endTime: '2024-12-11T23:59:59.999Z', + googleProjectId: 'terra-dev-fe98dcb6', + startTime: '2024-11-11T00:00:00.000Z', + subAggregation: { + aggregationKey: 'Category', + spendData: [ + { + category: 'Other', + cost: '0.10', + credits: '0.00', + currency: 'USD', + }, + { + category: 'Storage', + cost: '0.00', + credits: '0.00', + currency: 'USD', + }, + { + category: 'Compute', + cost: '0.00', + credits: '0.00', + currency: 'USD', + }, + ], + }, + workspace: { + name: 'workspace2', + namespace: 'namespace-1', + }, + }, + ], + }, + { + aggregationKey: 'Workspace', + spendData: [ + { + cost: '0.11', + credits: '0.00', + currency: 'USD', + endTime: '2024-12-11T23:59:59.999Z', + googleProjectId: 'terra-dev-fe98dcb8', + startTime: '2024-11-11T00:00:00.000Z', + subAggregation: { + aggregationKey: 'Category', + spendData: [ + { + category: 'Other', + cost: '0.10', + credits: '0.00', + currency: 'USD', + }, + { + category: 'Storage', + cost: '0.00', + credits: '0.00', + currency: 'USD', + }, + { + category: 'Compute', + cost: '0.00', + credits: '0.00', + currency: 'USD', + }, + ], + }, + workspace: { + name: 'workspace3', + namespace: 'namespace-2', + }, + }, + ], + }, + ], +}; + +const workspaces = [ + { + canShare: true, + canCompute: true, + accessLevel: 'WRITER', + policies: [], + public: false, + workspace: { + attributes: { + description: '', + 'tag:tags': { + itemsType: 'AttributeValue', + items: [], + }, + }, + authorizationDomain: [], + billingAccount: 'billingAccounts/00102A-34B56C-78DEFA', + bucketName: 'fc-01111a11-20b2-3033-4044-55c0c5c55555', + cloudPlatform: 'Gcp', + createdBy: 'user2@gmail.com', + createdDate: '2024-08-16T13:55:36.984Z', + googleProject: 'terra-dev-fe98dcb7', + googleProjectNumber: '123045678091', + isLocked: false, + lastModified: '2024-12-11T04:32:15.461Z', + name: 'workspace1', + namespace: 'namespace-1', + state: 'Ready', + workflowCollectionName: '01111a11-20b2-3033-4044-55c0c5c55555', + workspaceId: '01111a11-20b2-3033-4044-55c0c5c55555', + workspaceType: 'rawls', + workspaceVersion: 'v2', + }, + } as WorkspaceWrapper, + { + canShare: true, + canCompute: true, + accessLevel: 'OWNER', + policies: [], + public: false, + workspace: { + attributes: { + description: '', + 'tag:tags': { + itemsType: 'AttributeValue', + items: [], + }, + }, + authorizationDomain: [], + billingAccount: 'billingAccounts/00102A-34B56C-78DEFA', + bucketName: 'fc-01111a11-20b2-3033-4044-66c0c6c66666', + cloudPlatform: 'Gcp', + createdBy: 'user1@gmail.com', + createdDate: '2024-09-26T11:55:36.984Z', + googleProject: 'terra-dev-fe98dcb6', + googleProjectNumber: '123045678092', + isLocked: false, + lastModified: '2024-11-11T04:32:15.461Z', + name: 'workspace2', + namespace: 'namespace-1', + state: 'Ready', + workflowCollectionName: '01111a11-20b2-3033-4044-66c0c6c66666', + workspaceId: '01111a11-20b2-3033-4044-66c0c6c66666', + workspaceType: 'rawls', + workspaceVersion: 'v2', + }, + } as WorkspaceWrapper, + { + canShare: true, + canCompute: true, + policies: [], + accessLevel: 'PROJECT_OWNER', + public: false, + workspace: { + attributes: { + description: '', + 'tag:tags': { + itemsType: 'AttributeValue', + items: [], + }, + }, + authorizationDomain: [], + billingAccount: 'billingAccounts/00102A-34B56C-78DEFB', + bucketName: 'fc-01111a11-20b2-3033-4044-77c0c7c77777', + cloudPlatform: 'Gcp', + createdBy: 'user1@gmail.com', + createdDate: '2024-08-10T13:50:36.984Z', + googleProject: 'terra-dev-fe98dcb8', + googleProjectNumber: '123045678090', + isLocked: false, + lastModified: '2024-12-19T04:30:15.461Z', + name: 'workspace3', + namespace: 'namespace-2', + state: 'Ready', + workflowCollectionName: '01111a11-20b2-3033-4044-77c0c7c77777', + workspaceId: '01111a11-20b2-3033-4044-77c0c7c77777', + workspaceType: 'rawls', + workspaceVersion: 'v2', + }, + } as WorkspaceWrapper, +]; + +describe('ConsolidatedSpendReport', () => { + it('displays results of spend query', async () => { + // Arrange + const getCrossBillingSpendReport = jest.fn(() => Promise.resolve(spendReport)); + asMockedFn(Billing).mockImplementation( + () => ({ getCrossBillingSpendReport } as Partial as BillingContract) + ); + + // Act + await act(async () => renderWithAppContexts()); + + // Assert + expect(screen.getByText('workspace2')).not.toBeNull(); + expect(screen.getByText('workspace3')).not.toBeNull(); + }); + + it('searches by workspace name', async () => { + // Arrange + const user = userEvent.setup(); + const getCrossBillingSpendReport = jest.fn(() => Promise.resolve(spendReport)); + asMockedFn(Billing).mockImplementation( + () => ({ getCrossBillingSpendReport } as Partial as BillingContract) + ); + + // Act + await act(async () => renderWithAppContexts()); + await user.type(screen.getByPlaceholderText('Search by name, project or bucket'), 'workspace2'); + + // Assert + expect(screen.getByText('workspace2')).not.toBeNull(); + expect(screen.queryByText('workspace3')).toBeNull(); + }); + + // TODO: tests for tests for changing time period, tests for caching (once it's implemented) +}); diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx new file mode 100644 index 0000000000..23da3d195f --- /dev/null +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -0,0 +1,420 @@ +import { Link } from '@terra-ui-packages/components'; +import { subDays } from 'date-fns/fp'; +import _ from 'lodash/fp'; +import qs from 'qs'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { DateRangeFilter } from 'src/billing/Filter/DateRangeFilter'; +import { SearchFilter } from 'src/billing/Filter/SearchFilter'; +import { parseCurrencyIfNeeded } from 'src/billing/utils'; +import { BillingProject } from 'src/billing-core/models'; +import { ButtonOutline, fixedSpinnerOverlay } from 'src/components/common'; +import { ariaSort, HeaderRenderer } from 'src/components/table'; +import { Ajax } from 'src/libs/ajax'; +import { Billing } from 'src/libs/ajax/billing/Billing'; +import { + AggregatedWorkspaceSpendData, + SpendReport as SpendReportServerResponse, +} from 'src/libs/ajax/billing/billing-models'; +import { Metrics } from 'src/libs/ajax/Metrics'; +import Events, { extractBillingDetails } from 'src/libs/events'; +import * as Nav from 'src/libs/nav'; +import { memoWithName, useCancellation } from 'src/libs/react-utils'; +import { SpendReportStore, spendReportStore } from 'src/libs/state'; +import * as Style from 'src/libs/style'; +import * as Utils from 'src/libs/utils'; +import { GoogleWorkspace, GoogleWorkspaceInfo, WorkspaceWrapper } from 'src/workspaces/utils'; + +// Copied and slightly altered from WorkspaceCard and WorkspaceCardHeaders in Workspaces, +// May want to instead extend them +const workspaceLastModifiedWidth = 150; + +interface ConsolidatedSpendWorkspaceCardHeadersProps { + sort: { field: string; direction: 'asc' | 'desc' }; + onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; +} + +const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( + 'ConsolidatedSpendWorkspaceCardHeaders', + (props: ConsolidatedSpendWorkspaceCardHeadersProps) => { + const { sort, onSort } = props; + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {/*
+ +
*/} +
+ +
+
+ +
+
+ ); + } +); + +interface ConsolidatedSpendWorkspaceCardProps { + workspace: GoogleWorkspaceInfo; + billingProject: BillingProject; +} + +const ConsolidatedSpendWorkspaceCard: React.FC = memoWithName( + 'ConsolidatedSpendWorkspaceCard', + (props: ConsolidatedSpendWorkspaceCardProps) => { + const { workspace, billingProject } = props; + const { namespace, name, createdBy, lastModified, totalSpend, totalCompute, totalStorage } = workspace; + const workspaceCardStyles = { + field: { + ...Style.noWrapEllipsis, + flex: 1, + height: '1.20rem', + width: `calc(50% - ${workspaceLastModifiedWidth / 2}px)`, + paddingRight: '1rem', + }, + row: { display: 'flex', alignItems: 'center', width: '100%', padding: '1rem' }, + }; + + return ( +
+
+
+ { + void Metrics().captureEvent(Events.billingConsolidatedReportGoToBillingProject, { + ...extractBillingDetails(billingProject), + }); + }} + > + {namespace ?? '...'} + +
+
+ { + void Metrics().captureEvent(Events.billingConsolidatedReportGoToWorkspace, { + workspaceName: name, + ...extractBillingDetails(billingProject), + }); + }} + > + {name} + +
+
+ {totalSpend ?? '...'} +
+
+ {totalCompute ?? '...'} +
+
+ {totalStorage ?? '...'} +
+ {/*
+ {otherSpend ?? '...'} +
*/} +
+ {createdBy} +
+
+ {lastModified ? Utils.makeStandardDate(lastModified) : ' '} +
+
+
+ ); + } +); +/// /// + +interface ConsolidatedSpendReportProps { + workspaces: WorkspaceWrapper[]; +} + +export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): ReactNode => { + const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); + const [updating, setUpdating] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [ownedWorkspaces, setOwnedWorkspaces] = useState([]); + const [workspaceSort, setWorkspaceSort] = useState<{ field: string; direction: 'asc' | 'desc' }>({ + field: 'totalSpend', + direction: 'desc', + }); + const [allWorkspaces, setAllWorkspaces] = useState(props.workspaces); + + const [itemsPerPage, setItemsPerPage] = useState(250); + + const signal = useCancellation(); + + // Apply filters to ownedWorkspaces + const searchValueLower = searchValue.toLowerCase(); + + const filteredOwnedWorkspaces = _.filter( + (workspace: GoogleWorkspaceInfo) => + workspace.name.toLowerCase().includes(searchValueLower) || + workspace.googleProject?.toLowerCase().includes(searchValueLower) || + workspace.bucketName?.toLowerCase().includes(searchValueLower) || + workspace.namespace.toLowerCase().includes(searchValueLower), + ownedWorkspaces + ); + + useEffect(() => { + // Define start and end dates + const endDate = new Date().toISOString().slice(0, 10); + const startDate = subDays(spendReportLengthInDays, new Date()).toISOString().slice(0, 10); + + const fetchWorkspaces = async (signal: AbortSignal): Promise => { + const fetchedWorkspaces = await Ajax(signal).Workspaces.list( + [ + 'workspace.billingAccount', + 'workspace.bucketName', + 'workspace.createdBy', + 'workspace.createdDate', + 'workspace.googleProject', + 'workspace.lastModified', + 'workspace.name', + 'workspace.namespace', + ], + itemsPerPage + ); + return fetchedWorkspaces; + }; + + const getWorkspaceSpendData = async (signal: AbortSignal): Promise => { + setUpdating(true); + + const spendReportStoreLocal = spendReportStore.get(); + const storedSpendReport = spendReportStoreLocal?.[itemsPerPage]?.[spendReportLengthInDays]; + if (!storedSpendReport || storedSpendReport.startDate !== startDate || storedSpendReport.endDate !== endDate) { + const setDefaultSpendValues = (workspace: GoogleWorkspaceInfo) => ({ + ...workspace, + totalSpend: 'N/A', + totalCompute: 'N/A', + totalStorage: 'N/A', + }); + + try { + // Fetch the spend report for the billing project + const consolidatedSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ + startDate, + endDate, + pageSize: itemsPerPage, + offset: 0, + }); + + const spendDataItems = (consolidatedSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( + (detail) => detail.spendData[0] + ); + + // Update each workspace with spend data or default values + return spendDataItems.map((spendItem) => { + const costFormatter = new Intl.NumberFormat(navigator.language, { + style: 'currency', + currency: spendItem.currency, + }); + + const workspaceDetails = allWorkspaces.find( + (ws): ws is GoogleWorkspace => + ws.workspace.name === spendItem.workspace.name && + ws.workspace.namespace === spendItem.workspace.namespace + ); + + return { + ...spendItem.workspace, + workspaceId: `${spendItem.workspace.namespace}-${spendItem.workspace.name}`, + authorizationDomain: workspaceDetails?.workspace.authorizationDomain, + createdDate: workspaceDetails?.workspace.createdDate, + createdBy: workspaceDetails?.workspace.createdBy, + lastModified: workspaceDetails?.workspace.lastModified, + + billingAccount: workspaceDetails?.workspace.billingAccount, + googleProject: workspaceDetails?.workspace.googleProject, + cloudPlatform: 'Gcp', + bucketName: workspaceDetails?.workspace.bucketName, + + totalSpend: costFormatter.format(parseFloat(spendItem.cost ?? '0.00')), + totalCompute: costFormatter.format( + parseFloat(_.find({ category: 'Compute' }, spendItem.subAggregation.spendData)?.cost ?? '0.00') + ), + totalStorage: costFormatter.format( + parseFloat(_.find({ category: 'Storage' }, spendItem.subAggregation.spendData)?.cost ?? '0.00') + ), + // otherSpend: costFormatter.format( + // parseFloat(_.find({ category: 'Other' }, spendItem.subAggregation.spendData)?.cost ?? '0.00') + // ), + } as GoogleWorkspaceInfo; + }); + } catch { + // Return default values for each workspace in case of an error + return ownedWorkspaces.map(setDefaultSpendValues); + } finally { + // Ensure updating state is reset regardless of success or failure + setUpdating(false); + } + } else { + setUpdating(false); + return storedSpendReport.spendReport; + } + }; + + const initialize = async () => { + // Fetch workspaces if they haven't been fetched yet + if (!allWorkspaces || allWorkspaces.length === 0) { + setUpdating(true); + await fetchWorkspaces(signal).then((workspaces) => { + setAllWorkspaces(workspaces); + }); + } else { + getWorkspaceSpendData(signal).then((updatedWorkspaces) => { + if (updatedWorkspaces) { + spendReportStore.update((store) => { + const newStore: SpendReportStore = { ...store }; + if (!newStore[itemsPerPage]) { + newStore[itemsPerPage] = {}; + } + newStore[itemsPerPage][spendReportLengthInDays] = { + spendReport: updatedWorkspaces, + startDate, + endDate, + }; + return newStore; + }); + setOwnedWorkspaces(updatedWorkspaces); + } + }); + } + }; + initialize(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signal, spendReportLengthInDays, allWorkspaces, itemsPerPage]); + + return ( + <> +
+ Consolidated Spend Report +
+
+
+ { + if (selectedOption !== spendReportLengthInDays) { + setSpendReportLengthInDays(selectedOption); + } + }} + /> + + {ownedWorkspaces.length >= itemsPerPage && ( + { + setItemsPerPage(2500000); + }} + > + Show all workspaces + + )} +
+
+ * + Total spend includes infrastructure or query costs related to the general operations of Terra. +
+ {!_.isEmpty(filteredOwnedWorkspaces) && ( +
+ +
+ {_.flow( + _.orderBy( + [(workspace) => parseCurrencyIfNeeded(workspaceSort.field, _.get(workspaceSort.field, workspace))], + [workspaceSort.direction] + ), + _.map((workspace: GoogleWorkspaceInfo) => { + return ( + + ); + }) + )(filteredOwnedWorkspaces)} +
+
+ )} + {updating && fixedSpinnerOverlay} +
+ + ); +}; diff --git a/src/billing/List/BillingList.test.tsx b/src/billing/List/BillingList.test.tsx new file mode 100644 index 0000000000..c5c87c8f3d --- /dev/null +++ b/src/billing/List/BillingList.test.tsx @@ -0,0 +1,95 @@ +import { act, screen } from '@testing-library/react'; +import React from 'react'; +import { BillingProject } from 'src/billing-core/models'; +import { Billing, BillingContract } from 'src/libs/ajax/billing/Billing'; +import { Metrics, MetricsContract } from 'src/libs/ajax/Metrics'; +import { isFeaturePreviewEnabled } from 'src/libs/feature-previews'; +import { asMockedFn, partial, renderWithAppContexts as render } from 'src/testing/test-utils'; +import { defaultAzureWorkspace, defaultGoogleWorkspace } from 'src/testing/workspace-fixtures'; +import { useWorkspaces } from 'src/workspaces/common/state/useWorkspaces'; + +import { BillingList, BillingListProps } from './BillingList'; + +// Mocking for using Nav.getLink +jest.mock('src/libs/nav', () => ({ + ...jest.requireActual('src/libs/nav'), + getPath: jest.fn(() => '/test/'), + getLink: jest.fn(() => '/'), +})); + +jest.mock('src/libs/ajax/Metrics'); +asMockedFn(Metrics).mockImplementation(() => partial({ captureEvent: jest.fn() })); + +type UseWorkspacesExports = typeof import('src/workspaces/common/state/useWorkspaces'); +jest.mock('src/workspaces/common/state/useWorkspaces', (): UseWorkspacesExports => { + return { + ...jest.requireActual('src/workspaces/common/state/useWorkspaces'), + useWorkspaces: jest.fn(), + }; +}); +asMockedFn(useWorkspaces).mockReturnValue({ + workspaces: [defaultAzureWorkspace, defaultGoogleWorkspace], + loading: false, + refresh: () => Promise.resolve(), + status: 'Ready', +}); + +type AuthExports = typeof import('src/auth/auth'); +jest.mock('src/libs/ajax/billing/Billing'); +asMockedFn(Billing).mockReturnValue( + partial({ + listProjects: async () => [ + partial({ + billingAccount: 'billingAccounts/FOO-BAR-BAZ', + cloudPlatform: 'GCP', + invalidBillingAccount: false, + projectName: 'Google Billing Project', + roles: ['Owner'], + status: 'Ready', + }), + ], + getProject: async () => partial({ projectName: 'Google Billing Project' }), + }) +); + +jest.mock('src/auth/auth', (): AuthExports => { + const originalModule = jest.requireActual('src/auth/auth'); + return { + ...originalModule, + hasBillingScope: jest.fn(), + tryBillingScope: jest.fn(), + getAuthToken: jest.fn(), + getAuthTokenFromLocalStorage: jest.fn(), + sendRetryMetric: jest.fn(), + }; +}); + +jest.mock('src/libs/feature-previews', () => ({ + isFeaturePreviewEnabled: jest.fn(), +})); + +describe('BillingList', () => { + let billingListProps: BillingListProps; + + it('renders link to consolidated spend report if feature preview is on', async () => { + (isFeaturePreviewEnabled as jest.Mock).mockReturnValue(true); + billingListProps = { queryParams: { selectedName: 'name', type: undefined } }; + + // Act + await act(async () => render()); + + // Assert + expect(screen.getByText('Consolidated Spend Report')).not.toBeNull(); + }); + + it('does not render link to consolidated spend report if feature preview is off', async () => { + (isFeaturePreviewEnabled as jest.Mock).mockReturnValue(false); + billingListProps = { queryParams: { selectedName: 'name', type: undefined } }; + + // Act + await act(async () => render()); + + // Assert + expect(screen.queryByText('Consolidated Spend Report')).toBeNull(); + }); +}); diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index 69ab920dc5..4a1baaf328 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -6,19 +6,21 @@ import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 're import * as Auth from 'src/auth/auth'; import { CreateBillingProjectControl } from 'src/billing/List/CreateBillingProjectControl'; import { GCPNewBillingProjectModal } from 'src/billing/List/GCPNewBillingProjectModal'; -import { ProjectListItem, ProjectListItemProps } from 'src/billing/List/ProjectListItem'; +import { listItemStyle, ProjectListItem, ProjectListItemProps } from 'src/billing/List/ProjectListItem'; import { AzureBillingProjectWizard } from 'src/billing/NewBillingProjectWizard/AzureBillingProjectWizard/AzureBillingProjectWizard'; import { GCPBillingProjectWizard } from 'src/billing/NewBillingProjectWizard/GCPBillingProjectWizard/GCPBillingProjectWizard'; import ProjectDetail from 'src/billing/Project'; -import { isCreating, isDeleting } from 'src/billing/utils'; -import { billingRoles } from 'src/billing/utils'; +import { billingRoles, isCreating, isDeleting } from 'src/billing/utils'; import { BillingProject, GoogleBillingAccount } from 'src/billing-core/models'; import Collapse from 'src/components/Collapse'; +import { Clickable } from 'src/components/common'; import { Billing } from 'src/libs/ajax/billing/Billing'; import { Metrics } from 'src/libs/ajax/Metrics'; import colors from 'src/libs/colors'; import { reportErrorAndRethrow } from 'src/libs/error'; import Events from 'src/libs/events'; +import { isFeaturePreviewEnabled } from 'src/libs/feature-previews'; +import { SPEND_REPORTING } from 'src/libs/feature-previews-config'; import * as Nav from 'src/libs/nav'; import { useCancellation, useOnMount } from 'src/libs/react-utils'; import * as StateHistory from 'src/libs/state-history'; @@ -27,6 +29,8 @@ import * as Utils from 'src/libs/utils'; import { useWorkspaces } from 'src/workspaces/common/state/useWorkspaces'; import { CloudProvider, cloudProviderTypes, WorkspaceWrapper } from 'src/workspaces/utils'; +import { ConsolidatedSpendReport } from '../ConsolidatedSpendReport'; + const BillingProjectSubheader: React.FC<{ title: string; children: ReactNode }> = ({ title, children }) => ( {title}} @@ -51,6 +55,7 @@ interface RightHandContentProps { authorizeAndLoadAccounts: () => Promise; setCreatingBillingProjectType: (type: CloudProvider | null) => void; reloadBillingProject: (billingProject: BillingProject) => Promise; + type: string | undefined; } // This is the view of the Billing Project details, or the wizard to create a new billing project. @@ -68,6 +73,7 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { authorizeAndLoadAccounts, setCreatingBillingProjectType, reloadBillingProject, + type, } = props; if (!!selectedName && !_.some({ projectName: selectedName }, billingProjects)) { return ( @@ -133,14 +139,24 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { /> ); } - if (!_.isEmpty(projectsOwned) && !selectedName) { + if (type === 'consolidatedSpendReport' && !selectedName) { + return ( + + ); + } + if (type !== 'consolidatedSpendReport' && !_.isEmpty(projectsOwned) && !selectedName) { return
Select a Billing Project
; } }; -interface BillingListProps { +export interface BillingListProps { queryParams: { selectedName: string | undefined; + type: string | undefined; }; } @@ -153,11 +169,12 @@ export const BillingList = (props: BillingListProps) => { const [isAuthorizing, setIsAuthorizing] = useState(false); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const { workspaces: allWorkspaces, loading: workspacesLoading, refresh: refreshWorkspaces } = useWorkspaces(); - + const [spendReportHovered, setSpendReportHovered] = useState(false); const signal = useCancellation(); const interval = useRef(); const selectedName = props.queryParams.selectedName; const billingProjectListWidth = 350; + const type = props.queryParams.type; // Helpers const loadProjects = _.flow( @@ -291,6 +308,32 @@ export const BillingList = (props: BillingListProps) => {

Billing Projects

+ {isFeaturePreviewEnabled(SPEND_REPORTING) && ( +
+
+
setSpendReportHovered(true)} + onMouseLeave={() => setSpendReportHovered(false)} + > + void Metrics().captureEvent(Events.billingViewConsolidatedSpendReport)} + aria-current={type === 'consolidatedSpendReport' ? 'location' : false} + > + Consolidated Spend Report + +
+
+
+ )}
{projectsOwned.map((project) => ( @@ -335,6 +378,7 @@ export const BillingList = (props: BillingListProps) => { showAzureBillingProjectWizard={azureUserWithNoBillingProjects || creatingAzureBillingProject} setCreatingBillingProjectType={setCreatingBillingProjectType} reloadBillingProject={reloadBillingProject} + type={type} />
{(isLoadingProjects || isAuthorizing || isLoadingAccounts) && } diff --git a/src/billing/List/ProjectListItem.tsx b/src/billing/List/ProjectListItem.tsx index 8a5b1a44c1..1d3b91d98d 100644 --- a/src/billing/List/ProjectListItem.tsx +++ b/src/billing/List/ProjectListItem.tsx @@ -14,7 +14,7 @@ import * as Nav from 'src/libs/nav'; import * as Style from 'src/libs/style'; import { isKnownCloudProvider } from 'src/workspaces/utils'; -const listItemStyle = (selected, hovered) => { +export const listItemStyle = (selected, hovered) => { const style = { ...Style.navList.itemContainer(selected), ...Style.navList.item(selected), diff --git a/src/libs/ajax/billing/Billing.ts b/src/libs/ajax/billing/Billing.ts index 6eeab343a2..a7ab037708 100644 --- a/src/libs/ajax/billing/Billing.ts +++ b/src/libs/ajax/billing/Billing.ts @@ -138,6 +138,34 @@ export const Billing = (signal?: AbortSignal) => ({ return res.json(); }, + /** + * Returns a spend report for each workspace the user has owner permission on, from 12 AM on the startDate to 11:59 PM on the endDate (UTC). Spend details by + * Workspace are included. + * + * @param startDate, a string of the format YYYY-MM-DD, representing the start date of the report. + * @param endDate a string of the format YYYY-MM-DD, representing the end date of the report. + * @param pageSize how many workspaces to include in each page of the report + * @param offset the index of the first workspace to include in the report + * @returns {Promise<*>} + */ + getCrossBillingSpendReport: async ({ + startDate, + endDate, + pageSize, + offset, + }: { + startDate: string; + endDate: string; + pageSize: number; + offset: number; + }): Promise => { + const res = await fetchRawls( + `billing/v2/spendReport?${qs.stringify({ startDate, endDate, pageSize, offset }, { arrayFormat: 'repeat' })}`, + _.merge(authOpts(), { signal }) + ); + return res.json(); + }, + listProjectUsers: async (projectName: string): Promise => { const res = await fetchRawls(`billing/v2/${projectName}/members`, _.merge(authOpts(), { signal })); return res.json(); diff --git a/src/libs/events.ts b/src/libs/events.ts index 905e080d59..9a019fa2be 100644 --- a/src/libs/events.ts +++ b/src/libs/events.ts @@ -35,6 +35,8 @@ const eventsList = { billingProjectGoToWorkspace: 'billing:project:workspace:navigate', billingProjectOpenFromList: 'billing:project:open-from-list', billingProjectSelectTab: 'billing:project:tab', + billingConsolidatedReportGoToWorkspace: 'billing:consolidatedReport:workspace:navigate', + billingConsolidatedReportGoToBillingProject: 'billing:consolidatedReport:project:navigate', billingChangeAccount: 'billing:project:account:update', billingAzureCreationSubscriptionStep: 'billing:creation:step1:AzureSubscriptionStepActive', billingAzureCreationMRGSelected: 'billing:creation:step1:AzureMRGSelected', @@ -57,6 +59,7 @@ const eventsList = { billingCreationBillingProjectCreated: 'billing:creation:billingProjectCreated', billingRemoveAccount: 'billing:project:account:remove', billingSpendConfigurationUpdated: 'billing:spendConfiguration:updated', + billingViewConsolidatedSpendReport: 'billing:view:consolidatedSpendReport', cloudEnvironmentConfigOpen: 'cloudEnvironment:config:open', cloudEnvironmentLaunch: 'cloudEnvironment:launch', cloudEnvironmentCreate: 'cloudEnvironment:create', diff --git a/src/libs/feature-previews-config.ts b/src/libs/feature-previews-config.ts index 283815701c..bddea93765 100644 --- a/src/libs/feature-previews-config.ts +++ b/src/libs/feature-previews-config.ts @@ -125,7 +125,7 @@ const featurePreviewsConfig: readonly FeaturePreview[] = [ feedbackUrl: `mailto:dsp-core-services@broadinstitute.org?subject=${encodeURIComponent( 'Feedback on Improved Spend Reports' )}`, - lastUpdated: '11/19/2024', + lastUpdated: '01/17/2025', articleUrl: 'https://support.terra.bio/hc/en-us/articles/31182586327323', }, { diff --git a/src/libs/state.ts b/src/libs/state.ts index 7e2e4794c0..39b678f8d0 100644 --- a/src/libs/state.ts +++ b/src/libs/state.ts @@ -10,7 +10,7 @@ import { OidcConfig } from 'src/libs/ajax/OAuth2'; import { SamTermsOfServiceConfig } from 'src/libs/ajax/TermsOfService'; import { NihDatasetPermission, SamUserAllowances, SamUserAttributes, SamUserResponse } from 'src/libs/ajax/User'; import { getLocalStorage, getSessionStorage, staticStorageSlot } from 'src/libs/browser-storage'; -import type { WorkspaceInfo, WorkspaceWrapper } from 'src/workspaces/utils'; +import type { GoogleWorkspaceInfo, WorkspaceInfo, WorkspaceWrapper } from 'src/workspaces/utils'; export const routeHandlersStore = atom([]); @@ -391,3 +391,18 @@ export const workflowsAppStore = atom({ cbasProxyUrlState: { status: AppProxyUrlStatus.None, state: '' }, cromwellProxyUrlState: { status: AppProxyUrlStatus.None, state: '' }, }); + +export type SpendReportStore = { + 250?: { + 7?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + 30?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + 90?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + }; + 2500000?: { + 7?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + 30?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + 90?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + }; +}; + +export const spendReportStore = atom(undefined); diff --git a/src/pages/billing/BillingListPage.test.tsx b/src/pages/billing/BillingListPage.test.tsx index f42237109d..468b338807 100644 --- a/src/pages/billing/BillingListPage.test.tsx +++ b/src/pages/billing/BillingListPage.test.tsx @@ -36,7 +36,7 @@ jest.mock('src/billing/List/BillingList', () => ({ describe('BillingListPage', () => { test('renders the page with default props', () => { - render(); + render(); // Verify title expect(screen.getByText('Billing')).toBeInTheDocument(); @@ -49,7 +49,7 @@ describe('BillingListPage', () => { }); test('renders breadcrumbs when selectedName is provided', () => { - render(); + render(); // Verify breadcrumbs expect(screen.getByText('Billing > Billing Project')).toBeInTheDocument(); @@ -57,7 +57,7 @@ describe('BillingListPage', () => { }); test('TopBar renders the correct href when selectedName is provided', () => { - render(); + render(); // Verify TopBar link const link = screen.getByTestId('topbar-link'); @@ -65,7 +65,7 @@ describe('BillingListPage', () => { }); test('TopBar does not render href when selectedName is undefined', () => { - render(); + render(); // Verify TopBar link absence const link = screen.getByTestId('topbar-link'); @@ -73,14 +73,14 @@ describe('BillingListPage', () => { }); test('passes correct props to BillingList', () => { - render(); + render(); // Verify BillingList is rendered expect(screen.getByText('Mocked BillingList')).toBeInTheDocument(); }); test('TopBar receives correct props', () => { - render(); + render(); // Verify TopBar props expect(TopBar).toHaveBeenCalledWith( @@ -93,7 +93,7 @@ describe('BillingListPage', () => { }); test('TopBar receives correct props when selectedName is undefined', () => { - render(); + render(); // Verify TopBar props expect(TopBar).toHaveBeenCalledWith( diff --git a/src/pages/billing/BillingListPage.tsx b/src/pages/billing/BillingListPage.tsx index c3a5aec6f2..4d91fa979d 100644 --- a/src/pages/billing/BillingListPage.tsx +++ b/src/pages/billing/BillingListPage.tsx @@ -8,11 +8,13 @@ import * as Style from 'src/libs/style'; interface BillingListPageProps { queryParams: { selectedName: string | undefined; + type: string | undefined; }; } export const BillingListPage = (props: BillingListPageProps) => { const selectedName = props.queryParams.selectedName; + const type = props.queryParams.type; const breadcrumbs = 'Billing > Billing Project'; return ( @@ -25,7 +27,7 @@ export const BillingListPage = (props: BillingListPageProps) => { )} - + ); };