From fac91bcc5fcdc02954722f37f884cb70a75cda03 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 22 Jan 2021 13:05:58 -0700 Subject: [PATCH] Add support for additional breakdowns of daily cost data Hide expansion option if not enough breakdowns --- .changeset/cost-insights-yellow-trees-love.md | 6 + plugins/cost-insights/src/client.ts | 14 ++- ...art.tsx => CostOverviewBreakdownChart.tsx} | 115 +++++++++--------- .../CostOverviewCard.test.tsx | 89 ++++++++++++++ .../CostOverviewCard/CostOverviewCard.tsx | 45 ++++--- plugins/cost-insights/src/types/Cost.ts | 2 +- plugins/cost-insights/src/utils/mockData.ts | 15 +++ 7 files changed, 209 insertions(+), 77 deletions(-) create mode 100644 .changeset/cost-insights-yellow-trees-love.md rename plugins/cost-insights/src/components/CostOverviewCard/{CostOverviewByProductChart.tsx => CostOverviewBreakdownChart.tsx} (67%) create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx diff --git a/.changeset/cost-insights-yellow-trees-love.md b/.changeset/cost-insights-yellow-trees-love.md new file mode 100644 index 0000000000..19eea0dfe2 --- /dev/null +++ b/.changeset/cost-insights-yellow-trees-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Add support for additional breakdowns of daily cost data. +This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 0ba57f76fa..c43deba6ae 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -33,11 +33,12 @@ import { UnlabeledDataflowAlert, } from '../src/utils/alerts'; import { - trendlineOf, + aggregationFor, changeOf, entityOf, getGroupedProducts, - aggregationFor, + getGroupedProjects, + trendlineOf, } from './utils/mockData'; export class ExampleCostInsightsClient implements CostInsightsApi { @@ -101,7 +102,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { trendline: trendlineOf(aggregation), // Optional field on Cost which needs to be supplied in order to see // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + groupedCosts: { + product: getGroupedProducts(intervals), + project: getGroupedProjects(intervals), + }, }, ); @@ -119,7 +123,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi { trendline: trendlineOf(aggregation), // Optional field on Cost which needs to be supplied in order to see // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + groupedCosts: { + product: getGroupedProducts(intervals), + }, }, ); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx similarity index 67% rename from plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx rename to plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx index b562434c5e..feec23933f 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx @@ -56,26 +56,26 @@ import { BarChartLegendOptions } from '../BarChart/BarChartLegend'; dayjs.extend(utc); -export type CostOverviewByProductChartProps = { - costsByProduct: Cost[]; +export type CostOverviewBreakdownChartProps = { + costBreakdown: Cost[]; }; const LOW_COST_THRESHOLD = 0.1; -export const CostOverviewByProductChart = ({ - costsByProduct, -}: CostOverviewByProductChartProps) => { +export const CostOverviewBreakdownChart = ({ + costBreakdown, +}: CostOverviewBreakdownChartProps) => { const theme = useTheme(); const classes = useStyles(theme); const lastCompleteBillingDate = useLastCompleteBillingDate(); const { duration } = useFilters(mapFiltersToProps); const [isExpanded, setExpanded] = useState(false); - if (!costsByProduct) { + if (!costBreakdown) { return null; } - const flattenedAggregation = costsByProduct + const flattenedAggregation = costBreakdown .map(cost => cost.aggregation) .flat(); @@ -87,44 +87,46 @@ export const CostOverviewByProductChart = ({ lastCompleteBillingDate, ); const currentPeriodTotal = totalCost - previousPeriodTotal; - const otherProducts: string[] = []; + const canExpand = costBreakdown.length >= 8; + const otherCategoryIds: string[] = []; - const productsByDate = costsByProduct.reduce((prodByDate, product) => { - const productTotal = aggregationSum(product.aggregation); - // Group products with less than 10% of the total cost into "Other" category - // when we have >= 8 products. - const isOtherProduct = - costsByProduct.length >= 8 && - productTotal < totalCost * LOW_COST_THRESHOLD; + const breakdownsByDate = costBreakdown.reduce( + (breakdownByDate, breakdown) => { + const breakdownTotal = aggregationSum(breakdown.aggregation); + // Group breakdown items with less than 10% of the total cost into "Other" category if needed + const isOtherCategory = + canExpand && breakdownTotal < totalCost * LOW_COST_THRESHOLD; - const updatedProdByDate = { ...prodByDate }; - if (isOtherProduct) { - otherProducts.push(product.id); - } - product.aggregation.forEach(curAggregation => { - const productCostsForDate = updatedProdByDate[curAggregation.date] || {}; + const updatedBreakdownByDate = { ...breakdownByDate }; + if (isOtherCategory) { + otherCategoryIds.push(breakdown.id); + } + breakdown.aggregation.forEach(curAggregation => { + const costsForDate = updatedBreakdownByDate[curAggregation.date] || {}; - updatedProdByDate[curAggregation.date] = { - ...productCostsForDate, - [product.id]: - (productCostsForDate[product.id] || 0) + curAggregation.amount, - }; - }); + updatedBreakdownByDate[curAggregation.date] = { + ...costsForDate, + [breakdown.id]: + (costsForDate[breakdown.id] || 0) + curAggregation.amount, + }; + }); - return updatedProdByDate; - }, {} as Record>); + return updatedBreakdownByDate; + }, + {} as Record>, + ); - const chartData: Record[] = Object.keys(productsByDate).map( + const chartData: Record[] = Object.keys(breakdownsByDate).map( date => { - const costsForDate = Object.keys(productsByDate[date]).reduce( - (dateCosts, product) => { - // Group costs for products that belong to 'Other' in the chart. - const cost = productsByDate[date][product]; - const productCost = - !isExpanded && otherProducts.includes(product) + const costsForDate = Object.keys(breakdownsByDate[date]).reduce( + (dateCosts, breakdown) => { + // Group costs for items that belong to 'Other' in the chart. + const cost = breakdownsByDate[date][breakdown]; + const breakdownCost = + !isExpanded && otherCategoryIds.includes(breakdown) ? { Other: (dateCosts.Other || 0) + cost } - : { [product]: cost }; - return { ...dateCosts, ...productCost }; + : { [breakdown]: cost }; + return { ...dateCosts, ...breakdownCost }; }, {} as Record, ); @@ -135,40 +137,41 @@ export const CostOverviewByProductChart = ({ }, ); - const sortedProducts = costsByProduct.sort( + const sortedBreakdowns = costBreakdown.sort( (a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation), ); const renderAreas = () => { - const separatedProducts = sortedProducts - // Check that product is a separate group and hasn't been added to 'Other' + const separatedBreakdowns = sortedBreakdowns + // Check that the breakdown is a separate group and hasn't been added to 'Other' .filter( - product => - product.id !== 'Other' && !otherProducts.includes(product.id), + breakdown => + breakdown.id !== 'Other' && !otherCategoryIds.includes(breakdown.id), ) - .map(product => product.id); + .map(breakdown => breakdown.id); // Keep 'Other' category at the bottom of the stack - const productsToDisplay = isExpanded - ? sortedProducts.map(product => product.id) - : ['Other', ...separatedProducts]; + const breakdownsToDisplay = isExpanded + ? sortedBreakdowns.map(breakdown => breakdown.id) + : ['Other', ...separatedBreakdowns]; - return productsToDisplay.map((product, i) => { - // Logic to handle case where there are more products than data viz colors. - const productColor = + return breakdownsToDisplay.map((breakdown, i) => { + // Logic to handle case where there are more items than data viz colors. + const color = theme.palette.dataViz[ - (productsToDisplay.length - 1 - i) % + (breakdownsToDisplay.length - 1 - i) % (theme.palette.dataViz.length - 1) ]; return ( setExpanded(true)} style={{ - cursor: product === 'Other' && !isExpanded ? 'pointer' : null, + cursor: breakdown === 'Other' && !isExpanded ? 'pointer' : null, }} /> ); @@ -206,7 +209,7 @@ export const CostOverviewByProductChart = ({ {items.reverse().map((item, index) => ( ))} - {!isExpanded ? expandText : null} + {canExpand && !isExpanded ? expandText : null} ); }; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx new file mode 100644 index 0000000000..6fca8f67c9 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CostOverviewCard } from './CostOverviewCard'; +import { Cost } from '../../types'; +import { + changeOf, + getGroupedProducts, + getGroupedProjects, + MockAggregatedDailyCosts, + trendlineOf, +} from '../../utils/mockData'; +import { + MockBillingDateProvider, + MockConfigProvider, + MockFilterProvider, + MockScrollProvider, +} from '../../utils/tests'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockGroupDailyCost: Cost = { + id: 'test-group', + aggregation: MockAggregatedDailyCosts, + change: changeOf(MockAggregatedDailyCosts), + trendline: trendlineOf(MockAggregatedDailyCosts), +}; + +function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + + + {children} + + + + , + ); +} + +describe('', () => { + it('Renders without exploding', async () => { + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + }); + + it('Shows breakdown tabs if provided', async () => { + const mockDailyCostWithBreakdowns = { + ...mockGroupDailyCost, + groupedCosts: { + product: getGroupedProducts('R2/P90D/2021-01-01'), + project: getGroupedProjects('R2/P90D/2021-01-01'), + }, + }; + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + expect(getByText('Breakdown by product')).toBeInTheDocument(); + expect(getByText('Breakdown by project')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by product')); + expect(getByText('Cloud Cost By Product')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by project')); + expect(getByText('Cloud Cost By Project')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 39adc4bbf1..614e045b20 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -14,22 +14,22 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Card, CardContent, Divider, - useTheme, Tab, Tabs, + useTheme, } from '@material-ui/core'; import { CostOverviewChart } from './CostOverviewChart'; -import { CostOverviewByProductChart } from './CostOverviewByProductChart'; +import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart'; import { CostOverviewHeader } from './CostOverviewHeader'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; -import { useScroll, useFilters, useConfig } from '../../hooks'; +import { useConfig, useFilters, useScroll } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; import { findAlways } from '../../utils/assert'; @@ -49,6 +49,15 @@ export const CostOverviewCard = ({ const config = useConfig(); const [tabIndex, setTabIndex] = useState(0); + // Reset tabIndex if breakdowns available change + useEffect(() => { + // Intentionally off-by-one to account for the overview tab + const lastIndex = Object.keys(dailyCostData.groupedCosts ?? {}).length; + if (tabIndex > lastIndex) { + setTabIndex(0); + } + }, [dailyCostData, tabIndex, setTabIndex]); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, @@ -59,14 +68,18 @@ export const CostOverviewCard = ({ : null; const styles = useOverviewTabsStyles(theme); + const breakdownTabs = Object.keys(dailyCostData.groupedCosts ?? {}).map( + key => ({ + id: key, + label: `Breakdown by ${key}`, + title: `Cloud Cost By ${key.charAt(0).toUpperCase() + key.slice(1)}`, + }), + ); const tabs = [ { id: 'overview', label: 'Total cost', title: 'Cloud Cost' }, - { - id: 'breakdown', - label: 'Breakdown by product', - title: 'Cloud Cost By Product', - }, - ]; + ].concat(breakdownTabs); + // tabIndex can temporarily be invalid while the useEffect above processes + const safeTabIndex = tabIndex > tabs.length - 1 ? 0 : tabIndex; const OverviewTabs = () => { return ( @@ -74,7 +87,7 @@ export const CostOverviewCard = ({ setTabIndex(index)} - value={tabIndex} + value={safeTabIndex} > {tabs.map((tab, index) => ( {dailyCostData.groupedCosts && } - + - {tabIndex === 0 ? ( + {safeTabIndex === 0 ? ( ) : ( - )} diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index dd28bba6b0..c3b63946af 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -23,5 +23,5 @@ export interface Cost { aggregation: DateAggregation[]; change?: ChangeStatistic; trendline?: Trendline; - groupedCosts?: Cost[]; + groupedCosts?: Record; } diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index f84473b84f..14ff9aecd1 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -1064,3 +1064,18 @@ export const getGroupedProducts = (intervals: string) => [ aggregation: aggregationFor(intervals, 250), }, ]; + +export const getGroupedProjects = (intervals: string) => [ + { + id: 'project-a', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'project-b', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'project-c', + aggregation: aggregationFor(intervals, 1_300), + }, +];