Merge pull request #3268 from backstage/ryanv-cost-insights-custom-toolips

Ryanv cost insights custom toolips
This commit is contained in:
Ryan Vazquez
2020-11-11 16:41:47 -05:00
committed by GitHub
50 changed files with 1434 additions and 871 deletions
@@ -19,9 +19,9 @@ import {
Alert,
Cost,
Duration,
Entity,
Group,
Project,
ProductCost,
Maybe,
MetricData,
} from '../types';
@@ -138,7 +138,7 @@ export type CostInsightsApi = {
*
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
*/
getProductInsights(options: ProductInsightsOptions): Promise<ProductCost>;
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
/**
* Get current cost alerts for a given group. These show up as Action Items for the group on the
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
+134 -50
View File
@@ -25,9 +25,9 @@ import {
DateAggregation,
DEFAULT_DATE_FORMAT,
Duration,
Entity,
Group,
MetricData,
ProductCost,
Project,
ProjectGrowthData,
Trendline,
@@ -194,8 +194,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
async getProductInsights(
productInsightsOptions: ProductInsightsOptions,
): Promise<ProductCost> {
): Promise<Entity> {
const projectProductInsights = await this.request(productInsightsOptions, {
id: productInsightsOptions.product,
aggregation: [80_000, 110_000],
change: {
ratio: 0.375,
@@ -205,69 +206,152 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [45_000, 50_000],
change: {
ratio: 0.111,
amount: 5_000,
},
entities: [],
},
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
},
entities: [],
},
{
id: 'entity-b',
aggregation: [20_000, 30_000],
change: {
ratio: 0.5,
amount: 10_000,
},
entities: [],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
entities: [],
},
],
});
const productInsights: Entity = await this.request(productInsightsOptions, {
id: productInsightsOptions.product,
aggregation: [200_000, 250_000],
change: {
ratio: 0.2,
amount: 50_000,
},
entities: [
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [36_000, 42_000],
change: {
ratio: 0.1666,
amount: 6_000,
},
entities: [],
},
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: -0.33333333,
amount: 5_000,
},
entities: [],
},
{
id: 'entity-b',
aggregation: [20_000, 30_000],
change: {
ratio: 0.5,
amount: 10_000,
},
entities: [],
},
{
id: 'entity-c',
aggregation: [18_000, 25_000],
change: {
ratio: 0.38,
amount: 7_000,
},
entities: [],
},
{
id: 'entity-d',
aggregation: [15_000, 30_000],
change: {
ratio: 1,
amount: 15_000,
},
entities: [],
},
{
id: 'entity-e',
aggregation: [0, 10_000],
entities: [],
change: {
ratio: 10_000,
amount: 10_000,
},
},
{
id: 'entity-f',
aggregation: [17_000, 19_000],
change: {
ratio: 0.118,
amount: 2_000,
},
entities: [],
},
{
id: 'entity-g',
aggregation: [80_000, 60_000],
change: {
ratio: -0.25,
amount: -20_000,
},
entities: [
{
id: 'vCPU Time Batch Belgium',
aggregation: [15_000, 15_000],
change: {
ratio: 0,
amount: 0,
},
entities: [],
},
{
id: 'RAM Time Belgium',
aggregation: [15_000, 30_000],
change: {
ratio: 1,
amount: 15_000,
},
entities: [],
},
{
id: 'Local Disk Time PD Standard Belgium',
aggregation: [50_000, 15_000],
change: {
ratio: -0.7,
amount: -35_000,
},
entities: [],
},
],
},
],
});
const productInsights: ProductCost = await this.request(
productInsightsOptions,
{
aggregation: [200_000, 250_000],
change: {
ratio: 0.2,
amount: 50_000,
},
entities: [
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [15_000, 30_000],
},
{
id: 'entity-a',
aggregation: [15_000, 20_000],
},
{
id: 'entity-b',
aggregation: [20_000, 30_000],
},
{
id: 'entity-c',
aggregation: [18_000, 25_000],
},
{
id: 'entity-d',
aggregation: [36_000, 42_000],
},
{
id: 'entity-e',
aggregation: [0, 10_000],
},
{
id: 'entity-f',
aggregation: [17_000, 19_000],
},
{
id: 'entity-g',
aggregation: [49_000, 30_000],
},
{
id: 'entity-h',
aggregation: [0, 34_000],
},
],
},
);
return productInsightsOptions.project
? projectProductInsights
@@ -15,56 +15,40 @@
*/
import React from 'react';
import { TooltipPayload } from 'recharts';
import { fireEvent } from '@testing-library/react';
import { BarChart, BarChartProps } from './BarChart';
import { BarChartData, ResourceData } from '../../types';
import { ResourceData } from '../../types';
import { createMockEntity } from '../../utils/mockData';
import { resourceSort } from '../../utils/sort';
import { renderInTestApp } from '@backstage/test-utils';
import { TooltipItemProps } from '../Tooltip';
import { costInsightsLightTheme } from '../../utils/styles';
// Disable responsive container console warnings generated by recharts; doesn't disable React warnings.
jest.spyOn(console, 'warn').mockImplementation(() => {});
const MockEntities = [...Array(10)].map((_, index) =>
createMockEntity(() => ({
createMockEntity(mock => ({
...mock,
id: `test-id-${index + 1}`,
// grow resource costs linearly for testing
aggregation: [index * 1000, (index + 1) * 1000],
})),
);
const MockBarChartData: BarChartData = {
previousFill: costInsightsLightTheme.palette.yellow,
currentFill: costInsightsLightTheme.palette.darkBlue,
previousName: 'Last Period',
currentName: 'Current Period',
};
const MockResources: ResourceData[] = MockEntities.map(entity => ({
name: entity.id,
previous: entity.aggregation[0],
current: entity.aggregation[1],
}));
const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({
label: payload.name,
value: payload.value as string,
fill: payload.fill as string,
});
const renderWithProps = ({
responsive = false,
displayAmount = 6,
barChartData = MockBarChartData,
getTooltipItem = MockTooltipItem,
resources = MockResources,
}: BarChartProps) => {
return renderInTestApp(
<BarChart
responsive={responsive}
displayAmount={displayAmount}
barChartData={barChartData}
getTooltipItem={getTooltipItem}
resources={resources}
/>,
);
@@ -20,22 +20,21 @@ import {
BarChart as RechartsBarChart,
CartesianGrid,
ContentRenderer,
TooltipProps,
TooltipProps as RechartsTooltipProps,
RechartsFunction,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
TooltipPayload,
} from 'recharts';
import { Box, useTheme } from '@material-ui/core';
import { BarChartTick } from './BarChartTick';
import { BarChartStepper } from './BarChartStepper';
import { Tooltip, TooltipItemProps } from '../Tooltip';
import { BarChartTooltip } from './BarChartTooltip';
import { BarChartTooltipItem } from './BarChartTooltipItem';
import { currencyFormatter } from '../../utils/formatters';
import {
BarChartData,
Maybe,
ResourceData,
DataKey,
CostInsightsTheme,
@@ -43,28 +42,58 @@ import {
import { notEmpty } from '../../utils/assert';
import { useBarChartStyles } from '../../utils/styles';
import { resourceSort } from '../../utils/sort';
import { isInvalid, titleOf, tooltipItemOf } from '../../utils/graphs';
export const defaultTooltip: ContentRenderer<RechartsTooltipProps> = ({
label,
payload = [],
}) => {
if (isInvalid({ label, payload })) return null;
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
return (
<BarChartTooltip title={title}>
{items.map((item, index) => (
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
))}
</BarChartTooltip>
);
};
export type BarChartProps = {
resources: ResourceData[];
responsive?: boolean;
displayAmount?: number;
barChartData: BarChartData;
getTooltipItem: (payload: TooltipPayload) => Maybe<TooltipItemProps>;
resources: ResourceData[];
options?: Partial<BarChartData>;
tooltip?: ContentRenderer<RechartsTooltipProps>;
onClick?: RechartsFunction;
onMouseMove?: RechartsFunction;
};
export const BarChart = ({
resources,
responsive = true,
displayAmount = 6,
barChartData,
getTooltipItem,
resources,
options = {},
tooltip = defaultTooltip,
onClick,
onMouseMove,
}: BarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useBarChartStyles(theme);
const [activeChart, setActiveChart] = useState(false);
const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]);
const { previousFill, currentFill, previousName, currentName } = barChartData;
const data = Object.assign(
{
previousFill: theme.palette.lightBlue,
currentFill: theme.palette.darkBlue,
previousName: 'Previous',
currentName: 'Current',
},
options,
);
const [stepStart, stepEnd] = stepWindow;
const steps = Math.ceil(resources.length / displayAmount);
@@ -95,24 +124,11 @@ export const BarChart = ({
[setStepWindow, resources, displayAmount],
);
const handleChartEnter = () => setActiveChart(true);
const handleChartLeave = () => setActiveChart(false);
const renderTooltipContent: ContentRenderer<TooltipProps> = ({
label,
payload,
}) => {
if (!(payload && typeof label === 'string')) return [null, null];
const items = payload.map(getTooltipItem).filter(notEmpty);
return <Tooltip label={label} items={items} />;
};
return (
<Box
position="relative"
onMouseLeave={handleChartLeave}
onMouseEnter={handleChartEnter}
onMouseLeave={() => setActiveChart(false)}
onMouseEnter={() => setActiveChart(true)}
data-testid="bar-chart-wrapper"
>
{/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */}
@@ -121,16 +137,22 @@ export const BarChart = ({
width={responsive ? '100%' : styles.container.width}
>
<RechartsBarChart
style={{ cursor: onClick ? 'pointer' : null }}
onClick={onClick}
onMouseMove={onMouseMove}
data={sortedResources}
margin={styles.barChart.margin}
barSize={45}
data-testid="bar-chart"
>
<RechartsTooltip
cursor={styles.cursor}
animationDuration={100}
content={renderTooltipContent}
/>
{tooltip && (
<RechartsTooltip
filterNull
cursor={styles.cursor}
animationDuration={100}
content={tooltip}
/>
)}
<CartesianGrid
vertical={false}
stroke={styles.cartesianGrid.stroke}
@@ -145,18 +167,18 @@ export const BarChart = ({
<YAxis
tickFormatter={currencyFormatter.format}
domain={[() => 0, globalResourcesMax]}
tick={{ fill: styles.axis.fill }}
tick={styles.axis}
/>
<Bar
dataKey={DataKey.Previous}
name={previousName}
fill={previousFill}
name={data.previousName}
fill={data.previousFill}
isAnimationActive={false}
/>
<Bar
dataKey={DataKey.Current}
name={currentName}
fill={currentFill}
name={data.currentName}
fill={data.currentFill}
isAnimationActive={false}
/>
</RechartsBarChart>
@@ -15,7 +15,7 @@
*/
import React, { PropsWithChildren } from 'react';
import { Box } from '@material-ui/core';
import { Box, Typography } from '@material-ui/core';
import { useBarChartLabelStyles } from '../../utils/styles';
type BarChartLabel = {
@@ -23,6 +23,7 @@ type BarChartLabel = {
y: number;
height: number;
width: number;
details?: JSX.Element;
};
export const BarChartLabel = ({
@@ -30,11 +31,11 @@ export const BarChartLabel = ({
y,
height,
width,
details,
children,
}: PropsWithChildren<BarChartLabel>) => {
const classes = useBarChartLabelStyles();
const translateX = width * -0.5;
const childArray = React.Children.toArray(children);
return (
<foreignObject
@@ -46,8 +47,10 @@ export const BarChartLabel = ({
width={width}
>
<Box display="flex" flexDirection="column" justifyContent="center">
<b className={classes.label}>{childArray[0]}</b>
{childArray.slice(1)}
<Typography className={classes.label} gutterBottom>
{children}
</Typography>
{details}
</Box>
</foreignObject>
);
@@ -15,20 +15,15 @@
*/
import React from 'react';
import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend';
import { renderInTestApp } from '@backstage/test-utils';
import { BarChartLegend } from './BarChartLegend';
describe('<UnlabeledDataflowBarChartLegend />', () => {
it('Displays the correct text', async () => {
describe('<BarChartLegend />', () => {
it(`Should display the correct cost start and end`, async () => {
const rendered = await renderInTestApp(
<UnlabeledDataflowBarChartLegend
unlabeledCost={9842.822}
labeledCost={2309.1211}
/>,
<BarChartLegend costStart={1000} costEnd={5000} />,
);
expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument();
expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument();
expect(rendered.getByText('$9,843')).toBeInTheDocument();
expect(rendered.getByText('$2,309')).toBeInTheDocument();
expect(rendered.getByText(/\$1,000/)).toBeInTheDocument();
expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument();
});
});
@@ -14,47 +14,58 @@
* limitations under the License.
*/
import React from 'react';
import React, { PropsWithChildren } from 'react';
import { Box, useTheme } from '@material-ui/core';
import { LegendItem } from '../LegendItem';
import { CostGrowth } from '../CostGrowth';
import { currencyFormatter } from '../../utils/formatters';
import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types';
import { CostInsightsTheme } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
export type ResourceGrowthBarChartLegendProps = {
change: ChangeStatistic;
duration: Duration;
type BarChartLegendOptions = {
previousName: string;
previousFill: string;
currentName: string;
costStart: number;
costEnd: number;
currentFill: string;
};
export const ResourceGrowthBarChartLegend = ({
change,
duration,
previousName,
currentName,
export type BarChartLegendProps = {
costStart: number;
costEnd: number;
options?: Partial<BarChartLegendOptions>;
};
export const BarChartLegend = ({
costStart,
costEnd,
}: ResourceGrowthBarChartLegendProps) => {
options = {},
children,
}: PropsWithChildren<BarChartLegendProps>) => {
const theme = useTheme<CostInsightsTheme>();
const classes = useStyles();
const data = Object.assign(
{
previousName: 'Previous',
previousFill: theme.palette.lightBlue,
currentName: 'Current',
currentFill: theme.palette.darkBlue,
},
options,
);
return (
<Box display="flex" flexDirection="row">
<Box className={classes.legend} display="flex" flexDirection="row">
<Box marginRight={2}>
<LegendItem title={previousName} markerColor={theme.palette.lightBlue}>
<LegendItem title={data.previousName} markerColor={data.previousFill}>
{currencyFormatter.format(costStart)}
</LegendItem>
</Box>
<Box marginRight={2}>
<LegendItem title={currentName} markerColor={theme.palette.darkBlue}>
<LegendItem title={data.currentName} markerColor={data.currentFill}>
{currencyFormatter.format(costEnd)}
</LegendItem>
</Box>
<LegendItem title={`Cost ${change.ratio <= 0 ? 'Savings' : 'Growth'}`}>
<CostGrowth change={change} duration={duration} />
</LegendItem>
{children}
</Box>
);
};
@@ -26,6 +26,7 @@ type BarChartTickProps = {
value: any;
};
visibleTicksCount: number;
details?: JSX.Element;
};
export const BarChartTick = ({
@@ -35,11 +36,18 @@ export const BarChartTick = ({
width,
payload,
visibleTicksCount,
details,
}: BarChartTickProps) => {
const gutterWidth = 5;
const labelWidth = width / visibleTicksCount - gutterWidth * 2;
return (
<BarChartLabel x={x} y={y} height={height} width={labelWidth}>
<BarChartLabel
x={x}
y={y}
height={height}
width={labelWidth}
details={details}
>
{!payload.value ? 'Unlabeled' : payload.value}
</BarChartLabel>
);
@@ -16,10 +16,11 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { Tooltip } from './Tooltip';
import { BarChartTooltip } from './BarChartTooltip';
import { BarChartTooltipItem } from './BarChartTooltipItem';
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
const mockTooltipItems = [
const items = [
{
label: 'Cost',
value: '$1,000,000',
@@ -32,22 +33,14 @@ const mockTooltipItems = [
},
];
describe('<Tooltip/>', () => {
it('renders without exploding', async () => {
const rendered = await renderInTestApp(
<CostInsightsThemeProvider>
<Tooltip label="05/16/2020" items={mockTooltipItems} />
</CostInsightsThemeProvider>,
);
expect(
rendered.container.querySelector('.tooltip-content'),
).toBeInTheDocument();
});
const tooltipItems = () =>
items.map(item => <BarChartTooltipItem key={item.label} item={item} />);
describe('<BarChartTooltip/>', () => {
it('formats label and tooltip item text correctly', async () => {
const rendered = await renderInTestApp(
<CostInsightsThemeProvider>
<Tooltip label="05/16/2020" items={mockTooltipItems} />
<BarChartTooltip title="05/16/2020">{tooltipItems}</BarChartTooltip>
</CostInsightsThemeProvider>,
);
expect(rendered.getByText('05/16/2020')).toBeInTheDocument();
@@ -0,0 +1,79 @@
/*
* 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, { ReactNode, PropsWithChildren } from 'react';
import { Box, Divider, Typography } from '@material-ui/core';
import { useTooltipStyles as useStyles } from '../../utils/styles';
export type BarChartTooltipProps = {
title: string;
content?: ReactNode | string;
subtitle?: ReactNode;
topRight?: ReactNode;
actions?: ReactNode;
};
export const BarChartTooltip = ({
title,
content,
subtitle,
topRight,
actions,
children,
}: PropsWithChildren<BarChartTooltipProps>) => {
const classes = useStyles();
return (
<Box className={classes.tooltip} display="flex" flexDirection="column">
<Box
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="baseline"
px={2}
pt={2}
>
<Box display="flex" flexDirection="column">
<Typography variant="h6">{title}</Typography>
{subtitle && (
<Typography className={classes.subtitle} variant="subtitle1">
{subtitle}
</Typography>
)}
</Box>
{topRight}
</Box>
{content && (
<Box px={2} pt={2}>
<Typography variant="body1" paragraph>
{content}
</Typography>
</Box>
)}
<Box display="flex" flexDirection="column" p={2}>
{children}
</Box>
{actions && (
<>
<Divider className={classes.divider} variant="fullWidth" />
<Box display="flex" flexDirection="column" p={2}>
{actions}
</Box>
</>
)}
</Box>
);
};
@@ -19,15 +19,18 @@ import { Box, Typography } from '@material-ui/core';
import LensIcon from '@material-ui/icons/Lens';
import { useTooltipStyles as useStyles } from '../../utils/styles';
export type TooltipItemProps = {
value: string;
label: string;
export type TooltipItem = {
fill: string;
label: string;
value: string;
};
export const TooltipItem = ({ fill, label, value }: TooltipItemProps) => {
export type BarChartTooltipItemProps = {
item: TooltipItem;
};
export const BarChartTooltipItem = ({ item }: BarChartTooltipItemProps) => {
const classes = useStyles();
const style = { fill: fill };
return (
<Box
display="flex"
@@ -37,11 +40,11 @@ export const TooltipItem = ({ fill, label, value }: TooltipItemProps) => {
>
<Box display="flex" alignContent="center" marginRight=".5em">
<Box display="flex" alignItems="center" marginRight=".5em">
<LensIcon className={classes.lensIcon} style={style} />
<LensIcon className={classes.lensIcon} style={{ fill: item.fill }} />
</Box>
<Typography>{label}</Typography>
<Typography>{item.label}</Typography>
</Box>
<Typography display="block">{value}</Typography>
<Typography display="block">{item.value}</Typography>
</Box>
);
};
@@ -16,3 +16,12 @@
export { BarChart } from './BarChart';
export type { BarChartProps } from './BarChart';
export { BarChartLegend } from './BarChartLegend';
export type { BarChartLegendProps } from './BarChartLegend';
export { BarChartTooltip } from './BarChartTooltip';
export type { BarChartTooltipProps } from './BarChartTooltip';
export { BarChartTooltipItem } from './BarChartTooltipItem';
export type {
TooltipItem,
BarChartTooltipItemProps,
} from './BarChartTooltipItem';
@@ -49,7 +49,7 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => {
const converted = amount / (currency.rate ?? rate);
// Determine if growth is significant enough to highlight
const growth = growthOf(engineers, change.ratio);
const growth = growthOf(change.ratio, engineers);
const classes = classnames({
[styles.excess]: growth === GrowthType.Excess,
[styles.savings]: growth === GrowthType.Savings,
@@ -0,0 +1,57 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { CostGrowthIndicator } from './CostGrowthIndicator';
import { ChangeThreshold, EngineerThreshold } from '../../types';
describe.each`
ratio | amount | ariaLabel
${-0.1} | ${undefined} | ${'savings'}
${0.01} | ${undefined} | ${'excess'}
${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'}
${ChangeThreshold.upper} | ${EngineerThreshold} | ${'excess'}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${'excess'}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${'excess'}
`('growthOf', ({ ratio, amount, ariaLabel }) => {
it(`should display the correct indicator for ${ariaLabel}`, async () => {
const { getByLabelText } = await renderInTestApp(
<CostGrowthIndicator ratio={ratio} amount={amount} />,
);
expect(getByLabelText(ariaLabel)).toBeInTheDocument();
});
});
describe.each`
ratio | amount
${0} | ${undefined}
${ChangeThreshold.lower} | ${0}
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold}
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1}
`('growthOf', ({ ratio, amount }) => {
it('should display the correct indicator for negligible growth', async () => {
const { queryByLabelText } = await renderInTestApp(
<CostGrowthIndicator ratio={ratio} amount={amount} />,
);
expect(queryByLabelText('savings')).not.toBeInTheDocument();
expect(queryByLabelText('excess')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
/*
* 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 classnames from 'classnames';
import { Typography, TypographyProps } from '@material-ui/core';
import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp';
import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown';
import { growthOf } from '../../utils/change';
import { GrowthType } from '../../types';
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
export type CostGrowthIndicatorProps = TypographyProps & {
ratio: number;
amount?: number;
formatter?: (amount: number) => string;
};
export const CostGrowthIndicator = ({
ratio,
amount,
formatter,
className,
...props
}: CostGrowthIndicatorProps) => {
const classes = useStyles();
const growth = growthOf(ratio, amount);
const classNames = classnames(classes.indicator, className, {
[classes.savings]: growth === GrowthType.Savings,
[classes.excess]: growth === GrowthType.Excess,
});
// Display cost as a factor of engineer cost growth and percentage growth
if (typeof amount === 'number') {
return (
<Typography className={classNames} component="span" {...props}>
{formatter ? formatter(amount) : amount}
{growth === GrowthType.Savings && (
<ArrowDropDown aria-label="savings" />
)}
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
</Typography>
);
}
// Display cost as a factor of percent change
return (
<Typography className={classNames} component="span" {...props}>
{formatter ? formatter(ratio) : ratio}
{ratio < 0 && <ArrowDropDown aria-label="savings" />}
{ratio > 0 && <ArrowDropUp aria-label="excess" />}
</Typography>
);
};
@@ -16,3 +16,5 @@
export { CostGrowth } from './CostGrowth';
export type { CostGrowthProps } from './CostGrowth';
export { CostGrowthIndicator } from './CostGrowthIndicator';
export type { CostGrowthIndicatorProps } from './CostGrowthIndicator';
@@ -98,7 +98,7 @@ export const CostOverviewCard = ({
/>
</Box>
<Box display="flex" justifyContent="flex-end" alignItems="center">
{config.metrics.length > 1 && (
{config.metrics.length && (
<MetricSelect
metric={filters.metric}
metrics={config.metrics}
@@ -14,32 +14,38 @@
* limitations under the License.
*/
import React from 'react';
import moment from 'moment';
import { useTheme } from '@material-ui/core';
import {
ComposedChart,
ContentRenderer,
TooltipProps,
XAxis,
YAxis,
Tooltip,
Tooltip as RechartsTooltip,
CartesianGrid,
Area,
Line,
ResponsiveContainer,
TooltipPayload,
} from 'recharts';
import {
ChartData,
Cost,
DEFAULT_DATE_FORMAT,
Maybe,
Metric,
MetricData,
CostInsightsTheme,
} from '../../types';
import {
BarChartTooltip as Tooltip,
BarChartTooltipItem as TooltipItem,
} from '../BarChart';
import {
overviewGraphTickFormatter,
formatGraphValue,
isInvalid,
} from '../../utils/graphs';
import { CostOverviewTooltip } from './CostOverviewTooltip';
import { TooltipItemProps } from '../Tooltip';
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
import { aggregationSort } from '../../utils/sort';
@@ -91,22 +97,39 @@ export const CostOverviewChart = ({
: {}),
}));
function tooltipFormatter(payload: TooltipPayload): TooltipItemProps {
return {
label:
payload.dataKey === data.dailyCost.dataKey
? data.dailyCost.name
: data.metric.name,
value:
payload.dataKey === data.dailyCost.dataKey
? formatGraphValue(payload.value as number, data.dailyCost.format)
: formatGraphValue(payload.value as number, data.metric.format),
fill:
payload.dataKey === data.dailyCost.dataKey
? theme.palette.blue
: theme.palette.magenta,
};
}
const tooltipRenderer: ContentRenderer<TooltipProps> = ({
label,
payload = [],
}) => {
if (isInvalid({ label, payload })) return null;
const dataKeys = [data.dailyCost.dataKey, data.metric.dataKey];
const title = moment(label).format(DEFAULT_DATE_FORMAT);
const items = payload
.filter(p => dataKeys.includes(p.dataKey as string))
.map(p => ({
label:
p.dataKey === data.dailyCost.dataKey
? data.dailyCost.name
: data.metric.name,
value:
p.dataKey === data.dailyCost.dataKey
? formatGraphValue(p.value as number, data.dailyCost.format)
: formatGraphValue(p.value as number, data.metric.format),
fill:
p.dataKey === data.dailyCost.dataKey
? theme.palette.blue
: theme.palette.magenta,
}));
return (
<Tooltip title={title}>
{items.map((item, index) => (
<TooltipItem key={`${item.label}-${index}`} item={item} />
))}
</Tooltip>
);
};
return (
<ResponsiveContainer
@@ -168,15 +191,7 @@ export const CostOverviewChart = ({
yAxisId={data.metric.dataKey}
/>
)}
<Tooltip
content={
<CostOverviewTooltip
dataKeys={[data.dailyCost.dataKey, data.metric.dataKey]}
format={tooltipFormatter}
/>
}
animationDuration={100}
/>
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
</ComposedChart>
</ResponsiveContainer>
);
@@ -1,38 +0,0 @@
/*
* 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 moment from 'moment';
import { TooltipPayload, TooltipProps } from 'recharts';
import { Tooltip, TooltipItemProps } from '../../components/Tooltip';
import { DEFAULT_DATE_FORMAT } from '../../types';
export type CostOverviewTooltipProps = TooltipProps & {
dataKeys: Array<string>;
format: (payload: TooltipPayload) => TooltipItemProps;
};
export const CostOverviewTooltip = ({
label,
payload,
dataKeys,
format,
}: CostOverviewTooltipProps) => {
const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT);
const items = payload
?.filter((p: TooltipPayload) => dataKeys.includes(p.dataKey as string))
.map(p => format(p));
return <Tooltip label={tooltipLabel} items={items} />;
};
@@ -21,6 +21,7 @@ import { useConfig } from '../../hooks';
export const ProductInsights = ({}) => {
const config = useConfig();
return (
<>
<Box mt={0} mb={5} textAlign="center">
@@ -0,0 +1,188 @@
/*
* 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 classnames from 'classnames';
import { Table, TableColumn } from '@backstage/core';
import { Dialog, IconButton, Typography } from '@material-ui/core';
import { default as CloseButton } from '@material-ui/icons/Close';
import { CostGrowthIndicator } from '../CostGrowth';
import { currencyFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { BarChartOptions, Entity } from '../../types';
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
[classes.rowTotal]: row.id === 'total',
[classes.colFirst]: col === 'sku',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{currencyFormatter.format(row[col])}
</Typography>
);
case 'ratio':
return (
<CostGrowthIndicator
className={rowStyles}
ratio={row.ratio}
formatter={amount => formatPercent(Math.abs(amount))}
/>
);
default:
return <Typography className={rowStyles}>{row.sku}</Typography>;
}
};
}
// material-table does not support fixed rows. Override the sorting algorithm
// to force Total row to bottom by default or when a user sort toggles a column.
function createSorter(field?: keyof Omit<RowData, 'id'>) {
return function rowSort(data1: {}, data2: {}): number {
const a = data1 as RowData;
const b = data2 as RowData;
if (a.id === 'total') return 1;
if (b.id === 'total') return 1;
if (field === 'sku') return a.sku.localeCompare(b.sku);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
const defaultEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: [],
};
type RowData = {
id: string;
sku: string;
previous: number;
current: number;
ratio: number;
};
type ProductEntityDialogOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
type ProductEntityDialogProps = {
open: boolean;
entity?: Entity;
options?: ProductEntityDialogOptions;
onClose: () => void;
};
export const ProductEntityDialog = ({
open,
entity = defaultEntity,
options = {},
onClose,
}: ProductEntityDialogProps) => {
const classes = useStyles();
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'sku',
title: <Typography className={firstColClasses}>SKU</Typography>,
render: createRenderer('sku', classes),
customSort: createSorter('sku'),
},
{
field: 'previous',
title: (
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
customSort: createSorter('previous'),
},
{
field: 'current',
title: (
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
customSort: createSorter('current'),
},
{
field: 'ratio',
title: <Typography className={lastColClasses}>M/M</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entity.entities
.map(e => ({
id: e.id || 'Unknown',
sku: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
sku: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
return (
<Dialog open={open} onClose={onClose} scroll="body" fullWidth maxWidth="lg">
<IconButton className={classes.closeButton} onClick={onClose}>
<CloseButton />
</IconButton>
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unknown'}
subtitle="Resource breakdown"
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
/>
</Dialog>
);
};
@@ -20,7 +20,6 @@ import { ProductInsightsCard } from './ProductInsightsCard';
import { CostInsightsApi } from '../../api';
import {
createMockEntity,
createMockProductCost,
mockDefaultLoadingState,
MockComputeEngine,
MockProductFilters,
@@ -35,16 +34,14 @@ import {
MockScrollProvider,
MockLoadingProvider,
} from '../../utils/tests';
import { Duration, Product, ProductCost, ProductPeriod } from '../../types';
import { Duration, Entity, Product, ProductPeriod } from '../../types';
const costInsightsApi = (
productCost: ProductCost,
): Partial<CostInsightsApi> => ({
getProductInsights: () =>
Promise.resolve(productCost) as Promise<ProductCost>,
const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
getProductInsights: () => Promise.resolve(entity),
});
const mockProductCost = createMockProductCost(() => ({
const mockProductCost = createMockEntity(() => ({
id: 'test-id',
entities: [],
aggregation: [3000, 4000],
change: {
@@ -54,12 +51,12 @@ const mockProductCost = createMockProductCost(() => ({
}));
const renderProductInsightsCardInTestApp = async (
productCost: ProductCost,
entity: Entity,
product: Product,
duration: Duration,
) =>
await renderInTestApp(
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi(productCost)}>
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi(entity)}>
<MockConfigProvider>
<MockLoadingProvider state={mockDefaultLoadingState}>
<MockGroupsProvider>
@@ -96,27 +93,30 @@ describe('<ProductInsightsCard/>', () => {
});
it('Should render the right subheader for products with cost data', async () => {
const productCost = {
const entity = {
...mockProductCost,
entities: [...Array(1000)].map(createMockEntity),
};
const rendered = await renderProductInsightsCardInTestApp(
productCost,
entity,
MockComputeEngine,
Duration.P1M,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(
`${productCost.entities.length} ${subheader}`,
);
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
});
it('Should render the right subheader if there is no cost data or change data', async () => {
const productCost = { entities: [], aggregation: [0, 0] } as ProductCost;
const entity: Entity = {
id: 'test-id',
entities: [],
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
};
const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`;
const rendered = await renderProductInsightsCardInTestApp(
productCost,
entity,
MockComputeEngine,
Duration.P1M,
);
@@ -138,12 +138,12 @@ describe('<ProductInsightsCard/>', () => {
'Should display the correct relative time',
({ duration, periodStartText, periodEndText }) => {
it(`Should display the correct relative time for ${duration}`, async () => {
const productCost = {
const entity = {
...mockProductCost,
entities: [...Array(3)].map(createMockEntity),
};
const rendered = await renderProductInsightsCardInTestApp(
productCost,
entity,
MockComputeEngine,
duration,
);
@@ -16,12 +16,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { InfoCard, useApi } from '@backstage/core';
import { Box, Typography } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import { Typography } from '@material-ui/core';
import { costInsightsApiRef } from '../../api';
import { ProductInsightsChart } from './ProductInsightsChart';
import { PeriodSelect } from '../PeriodSelect';
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
import {
useFilters,
useLastCompleteBillingDate,
@@ -30,9 +29,8 @@ import {
} from '../../hooks';
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { mapFiltersToProps, mapLoadingToProps } from './selector';
import { Duration, Maybe, Product, ProductCost } from '../../types';
import { Duration, Maybe, Product, Entity } from '../../types';
import { pluralOf } from '../../utils/grammar';
import { formatPeriod } from '../../utils/formatters';
type ProductInsightsCardProps = {
product: Product;
@@ -43,7 +41,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
const classes = useStyles();
const { ScrollAnchor } = useScroll(product.kind);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [resource, setResource] = useState<Maybe<ProductCost>>(null);
const [entity, setEntity] = useState<Maybe<Entity>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { group, product: productFilter, setProduct, project } = useFilters(
@@ -57,39 +55,25 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]);
const amount = resource?.entities?.length || 0;
const hasCostsWithinTimeframe = !!(resource?.change && amount);
const previousName = formatPeriod(
productFilter.duration,
lastCompleteBillingDate,
false,
);
const currentName = formatPeriod(
productFilter.duration,
lastCompleteBillingDate,
true,
);
const amount = entity?.entities?.length || 0;
const hasCostsWithinTimeframe = !!(entity?.change && amount);
const subheader = hasCostsWithinTimeframe
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
: null;
const costStart = resource?.aggregation[0] || 0;
const costEnd = resource?.aggregation[1] || 0;
useEffect(() => {
async function load() {
if (loadingProduct) {
try {
const p: ProductCost = await client.getProductInsights({
const e: Entity = await client.getProductInsights({
product: product.kind,
group: group!,
duration: productFilter!.duration,
lastCompleteBillingDate,
project,
});
setResource(p);
setEntity(e);
} catch (e) {
setError(e);
} finally {
@@ -101,7 +85,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
}, [
client,
product,
setResource,
setEntity,
loadingProduct,
dispatchLoadingProduct,
productFilter,
@@ -137,7 +121,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
);
}
if (!resource) {
if (!entity) {
return null;
}
@@ -145,23 +129,11 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
<InfoCard title={product.name} subheader={subheader} {...infoCardProps}>
<ScrollAnchor behavior="smooth" top={-12} />
{hasCostsWithinTimeframe ? (
<Box display="flex" flexDirection="column">
<Box pb={2}>
<ResourceGrowthBarChartLegend
duration={productFilter.duration}
change={resource.change!}
previousName={previousName}
currentName={currentName}
costStart={costStart}
costEnd={costEnd}
/>
</Box>
<ResourceGrowthBarChart
previousName={previousName}
currentName={currentName}
resources={resource.entities || []}
/>
</Box>
<ProductInsightsChart
billingDate={lastCompleteBillingDate}
duration={productFilter.duration}
entity={entity}
/>
) : (
<Typography>
There are no {product.name} costs within this timeframe for your
@@ -0,0 +1,225 @@
/*
* 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, { useEffect, useState } from 'react';
import {
ContentRenderer,
TooltipProps as RechartsTooltipProps,
RechartsFunction,
} from 'recharts';
import { Box, Typography } from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import { LegendItem } from '../LegendItem';
import { ProductEntityDialog } from './ProductEntityDialog';
import { CostGrowth, CostGrowthIndicator } from '../CostGrowth';
import {
BarChart,
BarChartLegend,
BarChartTooltip,
BarChartTooltipItem,
} from '../BarChart';
import { pluralOf } from '../../utils/grammar';
import { findAlways, notEmpty } from '../../utils/assert';
import { formatPeriod, formatPercent } from '../../utils/formatters';
import {
titleOf,
tooltipItemOf,
resourceOf,
isInvalid,
isActiveLabel,
} from '../../utils/graphs';
import {
useProductInsightsChartStyles as useStyles,
useBarChartLayoutStyles as useLayoutStyles,
} from '../../utils/styles';
import { BarChartOptions, Duration, Entity, Maybe } from '../../types';
export type ProductInsightsChartProps = {
billingDate: string;
entity: Entity;
duration: Duration;
};
export const ProductInsightsChart = ({
billingDate,
entity,
duration,
}: ProductInsightsChartProps) => {
const classes = useStyles();
const layoutClasses = useLayoutStyles();
const [isOpen, setOpen] = useState(false);
const [isClickable, setClickable] = useState(true);
const [selectLabel, setSelected] = useState<Maybe<string>>(null);
const [activeLabel, setActive] = useState<Maybe<string>>(null);
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
const costStart = entity.aggregation[0];
const costEnd = entity.aggregation[1];
const resources = entity.entities.map(resourceOf);
const options: Partial<BarChartOptions> = {
previousName: formatPeriod(duration, billingDate, false),
currentName: formatPeriod(duration, billingDate, true),
};
useEffect(() => {
function toggleModal() {
if (selectLabel) {
setOpen(true);
} else {
setOpen(false);
}
}
toggleModal();
}, [selectLabel]);
useEffect(() => {
// disable click if an entity is unlabeled or if it does not have any skus
function toggleClickableEntity() {
if (activeLabel) {
const hasSkus = entity.entities.find(e => e.id === activeLabel)
?.entities.length;
if (hasSkus) {
setClickable(true);
} else {
setClickable(false);
}
} else {
setClickable(false);
}
}
toggleClickableEntity();
}, [activeLabel, entity]);
const onMouseMove: RechartsFunction = (
data: Record<'activeLabel', string | undefined>,
) => {
if (isActiveLabel(data)) {
setActive(data.activeLabel!);
} else {
setActive(null);
}
};
const onClick: RechartsFunction = (data: Record<'activeLabel', string>) => {
if (isActiveLabel(data)) {
setSelected(data.activeLabel);
} else {
setSelected(null);
}
};
const renderProductInsightsTooltip: ContentRenderer<RechartsTooltipProps> = ({
label,
payload = [],
}) => {
/* Labels and payloads may be undefined or empty */
if (isInvalid({ label, payload })) return null;
/*
* recharts coerces null values to strings
* entity -> resource -> payload
* { id: null } -> { name: null } -> { label: '' }
*/
const id = label === '' ? null : label;
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
const activeEntity = findAlways(entity.entities, e => e.id === id);
const ratio = activeEntity.change.ratio;
const skus = activeEntity.entities;
const subtitle = `${skus.length} ${pluralOf(skus.length, 'SKU')}`;
if (skus.length) {
return (
<BarChartTooltip
title={title}
subtitle={subtitle}
topRight={
<CostGrowthIndicator
className={classes.indicator}
ratio={ratio}
formatter={formatPercent}
/>
}
actions={
<Box className={classes.actions}>
<FullScreenIcon />
<Typography>Click for breakdown</Typography>
</Box>
}
>
{items.map((item, index) => (
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
))}
</BarChartTooltip>
);
}
// If an entity doesn't have any skus, there aren't any costs to break down.
return (
<BarChartTooltip
title={title}
topRight={
<CostGrowthIndicator
className={classes.indicator}
ratio={ratio}
formatter={formatPercent}
/>
}
content={
id
? null
: "This product has costs that are not labeled and therefore can't be attributed to a specific entity."
}
>
{items.map((item, index) => (
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
))}
</BarChartTooltip>
);
};
const barChartProps = isClickable ? { onClick } : {};
return (
<Box className={layoutClasses.wrapper}>
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
<LegendItem title={legendTitle}>
<CostGrowth change={entity.change} duration={duration} />
</LegendItem>
</BarChartLegend>
<BarChart
resources={resources}
tooltip={renderProductInsightsTooltip}
onMouseMove={onMouseMove}
options={options}
{...barChartProps}
/>
{selectLabel && entity.entities.length && (
<ProductEntityDialog
open={isOpen}
onClose={() => setSelected(null)}
entity={entity.entities.find(e => e.id === selectLabel)}
options={options}
/>
)}
</Box>
);
};
@@ -15,3 +15,4 @@
*/
export { ProductInsightsCard } from './ProductInsightsCard';
export { ProductInsightsChart } from './ProductInsightsChart';
@@ -15,12 +15,9 @@
*/
import React from 'react';
import moment from 'moment';
import { Box } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
import { Duration, ProjectGrowthData } from '../../types';
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
import { ProjectGrowthData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
@@ -28,39 +25,17 @@ type ProjectGrowthAlertProps = {
};
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
const [costStart, costEnd] = alert.aggregation;
const subheader = `
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
alert.products.length > 1 ? ', sorted by cost' : ''
}`;
const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format(
'[Q]Q YYYY',
);
const currentName = moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY');
return (
<InfoCard
title={`Project growth for ${alert.project}`}
subheader={subheader}
>
<Box display="flex" flexDirection="column">
<Box pb={2}>
<ResourceGrowthBarChartLegend
change={alert.change}
duration={Duration.P3M}
previousName={previousName}
currentName={currentName}
costStart={costStart}
costEnd={costEnd}
/>
</Box>
<ResourceGrowthBarChart
resources={alert.products}
previousName={previousName}
currentName={currentName}
/>
</Box>
<ProjectGrowthAlertChart alert={alert} />
</InfoCard>
);
};
@@ -0,0 +1,55 @@
/*
* 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 moment from 'moment';
import { Box } from '@material-ui/core';
import { BarChart, BarChartLegend } from '../BarChart';
import { LegendItem } from '../LegendItem';
import { CostGrowth } from '../CostGrowth';
import { BarChartOptions, Duration, ProjectGrowthData } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
import { resourceOf } from '../../utils/graphs';
type ProjectGrowthAlertChartProps = {
alert: ProjectGrowthData;
};
export const ProjectGrowthAlertChart = ({
alert,
}: ProjectGrowthAlertChartProps) => {
const classes = useStyles();
const costStart = alert.aggregation[0];
const costEnd = alert.aggregation[1];
const resourceData = alert.products.map(resourceOf);
const options: Partial<BarChartOptions> = {
previousName: moment(alert.periodStart, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
currentName: moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
};
return (
<Box className={classes.wrapper}>
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
<LegendItem title="Cost Growth">
<CostGrowth change={alert.change} duration={Duration.P3M} />
</LegendItem>
</BarChartLegend>
<BarChart resources={resourceData} options={options} />
</Box>
);
};
@@ -15,19 +15,22 @@
*/
import React from 'react';
import moment from 'moment';
import { Box, Typography } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import { AlertInstructionsLayout } from '../AlertInstructionsLayout';
import { ProductInsightsChart } from '../ProductInsightsCard';
import {
Alert,
DEFAULT_DATE_FORMAT,
Duration,
Entity,
Product,
ProjectGrowthData,
} from '../../types';
import { ProjectGrowthAlert } from '../../utils/alerts';
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
const today = moment().format(DEFAULT_DATE_FORMAT);
export const ProjectGrowthInstructionsPage = () => {
const alertData: ProjectGrowthData = {
@@ -54,6 +57,7 @@ export const ProjectGrowthInstructionsPage = () => {
},
],
};
const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData);
const product: Product = {
@@ -61,20 +65,34 @@ export const ProjectGrowthInstructionsPage = () => {
name: 'Compute Engine',
};
const entities: Entity[] = [
{
id: 'service-one',
aggregation: [18200, 58500],
const entity: Entity = {
id: 'example-id',
aggregation: [20_000, 60_000],
change: {
ratio: 3,
amount: 40_000,
},
{
id: 'service-two',
aggregation: [1200, 1300],
},
{
id: 'service-three',
aggregation: [600, 200],
},
];
entities: [
{
id: 'service-one',
aggregation: [18_200, 58_500],
entities: [],
change: { ratio: 2.21, amount: 40_300 },
},
{
id: 'service-two',
aggregation: [1200, 1300],
entities: [],
change: { ratio: 0.083, amount: 100 },
},
{
id: 'service-three',
aggregation: [600, 200],
entities: [],
change: { ratio: -0.666, amount: -400 },
},
],
};
return (
<AlertInstructionsLayout title="Investigating Growth">
@@ -153,27 +171,12 @@ export const ProjectGrowthInstructionsPage = () => {
) that has grown in cost:
</Typography>
<Box mt={2} mb={2}>
{/* ProductInsightsCard without API query / PeriodSelect */}
<InfoCard title={product.name} subheader="3 entities, sorted by cost">
<Box display="flex" flexDirection="column">
<Box paddingY={1}>
<ResourceGrowthBarChartLegend
duration={Duration.P3M}
change={{ ratio: 3, amount: 40000 }}
previousName="Q2 2020"
currentName="Q3 2020"
costStart={20000}
costEnd={60000}
/>
</Box>
<Box paddingY={1}>
<ResourceGrowthBarChart
resources={entities}
previousName="Q2 2020"
currentName="Q3 2020"
/>
</Box>
</Box>
<ProductInsightsChart
billingDate={today}
duration={Duration.P3M}
entity={entity}
/>
</InfoCard>
</Box>
<Typography paragraph>
@@ -1,41 +0,0 @@
/*
* 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 { ResourceGrowthBarChart } from './ResourceGrowthBarChart';
import { renderInTestApp } from '@backstage/test-utils';
import { createMockEntity } from '../../utils/mockData';
const MockResources = [...Array(10)].map((_, index) =>
createMockEntity(() => ({
id: `test-id-${index + 1}`,
// grow resource costs linearly for testing
aggregation: [index * 1000, (index + 1) * 1000],
})),
);
describe('<ResourceGrowthBarChart/>', () => {
it('Pre-renders without exploding', async () => {
const rendered = await renderInTestApp(
<ResourceGrowthBarChart
resources={MockResources}
previousName="Q2 2020"
currentName="Q3 2020"
/>,
);
expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument();
});
});
@@ -1,87 +0,0 @@
/*
* 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 { TooltipPayload } from 'recharts';
import { currencyFormatter } from '../../utils/formatters';
import {
AlertCost,
BarChartData,
CostInsightsTheme,
DataKey,
Entity,
Maybe,
ResourceData,
} from '../../types';
import { BarChart } from '../BarChart';
import { TooltipItemProps } from '../Tooltip';
import { useTheme } from '@material-ui/core';
export type ResourceGrowthBarChartProps = {
resources: Array<Entity | AlertCost>;
previousName: string;
currentName: string;
};
export const ResourceGrowthBarChart = ({
resources,
previousName,
currentName,
}: ResourceGrowthBarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const getTooltipItem = (payload: TooltipPayload): Maybe<TooltipItemProps> => {
const value =
typeof payload.value === 'number'
? currencyFormatter.format(payload.value)
: (payload.value as string);
const fill = payload.fill as string;
switch (payload.dataKey) {
case DataKey.Current:
case DataKey.Previous:
return {
label: payload.name,
value: value,
fill: fill,
};
default:
return null;
}
};
const barChartData: BarChartData = {
previousFill: theme.palette.lightBlue,
currentFill: theme.palette.darkBlue,
previousName: previousName,
currentName: currentName,
};
const resourceData: ResourceData[] = resources.map(resource => {
return {
name: resource.id,
previous: resource.aggregation[0],
current: resource.aggregation[1],
};
});
return (
<BarChart
barChartData={barChartData}
getTooltipItem={getTooltipItem}
resources={resourceData}
/>
);
};
@@ -1,17 +0,0 @@
/*
* 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.
*/
export { ResourceGrowthBarChart } from './ResourceGrowthBarChart';
@@ -1,59 +0,0 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
import { defaultCurrencies } from '../../utils/currency';
import { findAlways } from '../../utils/assert';
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
import { Duration } from '../../types';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const MockContext = ({ children }: PropsWithChildren<{}>) => (
<MockConfigProvider engineerCost={200_000}>
<MockCurrencyProvider currency={engineers}>{children}</MockCurrencyProvider>
</MockConfigProvider>
);
describe('<ResourceGrowthBarChartLegend />', () => {
describe.each`
ratio | amount | costText | engineerTest
${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/}
${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/}
`(
'Should display the cost text',
({ ratio, amount, costText, engineerTest }) => {
it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => {
const rendered = await renderInTestApp(
<MockContext>
<ResourceGrowthBarChartLegend
duration={Duration.P3M}
change={{ ratio, amount }}
previousName="Q2 2020"
currentName="Q3 2020"
costStart={1000}
costEnd={5000}
/>
</MockContext>,
);
expect(rendered.getByText(costText)).toBeInTheDocument();
expect(rendered.queryByText(engineerTest)).toBeInTheDocument();
});
},
);
});
@@ -1,17 +0,0 @@
/*
* 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.
*/
export { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
@@ -1,52 +0,0 @@
/*
* 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 { Box, Typography } from '@material-ui/core';
import { TooltipItem, TooltipItemProps } from './TooltipItem';
import { useTooltipStyles } from '../../utils/styles';
export type TooltipProps = {
label?: string;
items?: Array<TooltipItemProps>;
};
export const Tooltip = ({ label, items }: TooltipProps) => {
const classes = useTooltipStyles();
return (
<Box
className={`tooltip-content ${classes.tooltip}`}
display="flex"
flexDirection="column"
minWidth={250}
>
{label && (
<Typography display="block" gutterBottom>
<b>{label}</b>
</Typography>
)}
{items &&
items.map((item, index) => (
<TooltipItem
key={`${item.label}-${index}`}
fill={item.fill}
label={item.label}
value={item.value}
/>
))}
</Box>
);
};
@@ -1,20 +0,0 @@
/*
* 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.
*/
export { Tooltip } from './Tooltip';
export type { TooltipProps } from './Tooltip';
export { TooltipItem } from './TooltipItem';
export type { TooltipItemProps } from './TooltipItem';
@@ -15,12 +15,12 @@
*/
import React from 'react';
import { Box } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import { UnlabeledDataflowBarChart } from './UnlabeledDataflowBarChart';
import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend';
import { UnlabeledDataflowData } from '../../types';
import { Box } from '@material-ui/core';
import { BarChart, BarChartLegend } from '../BarChart';
import { UnlabeledDataflowData, ResourceData } from '../../types';
import { pluralOf } from '../../utils/grammar';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
type UnlabeledDataflowAlertProps = {
alert: UnlabeledDataflowData;
@@ -29,22 +29,31 @@ type UnlabeledDataflowAlertProps = {
export const UnlabeledDataflowAlertCard = ({
alert,
}: UnlabeledDataflowAlertProps) => {
const classes = useStyles();
const projects = pluralOf(alert.projects.length, 'project');
const subheader = `
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
`;
const options = {
previousName: 'Unlabeled Cost',
currentName: 'Labeled Cost',
};
const resources: ResourceData[] = alert.projects.map(project => ({
name: project.id,
previous: project.unlabeledCost,
current: project.labeledCost,
}));
return (
<InfoCard title="Label Dataflow" subheader={subheader}>
<Box display="flex" flexDirection="column">
<Box paddingY={1}>
<UnlabeledDataflowBarChartLegend
unlabeledCost={alert.unlabeledCost}
labeledCost={alert.labeledCost}
/>
</Box>
<Box paddingY={1}>
<UnlabeledDataflowBarChart projects={alert.projects} />
</Box>
<Box className={classes.wrapper}>
<BarChartLegend
costStart={alert.labeledCost}
costEnd={alert.unlabeledCost}
options={options}
/>
<BarChart resources={resources} options={options} />
</Box>
</InfoCard>
);
@@ -1,71 +0,0 @@
/*
* 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 { TooltipPayload } from 'recharts';
import { BarChart } from '../BarChart';
import { TooltipItemProps } from '../Tooltip';
import {
BarChartData,
CostInsightsTheme,
ResourceData,
UnlabeledDataflowAlertProject,
} from '../../types';
import { currencyFormatter } from '../../utils/formatters';
import { useTheme } from '@material-ui/core';
type UnlabeledDataflowBarChartProps = {
projects: Array<UnlabeledDataflowAlertProject>;
};
export const UnlabeledDataflowBarChart = ({
projects,
}: UnlabeledDataflowBarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const barChartData: BarChartData = {
previousFill: theme.palette.lightBlue,
currentFill: theme.palette.darkBlue,
previousName: 'Unlabeled Cost',
currentName: 'Labeled Cost',
};
const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => {
return {
label: payload.name,
value:
typeof payload.value === 'number'
? currencyFormatter.format(payload.value)
: (payload.value as string),
fill: payload.fill as string,
};
};
const resources: ResourceData[] = projects.map(project => {
return {
name: project.id,
previous: project.unlabeledCost || 0,
current: project.labeledCost || 0,
};
});
return (
<BarChart
barChartData={barChartData}
resources={resources}
getTooltipItem={getTooltipItem}
/>
);
};
@@ -1,57 +0,0 @@
/*
* 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 { Box, useTheme } from '@material-ui/core';
import { LegendItem } from '../LegendItem';
import { currencyFormatter } from '../../utils/formatters';
import { CostInsightsTheme } from '../../types';
type UnlabeledDataflowBarChartLegendProps = {
labeledCost: number;
unlabeledCost: number;
};
export const UnlabeledDataflowBarChartLegend = ({
unlabeledCost,
labeledCost,
}: UnlabeledDataflowBarChartLegendProps) => {
const theme = useTheme<CostInsightsTheme>();
return (
<Box
className="dataflow-label-chart-legend"
display="flex"
flexDirection="row"
>
<Box marginRight={2}>
<LegendItem
title="Total Unlabeled Cost"
markerColor={theme.palette.lightBlue}
>
{currencyFormatter.format(unlabeledCost)}
</LegendItem>
</Box>
<Box marginRight={2}>
<LegendItem
title="Total Labeled Cost"
markerColor={theme.palette.darkBlue}
>
{currencyFormatter.format(labeledCost)}
</LegendItem>
</Box>
</Box>
);
};
@@ -17,4 +17,3 @@
export * from './BarChart';
export * from './CostGrowth';
export * from './LegendItem';
export * from './Tooltip';
+4 -1
View File
@@ -41,13 +41,16 @@ export interface ResourceData {
name: Maybe<string>;
}
export interface BarChartData {
export interface BarChartOptions {
previousFill: string;
currentFill: string;
previousName: string;
currentName: string;
}
/** deprecated use BarChartOptions instead */
export interface BarChartData extends BarChartOptions {}
export enum DataKey {
Previous = 'previous',
Current = 'current',
+64 -1
View File
@@ -20,5 +20,68 @@ import { Maybe } from './Maybe';
export interface Entity {
id: Maybe<string>;
aggregation: [number, number];
change?: Maybe<ChangeStatistic>;
entities: Entity[];
change: ChangeStatistic;
}
/*
An entity is a tree-like structure that represents any unique
product or service that generates cost over a fixed period of time.
An entity could be atomic or composite. An atomic entity is indivisible
and cannot be broken into sub-entities.
A composite entity can be broken down recursively into sub-entities
that generate cost **over the same time period**. All costs must sum to the root cost.
Entities with null ids are considered "unlabeled" - costs without attribution.
If an entity is a composite, it may only have one (1) null child but may have any number of
null grandchildren.
{
id: 'product',
aggregation: [0, 200],
change: {
ratio: 2000,
amount: 200
},
entities: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: []
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: [
{
id: 'service-b-sku-a',
aggregation: [0, 25],
change: {
ratio: 25,
amount: 25
},
entities: []
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: []
},
]
},
]
}
*/
@@ -14,17 +14,7 @@
* limitations under the License.
*/
import { Entity } from './Entity';
import { ChangeStatistic } from './ChangeStatistic';
import { Maybe } from './Maybe';
export interface Product {
kind: string;
name: string;
}
export interface ProductCost {
entities?: Array<Entity>;
aggregation: [number, number];
change?: Maybe<ChangeStatistic>;
}
@@ -0,0 +1,62 @@
/*
* 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 { growthOf } from './change';
import { GrowthType, ChangeThreshold, EngineerThreshold } from '../types';
const GrowthMap = {
[GrowthType.Negligible]: 'negligible growth',
[GrowthType.Savings]: 'cost savings',
[GrowthType.Excess]: 'cost excess',
};
describe.each`
ratio | amount | expected
${0.0} | ${undefined} | ${GrowthType.Negligible}
${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible}
${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible}
${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible}
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible}
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings}
${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings}
${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings}
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings}
${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess}
${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess}
${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess}
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess}
`(
'growthOf',
({
ratio,
amount,
expected,
}: {
ratio: number;
amount: number;
expected: GrowthType;
}) => {
it(`should display ${GrowthMap[expected]}`, () => {
expect(growthOf(ratio, amount)).toBe(expected);
});
},
);
+12 -8
View File
@@ -24,14 +24,18 @@ import {
} from '../types';
import { aggregationSort } from '../utils/sort';
// Used by <CostGrowth /> for displaying status colors
export function growthOf(amount: number, ratio: number) {
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
return GrowthType.Excess;
}
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
return GrowthType.Savings;
// Used for displaying status colors
export function growthOf(ratio: number, amount?: number) {
if (typeof amount === 'number') {
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
return GrowthType.Excess;
}
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
return GrowthType.Savings;
}
} else {
if (ratio >= ChangeThreshold.upper) return GrowthType.Excess;
if (ratio <= ChangeThreshold.lower) return GrowthType.Savings;
}
return GrowthType.Negligible;
+79
View File
@@ -0,0 +1,79 @@
/*
* 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 { TooltipPayload, TooltipProps } from 'recharts';
import { AlertCost, DataKey, Entity, ResourceData } from '../types';
import {
currencyFormatter,
dateFormatter,
lengthyCurrencyFormatter,
} from './formatters';
export function formatGraphValue(value: number, format?: string) {
if (format === 'number') {
return value.toLocaleString();
}
if (value < 1) {
return lengthyCurrencyFormatter.format(value);
}
return currencyFormatter.format(value);
}
export const overviewGraphTickFormatter = (millis: string | number) =>
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
export const tooltipItemOf = (payload: TooltipPayload) => {
const value =
typeof payload.value === 'number'
? currencyFormatter.format(payload.value)
: (payload.value as string);
const fill = payload.fill as string;
switch (payload.dataKey) {
case DataKey.Current:
case DataKey.Previous:
return {
label: payload.name,
value: value,
fill: fill,
};
default:
return null;
}
};
export const resourceOf = (entity: Entity | AlertCost): ResourceData => ({
name: entity.id,
previous: entity.aggregation[0],
current: entity.aggregation[1],
});
export const titleOf = (label?: string | number) => {
return label ? String(label) : 'Unlabeled';
};
export const isInvalid = ({ label, payload }: TooltipProps) => {
// null labels are empty strings, which are valid
return label === undefined || !payload || !payload.length;
};
export const isActiveLabel = (
data?: Record<'activeLabel', string | undefined>,
) => {
return data?.activeLabel && data.activeLabel !== '';
};
@@ -1,36 +0,0 @@
/*
* 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 {
currencyFormatter,
dateFormatter,
lengthyCurrencyFormatter,
} from './formatters';
export function formatGraphValue(value: number, format?: string) {
if (format === 'number') {
return value.toLocaleString();
}
if (value < 1) {
return lengthyCurrencyFormatter.format(value);
}
return currencyFormatter.format(value);
}
export const overviewGraphTickFormatter = (millis: string | number) =>
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
+5 -14
View File
@@ -20,7 +20,6 @@ import {
Duration,
Entity,
Product,
ProductCost,
ProductFilters,
ProjectGrowthData,
UnlabeledDataflowAlertProject,
@@ -41,6 +40,11 @@ export const createMockEntity = (
const defaultEntity: Entity = {
id: 'test-entity',
aggregation: [100, 200],
entities: [],
change: {
ratio: 0,
amount: 0,
},
};
if (typeof callback === 'function') {
@@ -62,19 +66,6 @@ export const createMockProduct = (
return { ...defaultProduct };
};
export const createMockProductCost = (
callback?: mockEntityRenderer<ProductCost>,
): ProductCost => {
const defaultProduct: ProductCost = {
entities: [],
aggregation: [0, 0],
};
if (typeof callback === 'function') {
return callback({ ...defaultProduct });
}
return { ...defaultProduct };
};
export const createMockProjectGrowthData = (
callback?: mockAlertRenderer<ProjectGrowthData>,
): ProjectGrowthData => {
+1
View File
@@ -19,5 +19,6 @@ export const aggregationSort = (
a: DateAggregation,
b: DateAggregation,
): number => a.date.localeCompare(b.date);
export const resourceSort = (a: ResourceData, b: ResourceData) =>
b.previous + b.current - (a.previous + a.current);
+96 -2
View File
@@ -15,6 +15,7 @@
*/
import {
createStyles,
emphasize,
darken,
getLuminance,
lighten,
@@ -116,6 +117,18 @@ export const useBarChartStyles = (theme: CostInsightsTheme) => ({
},
});
export const useBarChartLayoutStyles = makeStyles<BackstageTheme>(theme =>
createStyles({
wrapper: {
display: 'flex',
flexDirection: 'column',
},
legend: {
paddingBottom: theme.spacing(2),
},
}),
);
export const useBarChartStepperButtonStyles = makeStyles<CostInsightsTheme>(
(theme: CostInsightsTheme) =>
createStyles({
@@ -146,12 +159,13 @@ export const useBarChartStepperButtonStyles = makeStyles<CostInsightsTheme>(
}),
);
export const useBarChartLabelStyles = makeStyles(() =>
export const useBarChartLabelStyles = makeStyles<BackstageTheme>(theme =>
createStyles({
foreignObject: {
textAlign: 'center',
},
label: {
fontWeight: theme.typography.fontWeightBold,
display: 'block',
textDecoration: 'none',
overflow: 'hidden',
@@ -162,6 +176,11 @@ export const useBarChartLabelStyles = makeStyles(() =>
marginLeft: 2,
fontSize: '1.25em',
},
button: {
textTransform: 'none',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.fontSize,
},
}),
);
@@ -244,6 +263,12 @@ export const useCostGrowthStyles = makeStyles<BackstageTheme>(
savings: {
color: theme.palette.status.ok,
},
indicator: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
}),
);
@@ -356,16 +381,31 @@ export const useTooltipStyles = makeStyles<CostInsightsTheme>(
(theme: CostInsightsTheme) =>
createStyles({
tooltip: {
padding: theme.spacing(1.5),
backgroundColor: theme.palette.tooltip.background,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[1],
color: theme.palette.tooltip.color,
fontSize: theme.typography.fontSize,
width: 250,
},
actions: {
padding: theme.spacing(2),
},
header: {
padding: theme.spacing(2),
},
content: {
padding: theme.spacing(2),
},
lensIcon: {
fontSize: `.75rem`,
},
divider: {
backgroundColor: emphasize(theme.palette.divider, 1),
},
subtitle: {
fontStyle: 'italic',
},
}),
);
@@ -446,6 +486,21 @@ export const useProductInsightsCardStyles = makeStyles<BackstageTheme>(
}),
);
export const useProductInsightsChartStyles = makeStyles<BackstageTheme>(
(theme: BackstageTheme) =>
createStyles({
indicator: {
fontWeight: theme.typography.fontWeightBold,
fontSize: '1.25rem',
},
actions: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
}),
);
export const useBackdropStyles = makeStyles<BackstageTheme>(
(theme: BackstageTheme) =>
createStyles({
@@ -463,3 +518,42 @@ export const useSubtleTypographyStyles = makeStyles<BackstageTheme>(
},
}),
);
export const useEntityDialogStyles = makeStyles<BackstageTheme>(theme =>
createStyles({
dialogContent: {
padding: 0,
},
dialogTitle: {
padding: 0,
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
zIndex: 100,
},
row: {
fontSize: theme.typography.fontSize * 1.25,
},
rowTotal: {
fontWeight: theme.typography.fontWeightBold,
},
colFirst: {
paddingLeft: theme.spacing(2),
},
colLast: {
paddingRight: theme.spacing(2),
},
column: {
fontWeight: theme.typography.fontWeightBold,
},
growth: {
display: 'flex',
flexDirection: 'row',
alignContent: 'center',
justifyContent: 'flex-end',
},
}),
);