Add support for additional breakdowns of daily cost data
Hide expansion option if not enough breakdowns
This commit is contained in:
@@ -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.
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+59
-56
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user