From 520c4c777959601c813a719046946cbca5e48548 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Thu, 5 Dec 2024 14:48:47 -0500 Subject: [PATCH 01/17] skeleton of cbsr --- src/billing/CrossBillingSpendReport.tsx | 348 ++++++++++++++++++++++++ src/billing/List/BillingList.tsx | 46 +++- src/libs/ajax/billing/Billing.ts | 28 ++ src/pages/billing/BillingListPage.tsx | 4 +- 4 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 src/billing/CrossBillingSpendReport.tsx diff --git a/src/billing/CrossBillingSpendReport.tsx b/src/billing/CrossBillingSpendReport.tsx new file mode 100644 index 0000000000..5d628f8add --- /dev/null +++ b/src/billing/CrossBillingSpendReport.tsx @@ -0,0 +1,348 @@ +import { Icon, Link } from '@terra-ui-packages/components'; +import { subDays } from 'date-fns/fp'; +import _ from 'lodash/fp'; +import { React, ReactNode, useEffect, useState } from 'react'; +import { DateRangeFilter } from 'src/billing/Filter/DateRangeFilter'; +import { SearchFilter } from 'src/billing/Filter/SearchFilter'; +import { + billingAccountIconSize, + BillingAccountStatus, + getBillingAccountIconProps, + parseCurrencyIfNeeded, +} from 'src/billing/utils'; +import { BillingProject } from 'src/billing-core/models'; +import { ariaSort, HeaderRenderer } from 'src/components/table'; +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 * as Style from 'src/libs/style'; +import * as Utils from 'src/libs/utils'; +import { BaseWorkspaceInfo, WorkspaceInfo } from 'src/workspaces/utils'; + +// Copied and slightly altered from WorkspaceCard and WorkspaceCardHeaders in Workspaces, +// May want to instead extend them +const workspaceLastModifiedWidth = 150; + +export interface CrossBillingWorkspaceCardHeadersProps { + needsStatusColumn: boolean; + sort: { field: string; direction: 'asc' | 'desc' }; + onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; +} + +export const CrossBillingWorkspaceCardHeaders: React.FC = memoWithName( + 'CrossBillingWorkspaceCardHeaders', + (props: CrossBillingWorkspaceCardHeadersProps) => { + const { needsStatusColumn, sort, onSort } = props; + return ( +
+ {needsStatusColumn && ( +
+
Status
+
+ )} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {/*
+ +
*/} +
+ +
+
+ +
+
+ ); + } +); + +interface CrossBillingWorkspaceCardProps { + workspace: WorkspaceInfo; + billingAccountDisplayName: string | undefined; + billingProject: BillingProject; + billingAccountStatus: false | BillingAccountStatus; +} + +export const CrossBillingWorkspaceCard: React.FC = memoWithName( + 'CrossBillingWorkspaceCard', + (props: CrossBillingWorkspaceCardProps) => { + const { workspace, billingProject, billingAccountStatus } = 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 ( +
+
+ {billingAccountStatus && ( +
+ +
+ )} +
+ {billingProject.projectName ?? '...'} +
+
+ { + void Metrics().captureEvent(Events.billingProjectGoToWorkspace, { + workspaceName: name, + ...extractBillingDetails(billingProject), + }); + }} + > + {name} + +
+
+ {totalSpend ?? '...'} +
+
+ {totalCompute ?? '...'} +
+
+ {totalStorage ?? '...'} +
+ {/*
+ {otherSpend ?? '...'} +
*/} +
+ {createdBy} +
+
+ {Utils.makeStandardDate(lastModified)} +
+
+
+ ); + } +); +/// /// + +export const CrossBillingSpendReport = (): ReactNode => { + // const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); // todo set this up + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [updating, setUpdating] = useState(false); + const [selectedDays, setSelectedDays] = useState(30); + // const [searchValue, setSearchValue] = useState(''); + const [ownedWorkspaces, setOwnedWorkspaces] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [workspaceSort, setWorkspaceSort] = useState<{ field: string; direction: 'asc' | 'desc' }>({ + field: 'totalSpend', + direction: 'desc', + }); + + const signal = useCancellation(); + useEffect(() => { + const getWorkspaceSpendData = async ( + selectedDays: number, + signal: AbortSignal, + workspaces: BaseWorkspaceInfo[] + ) => { + // Define start and end dates + const startDate = subDays(selectedDays, new Date()).toISOString().slice(0, 10); + const endDate = new Date().toISOString().slice(0, 10); + + const setDefaultSpendValues = (workspace: BaseWorkspaceInfo) => ({ + ...workspace, + totalSpend: 'N/A', + totalCompute: 'N/A', + totalStorage: 'N/A', + }); + + setUpdating(true); + + try { + // Fetch the spend report for the billing project + const crossBillingSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ + startDate, + endDate, + pageSize: 10, // TODO pass these in + offset: 0, // TODO pass these in + }); + const spendDataItems = (crossBillingSpendReport.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, + }); + + return { + ...spendItem.workspace, + workspaceId: 'temp', + authorizationDomain: [], + createdDate: '2024-12-01', + createdBy: 'temp', + lastModified: '2024-12-01', + 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') + // ), + }; + }); + } catch { + // Return default values for each workspace in case of an error + return workspaces.map(setDefaultSpendValues); + } finally { + // Ensure updating state is reset regardless of success or failure + setUpdating(false); + } + }; + getWorkspaceSpendData(selectedDays, signal, []).then((updatedWorkspaceInProject) => { + if (updatedWorkspaceInProject) { + setOwnedWorkspaces(updatedWorkspaceInProject); + } + }); + }, [selectedDays, signal]); + + return ( + <> + <> +
+ + +
+
+ * + Total spend includes infrastructure or query costs related to the general operations of Terra. +
+ + {_.isEmpty(ownedWorkspaces) ? ( +
+
+ + + Workspaces + +
+
+ ) : ( + !_.isEmpty(ownedWorkspaces) && ( +
+ {}} + sort={{ field: 'name', direction: 'asc' }} + /> +
+ {_.flow( + _.orderBy( + [(workspace) => parseCurrencyIfNeeded(workspaceSort.field, _.get(workspaceSort.field, workspace))], + [workspaceSort.direction] + ), + _.map((workspace: WorkspaceInfo) => { + return ( + + ); + }) + )(ownedWorkspaces)} + {/* {updating && fixedSpinnerOverlay} */} +
+
+ ) + )} + + ); +}; diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index 69ab920dc5..edc98e83b4 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -1,4 +1,4 @@ -import { SpinnerOverlay } from '@terra-ui-packages/components'; +import { Clickable, SpinnerOverlay } from '@terra-ui-packages/components'; import { withHandlers } from '@terra-ui-packages/core-utils'; import _ from 'lodash/fp'; import * as qs from 'qs'; @@ -10,8 +10,7 @@ import { ProjectListItem, ProjectListItemProps } from 'src/billing/List/ProjectL 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 { Billing } from 'src/libs/ajax/billing/Billing'; @@ -27,6 +26,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 { CrossBillingSpendReport } from '../CrossBillingSpendReport'; + const BillingProjectSubheader: React.FC<{ title: string; children: ReactNode }> = ({ title, children }) => ( {title}} @@ -51,6 +52,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 +70,7 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { authorizeAndLoadAccounts, setCreatingBillingProjectType, reloadBillingProject, + type, } = props; if (!!selectedName && !_.some({ projectName: selectedName }, billingProjects)) { return ( @@ -133,7 +136,26 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { /> ); } - if (!_.isEmpty(projectsOwned) && !selectedName) { + if (type === 'crossBillingProjectSpend' && !_.isEmpty(projectsOwned) && !selectedName) { + const billingProject = billingProjects.find(({ projectName }) => projectName === selectedName); + return ( + reloadBillingProject(billingProject).catch(loadProjects)} + isOwner={projectsOwned.some(({ projectName }) => projectName === selectedName)} + workspaces={allWorkspaces} + refreshWorkspaces={refreshWorkspaces} + /> + ); + } + if (type !== 'crossBillingProjectSpend' && !_.isEmpty(projectsOwned) && !selectedName) { return
Select a Billing Project
; } }; @@ -141,6 +163,7 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { interface BillingListProps { queryParams: { selectedName: string | undefined; + type: string | undefined; }; } @@ -158,6 +181,7 @@ export const BillingList = (props: BillingListProps) => { const interval = useRef(); const selectedName = props.queryParams.selectedName; const billingProjectListWidth = 350; + const type = props.queryParams.type; // Helpers const loadProjects = _.flow( @@ -291,6 +315,19 @@ export const BillingList = (props: BillingListProps) => {

Billing Projects

+ + Cross Billing Project Spend Report +
{projectsOwned.map((project) => ( @@ -335,6 +372,7 @@ export const BillingList = (props: BillingListProps) => { showAzureBillingProjectWizard={azureUserWithNoBillingProjects || creatingAzureBillingProject} setCreatingBillingProjectType={setCreatingBillingProjectType} reloadBillingProject={reloadBillingProject} + type={type} />
{(isLoadingProjects || isAuthorizing || isLoadingAccounts) && } diff --git a/src/libs/ajax/billing/Billing.ts b/src/libs/ajax/billing/Billing.ts index 1ad65a1c20..1c4a927b5d 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/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) => { )} - + ); }; From aaa2481cc16956ecdf8de30af476dbeb0ce47418 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Tue, 10 Dec 2024 14:04:35 -0500 Subject: [PATCH 02/17] add searching etc --- src/billing/CrossBillingSpendReport.tsx | 177 ++++++++++++++---------- 1 file changed, 107 insertions(+), 70 deletions(-) diff --git a/src/billing/CrossBillingSpendReport.tsx b/src/billing/CrossBillingSpendReport.tsx index 5d628f8add..80b87fd356 100644 --- a/src/billing/CrossBillingSpendReport.tsx +++ b/src/billing/CrossBillingSpendReport.tsx @@ -1,17 +1,14 @@ -import { Icon, Link } from '@terra-ui-packages/components'; +import { Link } from '@terra-ui-packages/components'; import { subDays } from 'date-fns/fp'; import _ from 'lodash/fp'; -import { React, ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode, useEffect, useState } from 'react'; import { DateRangeFilter } from 'src/billing/Filter/DateRangeFilter'; import { SearchFilter } from 'src/billing/Filter/SearchFilter'; -import { - billingAccountIconSize, - BillingAccountStatus, - getBillingAccountIconProps, - parseCurrencyIfNeeded, -} from 'src/billing/utils'; +import { BillingAccountStatus, parseCurrencyIfNeeded } from 'src/billing/utils'; import { BillingProject } from 'src/billing-core/models'; -import { ariaSort, HeaderRenderer } from 'src/components/table'; +import { fixedSpinnerOverlay } from 'src/components/common'; +import { ariaSort, HeaderRenderer, Paginator } from 'src/components/table'; +import { Ajax } from 'src/libs/ajax'; import { Billing } from 'src/libs/ajax/billing/Billing'; import { AggregatedWorkspaceSpendData, @@ -23,22 +20,21 @@ import * as Nav from 'src/libs/nav'; import { memoWithName, useCancellation } from 'src/libs/react-utils'; import * as Style from 'src/libs/style'; import * as Utils from 'src/libs/utils'; -import { BaseWorkspaceInfo, WorkspaceInfo } from 'src/workspaces/utils'; +import { GoogleWorkspaceInfo } from 'src/workspaces/utils'; // Copied and slightly altered from WorkspaceCard and WorkspaceCardHeaders in Workspaces, // May want to instead extend them const workspaceLastModifiedWidth = 150; -export interface CrossBillingWorkspaceCardHeadersProps { - needsStatusColumn: boolean; +interface CrossBillingWorkspaceCardHeadersProps { sort: { field: string; direction: 'asc' | 'desc' }; onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; } -export const CrossBillingWorkspaceCardHeaders: React.FC = memoWithName( +const CrossBillingWorkspaceCardHeaders: React.FC = memoWithName( 'CrossBillingWorkspaceCardHeaders', (props: CrossBillingWorkspaceCardHeadersProps) => { - const { needsStatusColumn, sort, onSort } = props; + const { sort, onSort } = props; return (
- {needsStatusColumn && ( -
-
Status
-
- )} -
+
-
+
@@ -93,16 +80,16 @@ export const CrossBillingWorkspaceCardHeaders: React.FC = memoWithName( +const CrossBillingWorkspaceCard: React.FC = memoWithName( 'CrossBillingWorkspaceCard', (props: CrossBillingWorkspaceCardProps) => { - const { workspace, billingProject, billingAccountStatus } = props; + const { workspace, billingAccountDisplayName, billingProject, billingAccountStatus } = props; const { namespace, name, createdBy, lastModified, totalSpend, totalCompute, totalStorage } = workspace; const workspaceCardStyles = { field: { @@ -118,13 +105,8 @@ export const CrossBillingWorkspaceCard: React.FC return (
- {billingAccountStatus && ( -
- -
- )}
- {billingProject.projectName ?? '...'} + {billingAccountDisplayName ?? '...'}
/// /// export const CrossBillingSpendReport = (): ReactNode => { - // const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); // todo set this up + const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [updating, setUpdating] = useState(false); - const [selectedDays, setSelectedDays] = useState(30); - // const [searchValue, setSearchValue] = useState(''); - const [ownedWorkspaces, setOwnedWorkspaces] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [searchValue, setSearchValue] = useState(''); + const [ownedWorkspaces, setOwnedWorkspaces] = useState([]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [workspaceSort, setWorkspaceSort] = useState<{ field: string; direction: 'asc' | 'desc' }>({ field: 'totalSpend', direction: 'desc', }); + // BIG TODO - paginate without making too many BQ calls but also knowing how many pages there are??? + const [pageNumber, setPageNumber] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(25); + 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(() => { - const getWorkspaceSpendData = async ( - selectedDays: number, - signal: AbortSignal, - workspaces: BaseWorkspaceInfo[] - ) => { + const getWorkspaceSpendData = async (signal: AbortSignal) => { // Define start and end dates - const startDate = subDays(selectedDays, new Date()).toISOString().slice(0, 10); const endDate = new Date().toISOString().slice(0, 10); + const startDate = subDays(spendReportLengthInDays, new Date()).toISOString().slice(0, 10); - const setDefaultSpendValues = (workspace: BaseWorkspaceInfo) => ({ + const setDefaultSpendValues = (workspace: GoogleWorkspaceInfo) => ({ ...workspace, totalSpend: 'N/A', totalCompute: 'N/A', @@ -208,16 +202,31 @@ export const CrossBillingSpendReport = (): ReactNode => { try { // Fetch the spend report for the billing project + // TODO Cache the result so it doesn't get called on every page change const crossBillingSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ startDate, endDate, - pageSize: 10, // TODO pass these in - offset: 0, // TODO pass these in + pageSize: itemsPerPage, + offset: itemsPerPage * (pageNumber - 1), }); const spendDataItems = (crossBillingSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( (detail) => detail.spendData[0] ); + const allWorkspaces = await Ajax(signal).Workspaces.list( + [ + 'workspace.billingAccount', + 'workspace.bucketName', + 'workspace.createdBy', + 'workspace.createdDate', + 'workspace.googleProject', + 'workspace.lastModified', + 'workspace.name', + 'workspace.namespace', + ], + 250 // TODO what to do here + ); + // Update each workspace with spend data or default values return spendDataItems.map((spendItem) => { const costFormatter = new Intl.NumberFormat(navigator.language, { @@ -225,13 +234,23 @@ export const CrossBillingSpendReport = (): ReactNode => { currency: spendItem.currency, }); + // TODO what if it's not found + const workspaceDetails = allWorkspaces.find( + (ws) => + ws.workspace.name === spendItem.workspace.name && ws.workspace.namespace === spendItem.workspace.namespace + ); + return { ...spendItem.workspace, - workspaceId: 'temp', + workspaceId: `${spendItem.workspace.namespace}-${spendItem.workspace.name}`, authorizationDomain: [], - createdDate: '2024-12-01', - createdBy: 'temp', - lastModified: '2024-12-01', + createdDate: workspaceDetails?.workspace.createdDate, + createdBy: workspaceDetails?.workspace.createdBy, + lastModified: workspaceDetails?.workspace.lastModified, + + billingAccount: workspaceDetails?.workspace.billingAccount, + projectName: workspaceDetails?.workspace.projectName, + totalSpend: costFormatter.format(parseFloat(spendItem.cost ?? '0.00')), totalCompute: costFormatter.format( parseFloat(_.find({ category: 'Compute' }, spendItem.subAggregation.spendData)?.cost ?? '0.00') @@ -246,18 +265,20 @@ export const CrossBillingSpendReport = (): ReactNode => { }); } catch { // Return default values for each workspace in case of an error - return workspaces.map(setDefaultSpendValues); + return ownedWorkspaces.map(setDefaultSpendValues); } finally { // Ensure updating state is reset regardless of success or failure setUpdating(false); } }; - getWorkspaceSpendData(selectedDays, signal, []).then((updatedWorkspaceInProject) => { - if (updatedWorkspaceInProject) { - setOwnedWorkspaces(updatedWorkspaceInProject); + getWorkspaceSpendData(signal).then((updatedWorkspaces) => { + if (updatedWorkspaces) { + setOwnedWorkspaces(updatedWorkspaces); } + setUpdating(false); }); - }, [selectedDays, signal]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signal, spendReportLengthInDays]); return ( <> @@ -273,9 +294,13 @@ export const CrossBillingSpendReport = (): ReactNode => { { + if (selectedOption !== spendReportLengthInDays) { + setSpendReportLengthInDays(selectedOption); + } + }} /> { Total spend includes infrastructure or query costs related to the general operations of Terra.
- {_.isEmpty(ownedWorkspaces) ? ( + {_.isEmpty(filteredOwnedWorkspaces) ? (
{
) : ( - !_.isEmpty(ownedWorkspaces) && ( + !_.isEmpty(filteredOwnedWorkspaces) && (
- {}} - sort={{ field: 'name', direction: 'asc' }} - /> +
{_.flow( _.orderBy( [(workspace) => parseCurrencyIfNeeded(workspaceSort.field, _.get(workspaceSort.field, workspace))], [workspaceSort.direction] ), - _.map((workspace: WorkspaceInfo) => { + _.map((workspace: GoogleWorkspaceInfo) => { return ( { /> ); }) - )(ownedWorkspaces)} - {/* {updating && fixedSpinnerOverlay} */} + )(filteredOwnedWorkspaces)} +
+ { + // @ts-expect-error + { + setPageNumber(1); + setItemsPerPage(v); + }} + /> + } +
+ {updating && fixedSpinnerOverlay}
) From 94477d2d04d92f913b7a0b98c1a3c338685327a2 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Tue, 10 Dec 2024 16:12:46 -0500 Subject: [PATCH 03/17] some fixes and renaming --- ...Report.tsx => ConsolidatedSpendReport.tsx} | 77 +++++++++---------- src/billing/List/BillingList.tsx | 43 +++++++---- src/libs/feature-previews-config.ts | 11 +++ src/pages/billing/BillingListPage.test.tsx | 14 ++-- 4 files changed, 81 insertions(+), 64 deletions(-) rename src/billing/{CrossBillingSpendReport.tsx => ConsolidatedSpendReport.tsx} (89%) diff --git a/src/billing/CrossBillingSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx similarity index 89% rename from src/billing/CrossBillingSpendReport.tsx rename to src/billing/ConsolidatedSpendReport.tsx index 80b87fd356..e28eee1452 100644 --- a/src/billing/CrossBillingSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -26,14 +26,14 @@ import { GoogleWorkspaceInfo } from 'src/workspaces/utils'; // May want to instead extend them const workspaceLastModifiedWidth = 150; -interface CrossBillingWorkspaceCardHeadersProps { +interface ConsolidatedSpendWorkspaceCardHeadersProps { sort: { field: string; direction: 'asc' | 'desc' }; onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; } -const CrossBillingWorkspaceCardHeaders: React.FC = memoWithName( - 'CrossBillingWorkspaceCardHeaders', - (props: CrossBillingWorkspaceCardHeadersProps) => { +const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( + 'ConsolidatedSpendWorkspaceCardHeaders', + (props: ConsolidatedSpendWorkspaceCardHeadersProps) => { const { sort, onSort } = props; return (
= memoWithName( - 'CrossBillingWorkspaceCard', - (props: CrossBillingWorkspaceCardProps) => { +const ConsolidatedSpendWorkspaceCard: React.FC = memoWithName( + 'ConsolidatedSpendWorkspaceCard', + (props: ConsolidatedSpendWorkspaceCardProps) => { const { workspace, billingAccountDisplayName, billingProject, billingAccountStatus } = props; const { namespace, name, createdBy, lastModified, totalSpend, totalCompute, totalStorage } = workspace; + const workspaceCardStyles = { field: { ...Style.noWrapEllipsis, @@ -155,7 +156,7 @@ const CrossBillingWorkspaceCard: React.FC = memo ); /// /// -export const CrossBillingSpendReport = (): ReactNode => { +export const ConsolidatedSpendReport = (): ReactNode => { const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [updating, setUpdating] = useState(false); @@ -187,6 +188,8 @@ export const CrossBillingSpendReport = (): ReactNode => { useEffect(() => { const getWorkspaceSpendData = async (signal: AbortSignal) => { + setUpdating(true); + // Define start and end dates const endDate = new Date().toISOString().slice(0, 10); const startDate = subDays(spendReportLengthInDays, new Date()).toISOString().slice(0, 10); @@ -198,18 +201,16 @@ export const CrossBillingSpendReport = (): ReactNode => { totalStorage: 'N/A', }); - setUpdating(true); - try { // Fetch the spend report for the billing project // TODO Cache the result so it doesn't get called on every page change - const crossBillingSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ + const consolidatedSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ startDate, endDate, pageSize: itemsPerPage, offset: itemsPerPage * (pageNumber - 1), }); - const spendDataItems = (crossBillingSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( + const spendDataItems = (consolidatedSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( (detail) => detail.spendData[0] ); @@ -275,14 +276,26 @@ export const CrossBillingSpendReport = (): ReactNode => { if (updatedWorkspaces) { setOwnedWorkspaces(updatedWorkspaces); } - setUpdating(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [signal, spendReportLengthInDays]); return ( <> - <> +
+ Consolidated Spend Report +
+
{ * Total spend includes infrastructure or query costs related to the general operations of Terra.
- - {_.isEmpty(filteredOwnedWorkspaces) ? ( -
-
- - - Workspaces - -
-
- ) : ( - !_.isEmpty(filteredOwnedWorkspaces) && ( + {!_.isEmpty(filteredOwnedWorkspaces) && (
- +
{_.flow( _.orderBy( @@ -342,7 +339,7 @@ export const CrossBillingSpendReport = (): ReactNode => { ), _.map((workspace: GoogleWorkspaceInfo) => { return ( - { /> }
- {updating && fixedSpinnerOverlay}
- ) - )} + )} + {updating && fixedSpinnerOverlay} +
); }; diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index edc98e83b4..6eefa8a6b5 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -18,6 +18,8 @@ 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 { CONSOLIDATED_SPEND_REPORT } 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'; @@ -26,7 +28,7 @@ import * as Utils from 'src/libs/utils'; import { useWorkspaces } from 'src/workspaces/common/state/useWorkspaces'; import { CloudProvider, cloudProviderTypes, WorkspaceWrapper } from 'src/workspaces/utils'; -import { CrossBillingSpendReport } from '../CrossBillingSpendReport'; +import { ConsolidatedSpendReport } from '../ConsolidatedSpendReport'; const BillingProjectSubheader: React.FC<{ title: string; children: ReactNode }> = ({ title, children }) => ( { /> ); } - if (type === 'crossBillingProjectSpend' && !_.isEmpty(projectsOwned) && !selectedName) { + if (type === 'consolidatedSpendReport' && !_.isEmpty(projectsOwned) && !selectedName) { const billingProject = billingProjects.find(({ projectName }) => projectName === selectedName); return ( - { /> ); } - if (type !== 'crossBillingProjectSpend' && !_.isEmpty(projectsOwned) && !selectedName) { + if (type !== 'consolidatedSpendReport' && !_.isEmpty(projectsOwned) && !selectedName) { return
Select a Billing Project
; } }; @@ -315,19 +317,26 @@ export const BillingList = (props: BillingListProps) => {

Billing Projects

- - Cross Billing Project Spend Report - + {isFeaturePreviewEnabled(CONSOLIDATED_SPEND_REPORT) && ( + + Consolidated Spend Report + + )}
{projectsOwned.map((project) => ( diff --git a/src/libs/feature-previews-config.ts b/src/libs/feature-previews-config.ts index 86002a9828..7882f4af48 100644 --- a/src/libs/feature-previews-config.ts +++ b/src/libs/feature-previews-config.ts @@ -9,6 +9,7 @@ export const SPEND_REPORTING = 'spendReporting'; export const AUTO_GENERATE_DATA_TABLES = 'autoGenerateDataTables'; export const PREVIEW_COST_CAPPING = 'previewCostCapping'; export const IGV_ENHANCEMENTS = 'igvEnhancements'; +export const CONSOLIDATED_SPEND_REPORT = 'consolidatedSpendReport'; // If the groups option is defined for a FeaturePreview, it must contain at least one group. type GroupsList = readonly [string, ...string[]]; @@ -150,6 +151,16 @@ const featurePreviewsConfig: readonly FeaturePreview[] = [ )}`, lastUpdated: '12/12/2024', }, + { + id: CONSOLIDATED_SPEND_REPORT, + title: 'Show spend report for all workspaces owned by user', + description: + 'Enabling this feature will allow user to generate a spend report across all billing projects in which they own workspaces', + feedbackUrl: `mailto:dsp-core-services@broadinstitute.org?subject=${encodeURIComponent( + 'Feedback on Consolidated Spend Report' + )}`, + lastUpdated: '12/12/2024', + }, ]; export default featurePreviewsConfig; 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( From 1ec9649a34f9827284e62616a441599b85d749c3 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Thu, 12 Dec 2024 11:08:16 -0500 Subject: [PATCH 04/17] udpates and tests --- src/billing/ConsolidatedSpendReport.test.tsx | 253 +++++++++++++++++++ src/billing/ConsolidatedSpendReport.tsx | 33 +-- src/billing/List/BillingList.test.tsx | 57 +++++ src/billing/List/BillingList.tsx | 12 +- 4 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 src/billing/ConsolidatedSpendReport.test.tsx create mode 100644 src/billing/List/BillingList.test.tsx diff --git a/src/billing/ConsolidatedSpendReport.test.tsx b/src/billing/ConsolidatedSpendReport.test.tsx new file mode 100644 index 0000000000..a0fa88ae42 --- /dev/null +++ b/src/billing/ConsolidatedSpendReport.test.tsx @@ -0,0 +1,253 @@ +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 { 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', + }, + }, + { + 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', + }, + }, + { + 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', + }, + }, +]; + +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 index e28eee1452..2419174f15 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -8,7 +8,6 @@ import { BillingAccountStatus, parseCurrencyIfNeeded } from 'src/billing/utils'; import { BillingProject } from 'src/billing-core/models'; import { fixedSpinnerOverlay } from 'src/components/common'; import { ariaSort, HeaderRenderer, Paginator } from 'src/components/table'; -import { Ajax } from 'src/libs/ajax'; import { Billing } from 'src/libs/ajax/billing/Billing'; import { AggregatedWorkspaceSpendData, @@ -20,7 +19,7 @@ import * as Nav from 'src/libs/nav'; import { memoWithName, useCancellation } from 'src/libs/react-utils'; import * as Style from 'src/libs/style'; import * as Utils from 'src/libs/utils'; -import { GoogleWorkspaceInfo } from 'src/workspaces/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 @@ -31,7 +30,7 @@ interface ConsolidatedSpendWorkspaceCardHeadersProps { onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; } -const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( +const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( 'ConsolidatedSpendWorkspaceCardHeaders', (props: ConsolidatedSpendWorkspaceCardHeadersProps) => { const { sort, onSort } = props; @@ -156,7 +155,11 @@ const ConsolidatedSpendWorkspaceCard: React.FC { +interface ConsolidatedSpendReportProps { + workspaces: WorkspaceWrapper[]; +} + +export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): ReactNode => { const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [updating, setUpdating] = useState(false); @@ -168,6 +171,7 @@ export const ConsolidatedSpendReport = (): ReactNode => { field: 'totalSpend', direction: 'desc', }); + const allWorkspaces = props.workspaces; // BIG TODO - paginate without making too many BQ calls but also knowing how many pages there are??? const [pageNumber, setPageNumber] = useState(1); @@ -210,24 +214,11 @@ export const ConsolidatedSpendReport = (): ReactNode => { pageSize: itemsPerPage, offset: itemsPerPage * (pageNumber - 1), }); + const spendDataItems = (consolidatedSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( (detail) => detail.spendData[0] ); - const allWorkspaces = await Ajax(signal).Workspaces.list( - [ - 'workspace.billingAccount', - 'workspace.bucketName', - 'workspace.createdBy', - 'workspace.createdDate', - 'workspace.googleProject', - 'workspace.lastModified', - 'workspace.name', - 'workspace.namespace', - ], - 250 // TODO what to do here - ); - // Update each workspace with spend data or default values return spendDataItems.map((spendItem) => { const costFormatter = new Intl.NumberFormat(navigator.language, { @@ -237,7 +228,7 @@ export const ConsolidatedSpendReport = (): ReactNode => { // TODO what if it's not found const workspaceDetails = allWorkspaces.find( - (ws) => + (ws): ws is GoogleWorkspace => ws.workspace.name === spendItem.workspace.name && ws.workspace.namespace === spendItem.workspace.namespace ); @@ -250,7 +241,9 @@ export const ConsolidatedSpendReport = (): ReactNode => { lastModified: workspaceDetails?.workspace.lastModified, billingAccount: workspaceDetails?.workspace.billingAccount, - projectName: workspaceDetails?.workspace.projectName, + googleProject: workspaceDetails?.workspace.googleProject, + cloudPlatform: 'Gcp', + bucketName: workspaceDetails?.workspace.bucketName, totalSpend: costFormatter.format(parseFloat(spendItem.cost ?? '0.00')), totalCompute: costFormatter.format( diff --git a/src/billing/List/BillingList.test.tsx b/src/billing/List/BillingList.test.tsx new file mode 100644 index 0000000000..29070d9d09 --- /dev/null +++ b/src/billing/List/BillingList.test.tsx @@ -0,0 +1,57 @@ +import { act, screen } from '@testing-library/react'; +import React from 'react'; +import { isFeaturePreviewEnabled } from 'src/libs/feature-previews'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +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(() => '/'), +})); + +type AuthExports = typeof import('src/auth/auth'); + +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 6eefa8a6b5..e442fae9f2 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -139,21 +139,11 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { ); } if (type === 'consolidatedSpendReport' && !_.isEmpty(projectsOwned) && !selectedName) { - const billingProject = billingProjects.find(({ projectName }) => projectName === selectedName); return ( reloadBillingProject(billingProject).catch(loadProjects)} - isOwner={projectsOwned.some(({ projectName }) => projectName === selectedName)} workspaces={allWorkspaces} - refreshWorkspaces={refreshWorkspaces} /> ); } @@ -162,7 +152,7 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { } }; -interface BillingListProps { +export interface BillingListProps { queryParams: { selectedName: string | undefined; type: string | undefined; From 8e4acb2157f13ce6edf190acbe3528fabb0ac159 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Thu, 12 Dec 2024 15:27:09 -0500 Subject: [PATCH 05/17] begin caching --- src/billing/ConsolidatedSpendReport.test.tsx | 7 +- src/billing/ConsolidatedSpendReport.tsx | 136 ++++++++++--------- src/libs/state.ts | 10 +- 3 files changed, 88 insertions(+), 65 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.test.tsx b/src/billing/ConsolidatedSpendReport.test.tsx index a0fa88ae42..dbef0079b7 100644 --- a/src/billing/ConsolidatedSpendReport.test.tsx +++ b/src/billing/ConsolidatedSpendReport.test.tsx @@ -4,6 +4,7 @@ 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'; @@ -147,7 +148,7 @@ const workspaces = [ workspaceType: 'rawls', workspaceVersion: 'v2', }, - }, + } as WorkspaceWrapper, { canShare: true, canCompute: true, @@ -180,7 +181,7 @@ const workspaces = [ workspaceType: 'rawls', workspaceVersion: 'v2', }, - }, + } as WorkspaceWrapper, { canShare: true, canCompute: true, @@ -213,7 +214,7 @@ const workspaces = [ workspaceType: 'rawls', workspaceVersion: 'v2', }, - }, + } as WorkspaceWrapper, ]; describe('ConsolidatedSpendReport', () => { diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 2419174f15..dd4a859315 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -17,6 +17,7 @@ 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'; @@ -181,6 +182,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re // Apply filters to ownedWorkspaces const searchValueLower = searchValue.toLowerCase(); + const filteredOwnedWorkspaces = _.filter( (workspace: GoogleWorkspaceInfo) => workspace.name.toLowerCase().includes(searchValueLower) || @@ -191,82 +193,94 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re ); useEffect(() => { - const getWorkspaceSpendData = async (signal: AbortSignal) => { + // Define start and end dates + const endDate = new Date().toISOString().slice(0, 10); + const startDate = subDays(spendReportLengthInDays, new Date()).toISOString().slice(0, 10); + const getWorkspaceSpendData = async (signal: AbortSignal): Promise => { setUpdating(true); - // Define start and end dates - const endDate = new Date().toISOString().slice(0, 10); - const startDate = subDays(spendReportLengthInDays, new Date()).toISOString().slice(0, 10); - - const setDefaultSpendValues = (workspace: GoogleWorkspaceInfo) => ({ - ...workspace, - totalSpend: 'N/A', - totalCompute: 'N/A', - totalStorage: 'N/A', - }); - - try { - // Fetch the spend report for the billing project - // TODO Cache the result so it doesn't get called on every page change - const consolidatedSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ - startDate, - endDate, - pageSize: itemsPerPage, - offset: itemsPerPage * (pageNumber - 1), + const spendReportStoreLocal = spendReportStore.get(); + const storedSpendReport = spendReportStoreLocal?.[spendReportLengthInDays]; + if (!storedSpendReport || storedSpendReport.startDate !== startDate || storedSpendReport.endDate !== endDate) { + const setDefaultSpendValues = (workspace: GoogleWorkspaceInfo) => ({ + ...workspace, + totalSpend: 'N/A', + totalCompute: 'N/A', + totalStorage: 'N/A', }); - 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, + try { + // Fetch the spend report for the billing project + // TODO Cache the result so it doesn't get called on every page change + const consolidatedSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ + startDate, + endDate, + pageSize: itemsPerPage, + offset: itemsPerPage * (pageNumber - 1), }); - // TODO what if it's not found - const workspaceDetails = allWorkspaces.find( - (ws): ws is GoogleWorkspace => - ws.workspace.name === spendItem.workspace.name && ws.workspace.namespace === spendItem.workspace.namespace + const spendDataItems = (consolidatedSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( + (detail) => detail.spendData[0] ); - return { - ...spendItem.workspace, - workspaceId: `${spendItem.workspace.namespace}-${spendItem.workspace.name}`, - authorizationDomain: [], - createdDate: workspaceDetails?.workspace.createdDate, - createdBy: workspaceDetails?.workspace.createdBy, - lastModified: workspaceDetails?.workspace.lastModified, + // 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, + }); - billingAccount: workspaceDetails?.workspace.billingAccount, - googleProject: workspaceDetails?.workspace.googleProject, - cloudPlatform: 'Gcp', - bucketName: workspaceDetails?.workspace.bucketName, + // TODO what if it's not found + const workspaceDetails = allWorkspaces.find( + (ws): ws is GoogleWorkspace => + ws.workspace.name === spendItem.workspace.name && + ws.workspace.namespace === spendItem.workspace.namespace + ); - 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') - // ), - }; - }); - } 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 + return { + ...spendItem.workspace, + workspaceId: `${spendItem.workspace.namespace}-${spendItem.workspace.name}`, + 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; } }; getWorkspaceSpendData(signal).then((updatedWorkspaces) => { if (updatedWorkspaces) { + spendReportStore.update((store) => { + const newStore: SpendReportStore = { ...store }; + newStore[spendReportLengthInDays] = { spendReport: updatedWorkspaces, startDate, endDate }; + return newStore; + }); setOwnedWorkspaces(updatedWorkspaces); } }); diff --git a/src/libs/state.ts b/src/libs/state.ts index 8b7f079f92..95def9a072 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([]); @@ -379,3 +379,11 @@ export const workflowsAppStore = atom({ cbasProxyUrlState: { status: AppProxyUrlStatus.None, state: '' }, cromwellProxyUrlState: { status: AppProxyUrlStatus.None, state: '' }, }); + +export type SpendReportStore = { + 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); From bc30bbb0efae436eebec7ace589a90f8001571ce Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Thu, 12 Dec 2024 16:08:51 -0500 Subject: [PATCH 06/17] fix test and add mixpanel event --- src/billing/List/BillingList.test.tsx | 27 ++++++++++++++++++++++++++- src/billing/List/BillingList.tsx | 3 ++- src/libs/events.ts | 1 + 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/billing/List/BillingList.test.tsx b/src/billing/List/BillingList.test.tsx index 29070d9d09..cf0f022ad6 100644 --- a/src/billing/List/BillingList.test.tsx +++ b/src/billing/List/BillingList.test.tsx @@ -1,7 +1,9 @@ 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 { isFeaturePreviewEnabled } from 'src/libs/feature-previews'; -import { renderWithAppContexts as render } from 'src/testing/test-utils'; +import { asMockedFn, partial, renderWithAppContexts as render } from 'src/testing/test-utils'; import { BillingList, BillingListProps } from './BillingList'; @@ -13,6 +15,29 @@ jest.mock('src/libs/nav', () => ({ })); 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', + }), + partial({ + // billingAccount: 'billingAccounts/BAA-RAM-EWE', + cloudPlatform: 'AZURE', + invalidBillingAccount: false, + projectName: 'Azure Billing Project', + roles: ['Owner'], + status: 'Ready', + }), + ], + }) +); jest.mock('src/auth/auth', (): AuthExports => { const originalModule = jest.requireActual('src/auth/auth'); diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index e442fae9f2..99009e5fa4 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -138,7 +138,7 @@ const RightHandContent = (props: RightHandContentProps): ReactNode => { /> ); } - if (type === 'consolidatedSpendReport' && !_.isEmpty(projectsOwned) && !selectedName) { + if (type === 'consolidatedSpendReport' && !selectedName) { return ( { type: 'consolidatedSpendReport', })}`} aria-current={false} + onClick={void Metrics().captureEvent(Events.billingViewConsolidatedSpendReport)} > Consolidated Spend Report diff --git a/src/libs/events.ts b/src/libs/events.ts index 914d9a7b72..7c26c0e320 100644 --- a/src/libs/events.ts +++ b/src/libs/events.ts @@ -57,6 +57,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', From 15a986f6b806b84842b14ac88f79c1e26fdbe533 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 13 Dec 2024 09:10:53 -0500 Subject: [PATCH 07/17] hopefully fix test --- src/billing/List/BillingList.test.tsx | 29 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/billing/List/BillingList.test.tsx b/src/billing/List/BillingList.test.tsx index cf0f022ad6..c5c87c8f3d 100644 --- a/src/billing/List/BillingList.test.tsx +++ b/src/billing/List/BillingList.test.tsx @@ -2,8 +2,11 @@ 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'; @@ -14,6 +17,23 @@ jest.mock('src/libs/nav', () => ({ 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( @@ -27,15 +47,8 @@ asMockedFn(Billing).mockReturnValue( roles: ['Owner'], status: 'Ready', }), - partial({ - // billingAccount: 'billingAccounts/BAA-RAM-EWE', - cloudPlatform: 'AZURE', - invalidBillingAccount: false, - projectName: 'Azure Billing Project', - roles: ['Owner'], - status: 'Ready', - }), ], + getProject: async () => partial({ projectName: 'Google Billing Project' }), }) ); From b837aa6f1ac815ef7f59dff9dfe43a8223d203ad Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 13 Dec 2024 10:18:16 -0500 Subject: [PATCH 08/17] fix sorting --- src/billing/ConsolidatedSpendReport.tsx | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index dd4a859315..69ed134199 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -31,10 +31,22 @@ interface ConsolidatedSpendWorkspaceCardHeadersProps { onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; } +// Since the variable names don't match display names for these two columns, we need to map them +const columnTitleToVariableNameMap = { + billingAccount: 'namespace', + workspaceName: 'name', +}; + const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( 'ConsolidatedSpendWorkspaceCardHeaders', (props: ConsolidatedSpendWorkspaceCardHeadersProps) => { const { sort, onSort } = props; + + const handleSort = (name: string) => { + const variableName = columnTitleToVariableNameMap[name]; + onSort({ field: variableName, direction: sort.direction === 'asc' ? 'desc' : 'asc' }); + }; + return (
- + handleSort('billingAccount')} name='billingAccount' />
- + handleSort('workspaceName')} name='workspaceName' />
@@ -91,7 +103,6 @@ const ConsolidatedSpendWorkspaceCard: React.FC { const { workspace, billingAccountDisplayName, billingProject, billingAccountStatus } = props; const { namespace, name, createdBy, lastModified, totalSpend, totalCompute, totalStorage } = workspace; - const workspaceCardStyles = { field: { ...Style.noWrapEllipsis, @@ -147,7 +158,7 @@ const ConsolidatedSpendWorkspaceCard: React.FC
- {Utils.makeStandardDate(lastModified)} + {lastModified ? Utils.makeStandardDate(lastModified) : ' '}
@@ -334,10 +345,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re
{!_.isEmpty(filteredOwnedWorkspaces) && (
- +
{_.flow( _.orderBy( From 2348fcfae635c650c5eb7c1cbb5e21c859b48321 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 13 Dec 2024 11:19:31 -0500 Subject: [PATCH 09/17] hopefully fix missing workspaces problem --- src/billing/ConsolidatedSpendReport.tsx | 59 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 69ed134199..8a1038358a 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -8,6 +8,7 @@ import { BillingAccountStatus, parseCurrencyIfNeeded } from 'src/billing/utils'; import { BillingProject } from 'src/billing-core/models'; import { fixedSpinnerOverlay } from 'src/components/common'; import { ariaSort, HeaderRenderer, Paginator } from 'src/components/table'; +import { Ajax } from 'src/libs/ajax'; import { Billing } from 'src/libs/ajax/billing/Billing'; import { AggregatedWorkspaceSpendData, @@ -173,19 +174,16 @@ interface ConsolidatedSpendReportProps { export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): ReactNode => { const [spendReportLengthInDays, setSpendReportLengthInDays] = useState(30); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [updating, setUpdating] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [searchValue, setSearchValue] = useState(''); const [ownedWorkspaces, setOwnedWorkspaces] = useState([]); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [workspaceSort, setWorkspaceSort] = useState<{ field: string; direction: 'asc' | 'desc' }>({ field: 'totalSpend', direction: 'desc', }); - const allWorkspaces = props.workspaces; + const [allWorkspaces, setAllWorkspaces] = useState(props.workspaces); - // BIG TODO - paginate without making too many BQ calls but also knowing how many pages there are??? + // TODO - how to know how many workspaces there are in total in order to paginate without querying BQ? const [pageNumber, setPageNumber] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(25); @@ -207,6 +205,24 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re // 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', + ], + 250 // TODO what to do here + ); + return fetchedWorkspaces; + }; + const getWorkspaceSpendData = async (signal: AbortSignal): Promise => { setUpdating(true); @@ -222,7 +238,6 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re try { // Fetch the spend report for the billing project - // TODO Cache the result so it doesn't get called on every page change const consolidatedSpendReport: SpendReportServerResponse = await Billing(signal).getCrossBillingSpendReport({ startDate, endDate, @@ -241,7 +256,6 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re currency: spendItem.currency, }); - // TODO what if it's not found const workspaceDetails = allWorkspaces.find( (ws): ws is GoogleWorkspace => ws.workspace.name === spendItem.workspace.name && @@ -285,18 +299,31 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re return storedSpendReport.spendReport; } }; - getWorkspaceSpendData(signal).then((updatedWorkspaces) => { - if (updatedWorkspaces) { - spendReportStore.update((store) => { - const newStore: SpendReportStore = { ...store }; - newStore[spendReportLengthInDays] = { spendReport: updatedWorkspaces, startDate, endDate }; - return newStore; + + const initialize = async () => { + // Fetch workspaces if they haven't been fetched yet + if (!allWorkspaces || allWorkspaces.length === 0) { + setUpdating(true); + await fetchWorkspaces(signal).then((workspaces) => { + // const gcpWorkspaces = workspaces.filter((ws) => ws.workspace.cloudPlatform === 'Gcp'); + setAllWorkspaces(workspaces); + }); + } else { + getWorkspaceSpendData(signal).then((updatedWorkspaces) => { + if (updatedWorkspaces) { + spendReportStore.update((store) => { + const newStore: SpendReportStore = { ...store }; + newStore[spendReportLengthInDays] = { spendReport: updatedWorkspaces, startDate, endDate }; + return newStore; + }); + setOwnedWorkspaces(updatedWorkspaces); + } }); - setOwnedWorkspaces(updatedWorkspaces); } - }); + }; + initialize(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signal, spendReportLengthInDays]); + }, [signal, spendReportLengthInDays, allWorkspaces]); return ( <> From b65972d79df6e22259817b17bb79a85280f236e5 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 13 Dec 2024 11:29:42 -0500 Subject: [PATCH 10/17] fix merge --- src/libs/feature-previews-config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/feature-previews-config.ts b/src/libs/feature-previews-config.ts index 9247f6762e..afbc1abe02 100644 --- a/src/libs/feature-previews-config.ts +++ b/src/libs/feature-previews-config.ts @@ -158,9 +158,11 @@ const featurePreviewsConfig: readonly FeaturePreview[] = [ description: 'Enabling this feature will allow user to generate a spend report across all billing projects in which they own workspaces', feedbackUrl: `mailto:dsp-core-services@broadinstitute.org?subject=${encodeURIComponent( - 'Feedback on Consolidated Spend Report')}`, + 'Feedback on Consolidated Spend Report' + )}`, lastUpdated: '12/20/2024', - }, + }, + { id: GCP_BATCH, title: 'Run workflows on GCP Batch', description: From 631c150c075931d88272c77619d2cf215395409c Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 13 Dec 2024 15:15:15 -0500 Subject: [PATCH 11/17] update/remove pagination --- src/billing/ConsolidatedSpendReport.tsx | 97 +++++++++++++------------ 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 8a1038358a..3c1a1b7992 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -7,7 +7,7 @@ import { SearchFilter } from 'src/billing/Filter/SearchFilter'; import { BillingAccountStatus, parseCurrencyIfNeeded } from 'src/billing/utils'; import { BillingProject } from 'src/billing-core/models'; import { fixedSpinnerOverlay } from 'src/components/common'; -import { ariaSort, HeaderRenderer, Paginator } from 'src/components/table'; +import { ariaSort, HeaderRenderer } from 'src/components/table'; import { Ajax } from 'src/libs/ajax'; import { Billing } from 'src/libs/ajax/billing/Billing'; import { @@ -184,8 +184,10 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re const [allWorkspaces, setAllWorkspaces] = useState(props.workspaces); // TODO - how to know how many workspaces there are in total in order to paginate without querying BQ? + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [pageNumber, setPageNumber] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(25); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [itemsPerPage, setItemsPerPage] = useState(250); const signal = useCancellation(); @@ -218,7 +220,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re 'workspace.name', 'workspace.namespace', ], - 250 // TODO what to do here + itemsPerPage // TODO what to do here ); return fetchedWorkspaces; }; @@ -305,7 +307,6 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re if (!allWorkspaces || allWorkspaces.length === 0) { setUpdating(true); await fetchWorkspaces(signal).then((workspaces) => { - // const gcpWorkspaces = workspaces.filter((ws) => ws.workspace.cloudPlatform === 'Gcp'); setAllWorkspaces(workspaces); }); } else { @@ -371,51 +372,53 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re 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)} -
- { - // @ts-expect-error - { - setPageNumber(1); - setItemsPerPage(v); - }} - /> - } + <> +
+ +
+ {_.flow( + _.orderBy( + [(workspace) => parseCurrencyIfNeeded(workspaceSort.field, _.get(workspaceSort.field, workspace))], + [workspaceSort.direction] + ), + _.map((workspace: GoogleWorkspaceInfo) => { + return ( + + ); + }) + )(filteredOwnedWorkspaces)}
-
+ {/*
+ { + // @ts-expect-error + { + setPageNumber(1); + setItemsPerPage(v); + }} + /> + } +
*/} + )} {updating && fixedSpinnerOverlay}
From 41d6f485f4a0c60b82dc3bf47917825673c30ac5 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Wed, 18 Dec 2024 10:54:48 -0500 Subject: [PATCH 12/17] pr comments --- src/billing/ConsolidatedSpendReport.tsx | 32 +++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 3c1a1b7992..b4086fcacc 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -1,10 +1,11 @@ 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 { BillingAccountStatus, parseCurrencyIfNeeded } from 'src/billing/utils'; +import { parseCurrencyIfNeeded } from 'src/billing/utils'; import { BillingProject } from 'src/billing-core/models'; import { fixedSpinnerOverlay } from 'src/components/common'; import { ariaSort, HeaderRenderer } from 'src/components/table'; @@ -34,7 +35,7 @@ interface ConsolidatedSpendWorkspaceCardHeadersProps { // Since the variable names don't match display names for these two columns, we need to map them const columnTitleToVariableNameMap = { - billingAccount: 'namespace', + billingProject: 'namespace', workspaceName: 'name', }; @@ -59,8 +60,8 @@ const ConsolidatedSpendWorkspaceCardHeaders: React.FC -
- handleSort('billingAccount')} name='billingAccount' /> +
+ handleSort('billingProject')} name='billingProject' />
handleSort('workspaceName')} name='workspaceName' /> @@ -94,15 +95,13 @@ const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( 'ConsolidatedSpendWorkspaceCard', (props: ConsolidatedSpendWorkspaceCardProps) => { - const { workspace, billingAccountDisplayName, billingProject, billingAccountStatus } = props; + const { workspace, billingProject } = props; const { namespace, name, createdBy, lastModified, totalSpend, totalCompute, totalStorage } = workspace; const workspaceCardStyles = { field: { @@ -119,7 +118,18 @@ const ConsolidatedSpendWorkspaceCard: React.FC
- {billingAccountDisplayName ?? '...'} + { + void Metrics().captureEvent(Events.billingProjectGoToWorkspace, { + workspaceName: name, + ...extractBillingDetails(billingProject), + }); + }} + > + {namespace ?? '...'} +
); From 4cfa9dc3823fe2ee7eb2c333f867301674d99d22 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Wed, 18 Dec 2024 16:05:24 -0500 Subject: [PATCH 13/17] add new events --- src/billing/ConsolidatedSpendReport.tsx | 9 ++++----- src/libs/events.ts | 2 ++ src/libs/feature-previews-config.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index b4086fcacc..51e048e5fb 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -122,8 +122,7 @@ const ConsolidatedSpendWorkspaceCard: React.FC { - void Metrics().captureEvent(Events.billingProjectGoToWorkspace, { - workspaceName: name, + void Metrics().captureEvent(Events.billingConsolidatedReportGoToBillingProject, { ...extractBillingDetails(billingProject), }); }} @@ -144,7 +143,7 @@ const ConsolidatedSpendWorkspaceCard: React.FC { - void Metrics().captureEvent(Events.billingProjectGoToWorkspace, { + void Metrics().captureEvent(Events.billingConsolidatedReportGoToWorkspace, { workspaceName: name, ...extractBillingDetails(billingProject), }); @@ -397,8 +396,8 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re workspace={workspace} billingProject={{ cloudPlatform: 'GCP', - billingAccount: workspace.namespace, - projectName: workspace.googleProject, + billingAccount: workspace.billingAccount, + projectName: workspace.namespace, invalidBillingAccount: false, roles: ['Owner'], status: 'Ready', diff --git a/src/libs/events.ts b/src/libs/events.ts index a40cb1a1e7..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', diff --git a/src/libs/feature-previews-config.ts b/src/libs/feature-previews-config.ts index ec6c9ea84f..fd4401f661 100644 --- a/src/libs/feature-previews-config.ts +++ b/src/libs/feature-previews-config.ts @@ -163,7 +163,7 @@ const featurePreviewsConfig: readonly FeaturePreview[] = [ }, { id: CONSOLIDATED_SPEND_REPORT, - title: 'Show spend report for all workspaces owned by user', + title: 'Show Consolidated Spend Report for all workspaces owned by user', description: 'Enabling this feature will allow user to generate a spend report across all billing projects in which they own workspaces', feedbackUrl: `mailto:dsp-core-services@broadinstitute.org?subject=${encodeURIComponent( From b0b24ae9423e79db8b51620d97de101dbd1c6f1b Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Thu, 9 Jan 2025 16:33:09 -0500 Subject: [PATCH 14/17] begin to updated spend report list item --- src/billing/ConsolidatedSpendReport.tsx | 2 +- src/billing/List/BillingList.tsx | 71 +++++++++++++++++-------- src/billing/List/ProjectListItem.tsx | 2 +- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 51e048e5fb..69cbd93d00 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -276,7 +276,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re return { ...spendItem.workspace, workspaceId: `${spendItem.workspace.namespace}-${spendItem.workspace.name}`, - authorizationDomain: [], + authorizationDomain: workspaceDetails?.workspace.authorizationDomain, createdDate: workspaceDetails?.workspace.createdDate, createdBy: workspaceDetails?.workspace.createdBy, lastModified: workspaceDetails?.workspace.lastModified, diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index 99009e5fa4..abfbd4c175 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -1,4 +1,4 @@ -import { Clickable, SpinnerOverlay } from '@terra-ui-packages/components'; +import { SpinnerOverlay } from '@terra-ui-packages/components'; import { withHandlers } from '@terra-ui-packages/core-utils'; import _ from 'lodash/fp'; import * as qs from 'qs'; @@ -6,13 +6,14 @@ 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 { 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'; @@ -308,25 +309,53 @@ export const BillingList = (props: BillingListProps) => {
{isFeaturePreviewEnabled(CONSOLIDATED_SPEND_REPORT) && ( - - Consolidated Spend Report - +
+
+
setHovered(true)} + // onMouseLeave={() => setHovered(false)} + > + void Metrics().captureEvent(Events.billingProjectOpenFromList, extractBillingDetails(props.project)) + // // (isActive = !isActive) + // } + aria-current={type === 'consolidatedSpendReport' ? 'location' : false} + > + Consolidated Spend Report + +
+
+
+ + // + // Consolidated Spend Report + // )}
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), From f52b54d544313bcbb28e401c8866d8ce5856bc6a Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 10 Jan 2025 11:09:50 -0500 Subject: [PATCH 15/17] update feature preview and hover ui --- src/billing/List/BillingList.tsx | 37 ++++++----------------------- src/libs/feature-previews-config.ts | 13 +--------- 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/src/billing/List/BillingList.tsx b/src/billing/List/BillingList.tsx index abfbd4c175..4a1baaf328 100644 --- a/src/billing/List/BillingList.tsx +++ b/src/billing/List/BillingList.tsx @@ -20,7 +20,7 @@ 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 { CONSOLIDATED_SPEND_REPORT } from 'src/libs/feature-previews-config'; +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'; @@ -169,7 +169,7 @@ 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; @@ -308,13 +308,13 @@ export const BillingList = (props: BillingListProps) => {

Billing Projects

- {isFeaturePreviewEnabled(CONSOLIDATED_SPEND_REPORT) && ( + {isFeaturePreviewEnabled(SPEND_REPORTING) && (
setHovered(true)} - // onMouseLeave={() => setHovered(false)} + style={{ ...listItemStyle(type === 'consolidatedSpendReport', spendReportHovered) }} + onMouseEnter={() => setSpendReportHovered(true)} + onMouseLeave={() => setSpendReportHovered(false)} > { href={`${Nav.getLink('billing')}?${qs.stringify({ type: 'consolidatedSpendReport', })}`} - // onClick={ - // () => void Metrics().captureEvent(Events.billingProjectOpenFromList, extractBillingDetails(props.project)) - // // (isActive = !isActive) - // } + onClick={() => void Metrics().captureEvent(Events.billingViewConsolidatedSpendReport)} aria-current={type === 'consolidatedSpendReport' ? 'location' : false} > Consolidated Spend Report @@ -336,26 +333,6 @@ export const BillingList = (props: BillingListProps) => {
- - // - // Consolidated Spend Report - // )}
diff --git a/src/libs/feature-previews-config.ts b/src/libs/feature-previews-config.ts index fd4401f661..5c770bb86a 100644 --- a/src/libs/feature-previews-config.ts +++ b/src/libs/feature-previews-config.ts @@ -9,7 +9,6 @@ export const SPEND_REPORTING = 'spendReporting'; export const AUTO_GENERATE_DATA_TABLES = 'autoGenerateDataTables'; export const PREVIEW_COST_CAPPING = 'previewCostCapping'; export const IGV_ENHANCEMENTS = 'igvEnhancements'; -export const CONSOLIDATED_SPEND_REPORT = 'consolidatedSpendReport'; export const GCP_BATCH = 'gcpBatch'; // If the groups option is defined for a FeaturePreview, it must contain at least one group. @@ -125,7 +124,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', }, { @@ -161,16 +160,6 @@ const featurePreviewsConfig: readonly FeaturePreview[] = [ )}`, lastUpdated: '12/12/2024', }, - { - id: CONSOLIDATED_SPEND_REPORT, - title: 'Show Consolidated Spend Report for all workspaces owned by user', - description: - 'Enabling this feature will allow user to generate a spend report across all billing projects in which they own workspaces', - feedbackUrl: `mailto:dsp-core-services@broadinstitute.org?subject=${encodeURIComponent( - 'Feedback on Consolidated Spend Report' - )}`, - lastUpdated: '12/20/2024', - }, { id: GCP_BATCH, title: 'Run workflows on GCP Batch', From 7d43f6c00807df0b0708e469c42553b1dd17c987 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 10 Jan 2025 11:55:28 -0500 Subject: [PATCH 16/17] fix header display --- src/billing/ConsolidatedSpendReport.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index 69cbd93d00..a482be39ed 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -33,22 +33,11 @@ interface ConsolidatedSpendWorkspaceCardHeadersProps { onSort: (sort: { field: string; direction: 'asc' | 'desc' }) => void; } -// Since the variable names don't match display names for these two columns, we need to map them -const columnTitleToVariableNameMap = { - billingProject: 'namespace', - workspaceName: 'name', -}; - const ConsolidatedSpendWorkspaceCardHeaders: React.FC = memoWithName( 'ConsolidatedSpendWorkspaceCardHeaders', (props: ConsolidatedSpendWorkspaceCardHeadersProps) => { const { sort, onSort } = props; - const handleSort = (name: string) => { - const variableName = columnTitleToVariableNameMap[name]; - onSort({ field: variableName, direction: sort.direction === 'asc' ? 'desc' : 'asc' }); - }; - return (
-
- handleSort('billingProject')} name='billingProject' /> +
+
-
- handleSort('workspaceName')} name='workspaceName' /> +
+
From d5ab72116d4c8f4df6fbe43c4a23e349535c13e5 Mon Sep 17 00:00:00 2001 From: Bria Morgan Date: Fri, 10 Jan 2025 15:29:14 -0500 Subject: [PATCH 17/17] add show all workspaces button --- src/billing/ConsolidatedSpendReport.tsx | 105 ++++++++++++------------ src/libs/state.ts | 13 ++- 2 files changed, 61 insertions(+), 57 deletions(-) diff --git a/src/billing/ConsolidatedSpendReport.tsx b/src/billing/ConsolidatedSpendReport.tsx index a482be39ed..23da3d195f 100644 --- a/src/billing/ConsolidatedSpendReport.tsx +++ b/src/billing/ConsolidatedSpendReport.tsx @@ -7,7 +7,7 @@ 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 { fixedSpinnerOverlay } from 'src/components/common'; +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'; @@ -181,10 +181,6 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re }); const [allWorkspaces, setAllWorkspaces] = useState(props.workspaces); - // TODO - how to know how many workspaces there are in total in order to paginate without querying BQ? - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [pageNumber, setPageNumber] = useState(1); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [itemsPerPage, setItemsPerPage] = useState(250); const signal = useCancellation(); @@ -218,7 +214,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re 'workspace.name', 'workspace.namespace', ], - itemsPerPage // TODO what to do here + itemsPerPage ); return fetchedWorkspaces; }; @@ -227,7 +223,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re setUpdating(true); const spendReportStoreLocal = spendReportStore.get(); - const storedSpendReport = spendReportStoreLocal?.[spendReportLengthInDays]; + const storedSpendReport = spendReportStoreLocal?.[itemsPerPage]?.[spendReportLengthInDays]; if (!storedSpendReport || storedSpendReport.startDate !== startDate || storedSpendReport.endDate !== endDate) { const setDefaultSpendValues = (workspace: GoogleWorkspaceInfo) => ({ ...workspace, @@ -242,7 +238,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re startDate, endDate, pageSize: itemsPerPage, - offset: itemsPerPage * (pageNumber - 1), + offset: 0, }); const spendDataItems = (consolidatedSpendReport.spendDetails as AggregatedWorkspaceSpendData[]).map( @@ -312,7 +308,14 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re if (updatedWorkspaces) { spendReportStore.update((store) => { const newStore: SpendReportStore = { ...store }; - newStore[spendReportLengthInDays] = { spendReport: updatedWorkspaces, startDate, endDate }; + if (!newStore[itemsPerPage]) { + newStore[itemsPerPage] = {}; + } + newStore[itemsPerPage][spendReportLengthInDays] = { + spendReport: updatedWorkspaces, + startDate, + endDate, + }; return newStore; }); setOwnedWorkspaces(updatedWorkspaces); @@ -322,7 +325,7 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re }; initialize(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signal, spendReportLengthInDays, allWorkspaces]); + }, [signal, spendReportLengthInDays, allWorkspaces, itemsPerPage]); return ( <> @@ -364,57 +367,51 @@ export const ConsolidatedSpendReport = (props: ConsolidatedSpendReportProps): Re style={{ gridRowStart: 1, gridColumnStart: 2, margin: '1.35rem' }} onChange={setSearchValue} /> + {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)} -
+
+ +
+ {_.flow( + _.orderBy( + [(workspace) => parseCurrencyIfNeeded(workspaceSort.field, _.get(workspaceSort.field, workspace))], + [workspaceSort.direction] + ), + _.map((workspace: GoogleWorkspaceInfo) => { + return ( + + ); + }) + )(filteredOwnedWorkspaces)}
- {/*
- { - // @ts-expect-error - { - setPageNumber(1); - setItemsPerPage(v); - }} - /> - } -
*/} - +
)} {updating && fixedSpinnerOverlay}
diff --git a/src/libs/state.ts b/src/libs/state.ts index 4e892dcb87..39b678f8d0 100644 --- a/src/libs/state.ts +++ b/src/libs/state.ts @@ -393,9 +393,16 @@ export const workflowsAppStore = atom({ }); export type SpendReportStore = { - 7?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; - 30?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; - 90?: { spendReport: GoogleWorkspaceInfo[] | undefined; startDate: string; endDate: string }; + 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);