Add support for additional breakdowns of daily cost data

Hide expansion option if not enough breakdowns
This commit is contained in:
Tim Hansen
2021-01-22 13:05:58 -07:00
parent d7b44f93cf
commit fac91bcc5f
7 changed files with 209 additions and 77 deletions
@@ -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.
+10 -4
View File
@@ -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),
},
},
);
@@ -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<CostInsightsTheme>();
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<string, Record<string, number>>);
return updatedBreakdownByDate;
},
{} as Record<string, Record<string, number>>,
);
const chartData: Record<string, number>[] = Object.keys(productsByDate).map(
const chartData: Record<string, number>[] = 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<string, number>,
);
@@ -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 (
<Area
dataKey={product}
key={breakdown}
dataKey={breakdown}
isAnimationActive={false}
stackId="1"
stroke={productColor}
fill={productColor}
stroke={color}
fill={color}
onClick={() => 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) => (
<TooltipItem key={`${item.label}-${index}`} item={item} />
))}
{!isExpanded ? expandText : null}
{canExpand && !isExpanded ? expandText : null}
</Tooltip>
);
};
@@ -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(
<CostInsightsThemeProvider>
<MockConfigProvider>
<MockFilterProvider>
<MockBillingDateProvider>
<MockScrollProvider>{children}</MockScrollProvider>
</MockBillingDateProvider>
</MockFilterProvider>
</MockConfigProvider>
</CostInsightsThemeProvider>,
);
}
describe('<CostOverviewCard/>', () => {
it('Renders without exploding', async () => {
const { getByText } = await renderInContext(
<CostOverviewCard dailyCostData={mockGroupDailyCost} metricData={null} />,
);
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(
<CostOverviewCard
dailyCostData={mockDailyCostWithBreakdowns}
metricData={null}
/>,
);
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();
});
});
@@ -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 = ({
<Tabs
indicatorColor="primary"
onChange={(_, index) => setTabIndex(index)}
value={tabIndex}
value={safeTabIndex}
>
{tabs.map((tab, index) => (
<Tab
@@ -91,27 +104,27 @@ export const CostOverviewCard = ({
};
// Metrics can only be selected on the total cost graph
const showMetricSelect = config.metrics.length && tabIndex === 0;
const showMetricSelect = config.metrics.length && safeTabIndex === 0;
return (
<Card style={{ position: 'relative' }}>
<ScrollAnchor behavior="smooth" top={-20} />
<CardContent>
{dailyCostData.groupedCosts && <OverviewTabs />}
<CostOverviewHeader title={tabs[tabIndex].title}>
<CostOverviewHeader title={tabs[safeTabIndex].title}>
<PeriodSelect duration={filters.duration} onSelect={setDuration} />
</CostOverviewHeader>
<Divider />
<Box ml={2} my={1} display="flex" flexDirection="column">
{tabIndex === 0 ? (
{safeTabIndex === 0 ? (
<CostOverviewChart
dailyCostData={dailyCostData}
metric={metric}
metricData={metricData}
/>
) : (
<CostOverviewByProductChart
costsByProduct={dailyCostData.groupedCosts!}
<CostOverviewBreakdownChart
costBreakdown={dailyCostData.groupedCosts![tabs[safeTabIndex].id]}
/>
)}
</Box>
+1 -1
View File
@@ -23,5 +23,5 @@ export interface Cost {
aggregation: DateAggregation[];
change?: ChangeStatistic;
trendline?: Trendline;
groupedCosts?: Cost[];
groupedCosts?: Record<string, Cost[]>;
}
@@ -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),
},
];