Merge pull request #3410 from backstage/add-top-panel-breakdown

Add product breakdown view to Cost Insights overview panel.
This commit is contained in:
brendasukh
2020-11-30 16:11:21 -05:00
committed by GitHub
18 changed files with 614 additions and 180 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Add breakdown view to the Cost Overview panel
+12 -48
View File
@@ -20,9 +20,7 @@ import { CostInsightsApi, ProductInsightsOptions } from '../src/api';
import {
Alert,
Cost,
DateAggregation,
DEFAULT_DATE_FORMAT,
Duration,
Entity,
Group,
MetricData,
@@ -34,52 +32,12 @@ import {
ProjectGrowthAlert,
UnlabeledDataflowAlert,
} from '../src/utils/alerts';
import { inclusiveStartDateOf } from '../src/utils/duration';
import { trendlineOf, changeOf } from './utils/mockData';
type IntervalFields = {
duration: Duration;
endDate: string;
};
function parseIntervals(intervals: string): IntervalFields {
const match = intervals.match(
/\/(?<duration>P\d+[DM])\/(?<date>\d{4}-\d{2}-\d{2})/,
);
if (Object.keys(match?.groups || {}).length !== 2) {
throw new Error(`Invalid intervals: ${intervals}`);
}
const { duration, date } = match!.groups!;
return {
duration: duration as Duration,
endDate: date,
};
}
function aggregationFor(
intervals: string,
baseline: number,
): DateAggregation[] {
const { duration, endDate } = parseIntervals(intervals);
const days = dayjs(endDate).diff(
inclusiveStartDateOf(duration, endDate),
'day',
);
return [...Array(days).keys()].reduce(
(values: DateAggregation[], i: number): DateAggregation[] => {
const last = values.length ? values[values.length - 1].amount : baseline;
values.push({
date: dayjs(inclusiveStartDateOf(duration, endDate))
.add(i, 'day')
.format(DEFAULT_DATE_FORMAT),
amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)),
});
return values;
},
[],
);
}
import {
trendlineOf,
changeOf,
getGroupedProducts,
aggregationFor,
} from './utils/mockData';
export class ExampleCostInsightsClient implements CostInsightsApi {
private request(_: any, res: any): Promise<any> {
@@ -140,6 +98,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
aggregation: aggregation,
change: changeOf(aggregation),
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),
},
);
@@ -155,6 +116,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
aggregation: aggregation,
change: changeOf(aggregation),
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),
},
);
@@ -21,11 +21,12 @@ import { currencyFormatter } from '../../utils/formatters';
import { CostInsightsTheme } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
type BarChartLegendOptions = {
export type BarChartLegendOptions = {
previousName: string;
previousFill: string;
currentName: string;
currentFill: string;
hideMarker?: boolean;
};
export type BarChartLegendProps = {
@@ -56,12 +57,18 @@ export const BarChartLegend = ({
return (
<Box className={classes.legend} display="flex" flexDirection="row">
<Box marginRight={2}>
<LegendItem title={data.previousName} markerColor={data.previousFill}>
<LegendItem
title={data.previousName}
markerColor={options.hideMarker ? undefined : data.previousFill}
>
{currencyFormatter.format(costStart)}
</LegendItem>
</Box>
<Box marginRight={2}>
<LegendItem title={data.currentName} markerColor={data.currentFill}>
<LegendItem
title={data.currentName}
markerColor={options.hideMarker ? undefined : data.currentFill}
>
{currencyFormatter.format(costEnd)}
</LegendItem>
</Box>
@@ -17,7 +17,10 @@
export { BarChart } from './BarChart';
export type { BarChartProps } from './BarChart';
export { BarChartLegend } from './BarChartLegend';
export type { BarChartLegendProps } from './BarChartLegend';
export type {
BarChartLegendProps,
BarChartLegendOptions,
} from './BarChartLegend';
export { BarChartTooltip } from './BarChartTooltip';
export type { BarChartTooltipProps } from './BarChartTooltip';
export { BarChartTooltipItem } from './BarChartTooltipItem';
@@ -0,0 +1,210 @@
/*
* Copyright 2020 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 dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { useTheme, Box } from '@material-ui/core';
import {
AreaChart,
ContentRenderer,
TooltipProps,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
Area,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
import { Cost, DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types';
import {
BarChartTooltip as Tooltip,
BarChartTooltipItem as TooltipItem,
BarChartLegend,
} from '../BarChart';
import {
overviewGraphTickFormatter,
formatGraphValue,
isInvalid,
} from '../../utils/graphs';
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { useFilters, useLastCompleteBillingDate } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { getPreviousPeriodTotalCost } from '../../utils/change';
import { formatPeriod } from '../../utils/formatters';
import { aggregationSum } from '../../utils/sum';
import { BarChartLegendOptions } from '../BarChart/BarChartLegend';
dayjs.extend(utc);
export type CostOverviewByProductChartProps = {
costsByProduct: Cost[];
};
const LOW_COST_THRESHOLD = 0.1;
export const CostOverviewByProductChart = ({
costsByProduct,
}: CostOverviewByProductChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useStyles(theme);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
if (!costsByProduct) {
return null;
}
const flattenedAggregation = costsByProduct
.map(cost => cost.aggregation)
.flat();
const totalCost = aggregationSum(flattenedAggregation);
const previousPeriodTotal = getPreviousPeriodTotalCost(
flattenedAggregation,
duration,
lastCompleteBillingDate,
);
const currentPeriodTotal = totalCost - previousPeriodTotal;
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 there we have >= 5 products.
const isOtherProduct =
costsByProduct.length >= 5 &&
productTotal < totalCost * LOW_COST_THRESHOLD;
const productName = isOtherProduct ? 'Other' : product.id;
const updatedProdByDate = { ...prodByDate };
product.aggregation.forEach(curAggregation => {
const productCostsForDate = updatedProdByDate[curAggregation.date] || {};
updatedProdByDate[curAggregation.date] = {
...productCostsForDate,
[productName]:
(productCostsForDate[productName] || 0) + curAggregation.amount,
};
});
return updatedProdByDate;
}, {} as Record<string, Record<string, number>>);
const chartData: Record<string, number>[] = Object.keys(productsByDate).map(
date => {
return {
...productsByDate[date],
date: Date.parse(date),
};
},
);
const renderAreas = () => {
const productGroupNames = new Set(
Object.values(productsByDate)
.map(d => Object.keys(d))
.flat(),
);
const sortedProducts = costsByProduct
// Check that product is a separate group and hasn't been added to 'Other'
.filter(
product => product.id !== 'Other' && productGroupNames.has(product.id),
)
.sort(
(a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation),
)
.map(product => product.id);
// Keep 'Other' category at the bottom of the stack
return ['Other', ...sortedProducts].map((product, i) => (
<Area
dataKey={product}
stackId="1"
stroke={theme.palette.dataViz[sortedProducts.length - i]}
fill={theme.palette.dataViz[sortedProducts.length - i]}
/>
));
};
const tooltipRenderer: ContentRenderer<TooltipProps> = ({
label,
payload = [],
}) => {
if (isInvalid({ label, payload })) return null;
const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT);
const items = payload.map(p => ({
label: p.dataKey as string,
value: formatGraphValue(p.value as number),
fill: p.fill!,
}));
return (
<Tooltip title={title}>
{items.reverse().map((item, index) => (
<TooltipItem key={`${item.label}-${index}`} item={item} />
))}
</Tooltip>
);
};
const options: Partial<BarChartLegendOptions> = {
previousName: formatPeriod(duration, lastCompleteBillingDate, false),
currentName: formatPeriod(duration, lastCompleteBillingDate, true),
hideMarker: true,
};
return (
<Box display="flex" flexDirection="column">
<Box display="flex" flexDirection="row">
<BarChartLegend
costStart={previousPeriodTotal}
costEnd={currentPeriodTotal}
options={options}
/>
</Box>
<ResponsiveContainer
width={styles.container.width}
height={styles.container.height}
>
<AreaChart
data={chartData}
margin={{
top: 16,
right: 30,
bottom: 40,
}}
>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
/>
{renderAreas()}
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
</AreaChart>
</ResponsiveContainer>
</Box>
);
};
@@ -14,26 +14,27 @@
* limitations under the License.
*/
import React from 'react';
import { Box, Card, CardContent, Divider, useTheme } from '@material-ui/core';
import { CostGrowth } from '../CostGrowth';
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
Divider,
useTheme,
Tab,
Tabs,
} from '@material-ui/core';
import { CostOverviewChart } from './CostOverviewChart';
import { CostOverviewByProductChart } from './CostOverviewByProductChart';
import { CostOverviewHeader } from './CostOverviewHeader';
import { LegendItem } from '../LegendItem';
import { MetricSelect } from '../MetricSelect';
import { PeriodSelect } from '../PeriodSelect';
import {
useScroll,
useFilters,
useConfig,
useLastCompleteBillingDate,
} from '../../hooks';
import { useScroll, useFilters, useConfig } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { DefaultNavigation } from '../../utils/navigation';
import { formatPercent } from '../../utils/formatters';
import { findAlways } from '../../utils/assert';
import { getComparedChange } from '../../utils/change';
import { Cost, CostInsightsTheme, MetricData } from '../../types';
import { useOverviewTabsStyles } from '../../utils/styles';
export type CostOverviewCardProps = {
dailyCostData: Cost;
@@ -46,7 +47,8 @@ export const CostOverviewCard = ({
}: CostOverviewCardProps) => {
const theme = useTheme<CostInsightsTheme>();
const config = useConfig();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [tabIndex, setTabIndex] = useState(0);
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
const { setDuration, setProject, setMetric, ...filters } = useFilters(
mapFiltersToProps,
@@ -55,61 +57,66 @@ export const CostOverviewCard = ({
const metric = filters.metric
? findAlways(config.metrics, m => m.kind === filters.metric)
: null;
const comparedChange = metricData
? getComparedChange(
dailyCostData,
metricData,
filters.duration,
lastCompleteBillingDate,
)
: null;
const styles = useOverviewTabsStyles(theme);
const tabs = [
{ id: 'overview', label: 'Total cost', title: 'Cloud Cost' },
{
id: 'breakdown',
label: 'Breakdown by product',
title: 'Cloud Cost By Product',
},
];
const OverviewTabs = () => {
return (
<>
<Tabs
indicatorColor="primary"
onChange={(_, index) => setTabIndex(index)}
value={tabIndex}
>
{tabs.map((tab, index) => (
<Tab
className={styles.default}
label={tab.label}
key={tab.id}
value={index}
classes={{ selected: styles.selected }}
/>
))}
</Tabs>
</>
);
};
// Metrics can only be selected on the total cost graph
const showMetricSelect = config.metrics.length && tabIndex === 0;
return (
<Card style={{ position: 'relative' }}>
<ScrollAnchor behavior="smooth" top={-20} />
<CardContent>
<CostOverviewHeader title="Cloud Cost">
{dailyCostData.groupedCosts && <OverviewTabs />}
<CostOverviewHeader title={tabs[tabIndex].title}>
<PeriodSelect duration={filters.duration} onSelect={setDuration} />
</CostOverviewHeader>
<Divider />
<Box my={1} display="flex" flexDirection="column">
<Box display="flex" flexDirection="row">
<Box mr={2}>
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
{formatPercent(dailyCostData.change.ratio)}
</LegendItem>
</Box>
{metric && metricData && comparedChange && (
<>
<Box mr={2}>
<LegendItem
title={`${metric.name} Trend`}
markerColor={theme.palette.magenta}
>
{formatPercent(metricData.change.ratio)}
</LegendItem>
</Box>
<LegendItem
title={
comparedChange.ratio <= 0 ? 'Your Savings' : 'Your Excess'
}
>
<CostGrowth
change={comparedChange}
duration={filters.duration}
/>
</LegendItem>
</>
)}
</Box>
<CostOverviewChart
dailyCostData={dailyCostData}
metric={metric}
metricData={metricData}
/>
<Box ml={2} my={1} display="flex" flexDirection="column">
{tabIndex === 0 ? (
<CostOverviewChart
dailyCostData={dailyCostData}
metric={metric}
metricData={metricData}
/>
) : (
<CostOverviewByProductChart
costsByProduct={dailyCostData.groupedCosts!}
/>
)}
</Box>
<Box display="flex" justifyContent="flex-end" alignItems="center">
{config.metrics.length && (
{showMetricSelect && (
<MetricSelect
metric={filters.metric}
metrics={config.metrics}
@@ -16,7 +16,7 @@
import React from 'react';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { useTheme } from '@material-ui/core';
import { useTheme, Box } from '@material-ui/core';
import {
ComposedChart,
ContentRenderer,
@@ -50,6 +50,7 @@ import {
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
import { aggregationSort } from '../../utils/sort';
import { CostOverviewLegend } from './CostOverviewLegend';
dayjs.extend(utc);
@@ -93,7 +94,7 @@ export const CostOverviewChart = ({
.sort(aggregationSort)
.map(entry => ({
date: Date.parse(entry.date),
trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)),
trend: trendFrom(data.dailyCost.data.trendline!, Date.parse(entry.date)),
dailyCost: entry.amount,
...(metric && data.metric.data
? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] }
@@ -135,67 +136,74 @@ export const CostOverviewChart = ({
};
return (
<ResponsiveContainer
width={responsive ? '100%' : styles.container.width}
height={styles.container.height}
className="cost-overview-chart"
>
<ComposedChart margin={styles.chart.margin} data={chartData}>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
yAxisId={data.dailyCost.dataKey}
/>
{metric && (
<YAxis
hide
domain={[() => 0, toDataMax(data.metric.dataKey, chartData)]}
width={styles.yAxis.width}
yAxisId={data.metric.dataKey}
<Box display="flex" flexDirection="column">
<CostOverviewLegend
dailyCostData={dailyCostData}
metric={metric}
metricData={metricData}
/>
<ResponsiveContainer
width={responsive ? '100%' : styles.container.width}
height={styles.container.height}
className="cost-overview-chart"
>
<ComposedChart margin={styles.chart.margin} data={chartData}>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
yAxisId={data.dailyCost.dataKey}
/>
{metric && (
<YAxis
hide
domain={[() => 0, toDataMax(data.metric.dataKey, chartData)]}
width={styles.yAxis.width}
yAxisId={data.metric.dataKey}
/>
)}
<Area
dataKey={data.dailyCost.dataKey}
isAnimationActive={false}
fill={theme.palette.blue}
fillOpacity={0.4}
stroke="none"
yAxisId={data.dailyCost.dataKey}
/>
)}
<Area
dataKey={data.dailyCost.dataKey}
isAnimationActive={false}
fill={theme.palette.blue}
fillOpacity={0.4}
stroke="none"
yAxisId={data.dailyCost.dataKey}
/>
<Line
activeDot={false}
dataKey="trend"
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.blue}
yAxisId={data.dailyCost.dataKey}
/>
{metric && (
<Line
dataKey={data.metric.dataKey}
activeDot={false}
dataKey="trend"
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.magenta}
yAxisId={data.metric.dataKey}
stroke={theme.palette.blue}
yAxisId={data.dailyCost.dataKey}
/>
)}
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
</ComposedChart>
</ResponsiveContainer>
{metric && (
<Line
dataKey={data.metric.dataKey}
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.magenta}
yAxisId={data.metric.dataKey}
/>
)}
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
</ComposedChart>
</ResponsiveContainer>
</Box>
);
};
@@ -27,7 +27,9 @@ export const CostOverviewHeader = ({
children,
}: PropsWithChildren<CostOverviewHeaderProps>) => (
<Box
marginY={1}
mt={2}
ml={1}
mb={1}
display="flex"
flexDirection="row"
justifyContent="space-between"
@@ -0,0 +1,84 @@
/*
* Copyright 2020 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, { PropsWithChildren } from 'react';
import { Box, useTheme } from '@material-ui/core';
import { LegendItem } from '../LegendItem';
import {
CostInsightsTheme,
MetricData,
Maybe,
Cost,
Metric,
} from '../../types';
import { useLastCompleteBillingDate, useFilters } from '../../hooks';
import { getComparedChange } from '../../utils/change';
import { mapFiltersToProps } from './selector';
import { formatPercent } from '../../utils/formatters';
import { CostGrowth } from '../CostGrowth';
type CostOverviewLegendProps = {
metric: Maybe<Metric>;
metricData: Maybe<MetricData>;
dailyCostData: Cost;
};
export const CostOverviewLegend = ({
dailyCostData,
metric,
metricData,
}: PropsWithChildren<CostOverviewLegendProps>) => {
const theme = useTheme<CostInsightsTheme>();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
const comparedChange = metricData
? getComparedChange(
dailyCostData,
metricData,
duration,
lastCompleteBillingDate,
)
: null;
return (
<Box display="flex" flexDirection="row">
<Box mr={2}>
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
{formatPercent(dailyCostData.change!.ratio)}
</LegendItem>
</Box>
{metric && metricData && comparedChange && (
<>
<Box mr={2}>
<LegendItem
title={`${metric.name} Trend`}
markerColor={theme.palette.magenta}
>
{formatPercent(metricData.change.ratio)}
</LegendItem>
</Box>
<LegendItem
title={comparedChange.ratio <= 0 ? 'Your Savings' : 'Your Excess'}
>
<CostGrowth change={comparedChange} duration={duration} />
</LegendItem>
</>
)}
</Box>
);
};
@@ -30,6 +30,7 @@ import {
BarChartLegend,
BarChartTooltip,
BarChartTooltipItem,
BarChartLegendOptions,
} from '../BarChart';
import { pluralOf } from '../../utils/grammar';
import { findAlways, notEmpty } from '../../utils/assert';
@@ -45,7 +46,7 @@ import {
useProductInsightsChartStyles as useStyles,
useBarChartLayoutStyles as useLayoutStyles,
} from '../../utils/styles';
import { BarChartOptions, Duration, Entity, Maybe } from '../../types';
import { Duration, Entity, Maybe } from '../../types';
export type ProductInsightsChartProps = {
billingDate: string;
@@ -70,7 +71,7 @@ export const ProductInsightsChart = ({
const costEnd = entity.aggregation[1];
const resources = entity.entities.map(resourceOf);
const options: Partial<BarChartOptions> = {
const options: Partial<BarChartLegendOptions> = {
previousName: formatPeriod(duration, billingDate, false),
currentName: formatPeriod(duration, billingDate, true),
};
@@ -17,10 +17,10 @@
import React from 'react';
import moment from 'moment';
import { Box } from '@material-ui/core';
import { BarChart, BarChartLegend } from '../BarChart';
import { BarChart, BarChartLegend, BarChartLegendOptions } from '../BarChart';
import { LegendItem } from '../LegendItem';
import { CostGrowth } from '../CostGrowth';
import { BarChartOptions, Duration, ProjectGrowthData } from '../../types';
import { Duration, ProjectGrowthData } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
import { resourceOf } from '../../utils/graphs';
@@ -37,7 +37,7 @@ export const ProjectGrowthAlertChart = ({
const costEnd = alert.aggregation[1];
const resourceData = alert.products.map(resourceOf);
const options: Partial<BarChartOptions> = {
const options: Partial<BarChartLegendOptions> = {
previousName: moment(alert.periodStart, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
currentName: moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
};
+3 -2
View File
@@ -21,6 +21,7 @@ import { Trendline } from './Trendline';
export interface Cost {
id: string;
aggregation: DateAggregation[];
change: ChangeStatistic;
trendline: Trendline;
change?: ChangeStatistic;
trendline?: Trendline;
groupedCosts?: Cost[];
}
+1
View File
@@ -30,6 +30,7 @@ type CostInsightsPaletteAdditions = {
tooltip: CostInsightsTooltipOptions;
navigationText: string;
alertBackground: string;
dataViz: string[];
};
export type CostInsightsPalette = BackstagePalette &
@@ -79,7 +79,7 @@ describe('getPreviousPeriodTotalCost', () => {
const exclusiveEndDate = '2020-09-30';
expect(
getPreviousPeriodTotalCost(
mockGroupDailyCost,
mockGroupDailyCost.aggregation,
Duration.P1M,
exclusiveEndDate,
),
+5 -4
View File
@@ -23,6 +23,7 @@ import {
MetricData,
Duration,
DEFAULT_DATE_FORMAT,
DateAggregation,
} from '../types';
import dayjs, { OpUnitType } from 'dayjs';
import duration from 'dayjs/plugin/duration';
@@ -54,9 +55,9 @@ export function getComparedChange(
duration: Duration,
lastCompleteBillingDate: string, // YYYY-MM-DD,
): ChangeStatistic {
const ratio = dailyCost.change.ratio - metricData.change.ratio;
const ratio = dailyCost.change!.ratio - metricData.change.ratio;
const previousPeriodTotal = getPreviousPeriodTotalCost(
dailyCost,
dailyCost.aggregation,
duration,
lastCompleteBillingDate,
);
@@ -67,7 +68,7 @@ export function getComparedChange(
}
export function getPreviousPeriodTotalCost(
dailyCost: Cost,
aggregation: DateAggregation[],
duration: Duration,
inclusiveEndDate: string,
): number {
@@ -83,7 +84,7 @@ export function getPreviousPeriodTotalCost(
const nextPeriodStart = dayjs(startDate).add(amount, type);
// Add up costs that incurred before the start of the next period.
return dailyCost.aggregation.reduce((acc, costByDate) => {
return aggregation.reduce((acc, costByDate) => {
return dayjs(costByDate.date).isBefore(nextPeriodStart)
? acc + costByDate.amount
: acc;
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import dayjs from 'dayjs';
import regression, { DataPoint } from 'regression';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core';
@@ -28,16 +29,23 @@ import {
UnlabeledDataflowAlertProject,
UnlabeledDataflowData,
DateAggregation,
DEFAULT_DATE_FORMAT,
} from '../types';
import {
DefaultLoadingAction,
getDefaultState as getDefaultLoadingState,
} from '../utils/loading';
import { findAlways } from '../utils/assert';
import { inclusiveStartDateOf } from './duration';
type mockAlertRenderer<T> = (alert: T) => T;
type mockEntityRenderer<T> = (entity: T) => T;
type IntervalFields = {
duration: Duration;
endDate: string;
};
export const createMockEntity = (
callback?: mockEntityRenderer<Entity>,
): Entity => {
@@ -216,6 +224,45 @@ export function changeOf(aggregation: DateAggregation[]): ChangeStatistic {
};
}
export function aggregationFor(
intervals: string,
baseline: number,
): DateAggregation[] {
const { duration, endDate } = parseIntervals(intervals);
const days = dayjs(endDate).diff(
inclusiveStartDateOf(duration, endDate),
'day',
);
return [...Array(days).keys()].reduce(
(values: DateAggregation[], i: number): DateAggregation[] => {
const last = values.length ? values[values.length - 1].amount : baseline;
values.push({
date: dayjs(inclusiveStartDateOf(duration, endDate))
.add(i, 'day')
.format(DEFAULT_DATE_FORMAT),
amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)),
});
return values;
},
[],
);
}
function parseIntervals(intervals: string): IntervalFields {
const match = intervals.match(
/\/(?<duration>P\d+[DM])\/(?<date>\d{4}-\d{2}-\d{2})/,
);
if (Object.keys(match?.groups || {}).length !== 2) {
throw new Error(`Invalid intervals: ${intervals}`);
}
const { duration, date } = match!.groups!;
return {
duration: duration as Duration,
endDate: date,
};
}
export const MockAggregatedDailyCosts: DateAggregation[] = [
{
date: '2020-08-07',
@@ -462,3 +509,38 @@ export const MockAggregatedDailyCosts: DateAggregation[] = [
amount: 5500,
},
];
export const getGroupedProducts = (intervals: string) => [
{
id: 'Cloud Dataflow',
aggregation: aggregationFor(intervals, 1_700),
},
{
id: 'Compute Engine',
aggregation: aggregationFor(intervals, 350),
},
{
id: 'Cloud Storage',
aggregation: aggregationFor(intervals, 1_300),
},
{
id: 'BigQuery',
aggregation: aggregationFor(intervals, 2_000),
},
{
id: 'Cloud SQL',
aggregation: aggregationFor(intervals, 750),
},
{
id: 'Cloud Spanner',
aggregation: aggregationFor(intervals, 50),
},
{
id: 'Cloud Pub/Sub',
aggregation: aggregationFor(intervals, 1_000),
},
{
id: 'Cloud Bigtable',
aggregation: aggregationFor(intervals, 250),
},
];
+38
View File
@@ -37,6 +37,18 @@ export const costInsightsLightTheme = {
},
navigationText: '#b5b5b5',
alertBackground: 'rgba(219, 219, 219, 0.13)',
dataViz: [
'#509BF5',
'#4B917D',
'#FF6437',
'#F573A0',
'#F59B23',
'#B49BC8',
'#C39687',
'#A0C3D2',
'#FFC864',
'#BABABA',
],
},
} as CostInsightsThemeOptions;
@@ -55,6 +67,18 @@ export const costInsightsDarkTheme = {
},
navigationText: '#b5b5b5',
alertBackground: 'rgba(32, 32, 32, 0.13)',
dataViz: [
'#8accff',
'#7bc2ac',
'#ff9664',
'#ffa5d1',
'#ffcc57',
'#e6ccfb',
'#f7c7b7',
'#d2f6ff',
'#fffb94',
'#ececec',
],
},
} as CostInsightsThemeOptions;
@@ -87,6 +111,20 @@ export const useCostOverviewStyles = (theme: CostInsightsTheme) => ({
},
});
export const useOverviewTabsStyles = makeStyles<CostInsightsTheme>(
(theme: CostInsightsTheme) => ({
default: {
padding: theme.spacing(2),
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.secondary,
textTransform: 'uppercase',
},
selected: {
color: theme.palette.text.primary,
},
}),
);
export const useBarChartStyles = (theme: CostInsightsTheme) => ({
axis: {
fill: theme.palette.text.primary,
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 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 { DateAggregation } from '../types';
export const aggregationSum = (aggregation: DateAggregation[]) =>
aggregation.reduce((total, curAgg) => total + curAgg.amount, 0);