Merge pull request #3410 from backstage/add-top-panel-breakdown
Add product breakdown view to Cost Insights overview panel.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
Add breakdown view to the Cost Overview panel
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
+3
-3
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user