Merge pull request #3576 from backstage/cost-insights-entity-breakdowns

[Cost Insights] Add support for multiple types of entity cost breakdown
This commit is contained in:
Tim
2020-12-07 09:49:24 -07:00
committed by GitHub
19 changed files with 912 additions and 627 deletions
@@ -0,0 +1,7 @@
---
'@backstage/plugin-cost-insights': minor
---
Add support for multiple types of entity cost breakdown.
This change is backwards-incompatible with plugin-cost-insights 0.3.x; the `entities` field on Entity returned in product cost queries changed from `Entity[]` to `Record<string, Entity[]`.
+2
View File
@@ -36,6 +36,7 @@
"dayjs": "^1.9.4",
"history": "^5.0.0",
"moment": "^2.27.0",
"pluralize": "^8.0.0",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -54,6 +55,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/pluralize": "^0.0.29",
"@types/recharts": "^1.8.14",
"@types/regression": "^2.0.0",
"@types/yup": "^0.29.8",
@@ -139,6 +139,7 @@ export type CostInsightsApi = {
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
*/
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
@@ -40,7 +40,7 @@ const MockComputeEngine: Product = {
const MockComputeEngineInsights: Entity = {
id: 'compute-engine',
entities: [],
entities: {},
aggregation: [0, 0],
change: {
ratio: 0,
@@ -55,7 +55,7 @@ const MockCloudDataflow: Product = {
const MockCloudDataflowInsights: Entity = {
id: MockCloudDataflow.kind,
entities: [],
entities: {},
aggregation: [1_000, 2_000],
change: {
ratio: 1,
@@ -70,7 +70,7 @@ const MockCloudStorage: Product = {
const MockCloudStorageInsights: Entity = {
id: MockCloudStorage.kind,
entities: [],
entities: {},
aggregation: [2_000, 4_000],
change: {
ratio: 1,
@@ -85,7 +85,7 @@ const MockBigQuery: Product = {
const MockBigQueryInsights: Entity = {
id: MockBigQuery.kind,
entities: [],
entities: {},
aggregation: [8_000, 16_000],
change: {
ratio: 1,
@@ -100,7 +100,7 @@ const MockBigTable: Product = {
const MockBigTableInsights: Entity = {
id: MockBigTable.kind,
entities: [],
entities: {},
aggregation: [16_000, 32_000],
change: {
ratio: 1,
@@ -115,7 +115,7 @@ const MockCloudPubSub: Product = {
const MockCloudPubSubInsights: Entity = {
id: MockCloudPubSub.kind,
entities: [],
entities: {},
aggregation: [32_000, 64_000],
change: {
ratio: 1,
@@ -0,0 +1,113 @@
/*
* 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 { wrapInTestApp } from '@backstage/test-utils';
import { ProductEntityDialog } from './ProductEntityDialog';
import { render } from '@testing-library/react';
import { Entity } from '../../types';
const atomicEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
};
const singleBreakdownEntity = {
...atomicEntity,
entities: {
SKU: [
{
id: 'sku-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'sku-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
const multiBreakdownEntity = {
...singleBreakdownEntity,
entities: {
...singleBreakdownEntity.entities,
deployment: [
{
id: 'd-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'd-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
describe('<ProductEntityDialog/>', () => {
it('Should error if no sub-entities exist', () => {
expect(() =>
render(
wrapInTestApp(
<ProductEntityDialog
open
entity={atomicEntity}
onClose={jest.fn()}
/>,
),
),
).toThrow();
});
it('Should show a tab for a single sub-entity type', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={singleBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
});
it('Should show tabs when multiple sub-entity types exist', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={multiBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
expect(getByText('Breakdown by deployment')).toBeInTheDocument();
expect(getByText('sku-1')).toBeInTheDocument();
});
});
@@ -14,179 +14,55 @@
* 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 React, { useState } from 'react';
import { HeaderTabs } from '@backstage/core';
import { Dialog, IconButton } from '@material-ui/core';
import { default as CloseButton } from '@material-ui/icons/Close';
import { CostGrowthIndicator } from '../CostGrowth';
import { costFormatter, 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 === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.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.label}</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 === 'label') return a.label.localeCompare(b.label);
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;
label: string;
previous: number;
current: number;
ratio: number;
};
type ProductEntityDialogOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
import { Entity } from '../../types';
import {
ProductEntityTable,
ProductEntityTableOptions,
} from './ProductEntityTable';
import { findAlways } from '../../utils/assert';
type ProductEntityDialogProps = {
open: boolean;
entity?: Entity;
entitiesLabel: string;
options?: ProductEntityDialogOptions;
entity: Entity;
options?: ProductEntityTableOptions;
onClose: () => void;
};
export const ProductEntityDialog = ({
open,
entity = defaultEntity,
entitiesLabel,
entity,
options = {},
onClose,
}: ProductEntityDialogProps) => {
const classes = useStyles();
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
const labels = Object.keys(entity.entities);
const [selectedLabel, setSelectedLabel] = useState(
findAlways(labels, _ => true),
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'label',
title: (
<Typography className={firstColClasses}>{entitiesLabel}</Typography>
),
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
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',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
const tabs = labels.map((label, index) => ({
id: index.toString(),
label: `Breakdown by ${label}`,
}));
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 || 'Unlabeled'}
subtitle="Resource breakdown"
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
<HeaderTabs
tabs={tabs}
onChange={index => setSelectedLabel(labels[index])}
/>
<ProductEntityTable
entityLabel={selectedLabel}
entity={entity}
options={options}
/>
</Dialog>
);
@@ -0,0 +1,174 @@
/*
* 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 { Typography } from '@material-ui/core';
import { costFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { CostGrowthIndicator } from '../CostGrowth';
import { BarChartOptions, Entity } from '../../types';
export type ProductEntityTableOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
type RowData = {
id: string;
label: string;
previous: number;
current: number;
ratio: number;
};
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 === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.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.label}</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 === 'label') return a.label.localeCompare(b.label);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
type ProductEntityTableProps = {
entityLabel: string;
entity: Entity;
options: ProductEntityTableOptions;
};
export const ProductEntityTable = ({
entityLabel,
entity,
options,
}: ProductEntityTableProps) => {
const classes = useStyles();
const entities = entity.entities[entityLabel];
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: 'label',
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
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}>Change</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entities
.map(e => ({
id: e.id || 'Unknown',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
return (
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unlabeled'}
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
/>
);
};
@@ -42,7 +42,7 @@ const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
const mockProductCost = createMockEntity(() => ({
id: 'test-id',
entities: [],
entities: {},
aggregation: [3000, 4000],
change: {
ratio: 0.23,
@@ -91,21 +91,21 @@ describe('<ProductInsightsCard/>', () => {
it('Should render the right subheader for products with cost data', async () => {
const entity = {
...mockProductCost,
entities: [...Array(1000)].map(createMockEntity),
entities: { entity: [...Array(1000)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
MockComputeEngine,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
expect(
rendered.getByText(/1000 entities, sorted by cost/),
).toBeInTheDocument();
});
it('Should render the right subheader if there is no cost data or change data', async () => {
const entity: Entity = {
id: 'test-id',
entities: [],
entities: {},
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
};
@@ -135,7 +135,7 @@ describe('<ProductInsightsCard/>', () => {
it(`Should display the correct relative time for ${duration}`, async () => {
const entity = {
...mockProductCost,
entities: [...Array(3)].map(createMockEntity),
entities: { entity: [...Array(3)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
@@ -21,6 +21,7 @@ import React, {
useRef,
useState,
} from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { default as Alert } from '@material-ui/lab/Alert';
@@ -30,12 +31,12 @@ import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { DefaultLoadingAction } from '../../utils/loading';
import { Duration, Entity, Maybe, Product } from '../../types';
import {
useLastCompleteBillingDate,
useScroll,
useLoading,
MapLoadingToProps,
useLastCompleteBillingDate,
useLoading,
useScroll,
} from '../../hooks';
import { pluralOf } from '../../utils/grammar';
import { findAnyKey } from '../../utils/assert';
type LoadingProps = (isLoading: boolean) => void;
@@ -91,13 +92,12 @@ export const ProductInsightsCard = ({
}
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
const entities = entity?.entities ?? [];
const subheader = entities.length
? `${entities.length} ${pluralOf(
entities.length,
'entity',
'entities',
)}, sorted by cost`
// Only a single entities Record for the root product entity is supported
const entityKey = findAnyKey(entity?.entities);
const entities = entityKey ? entity!.entities[entityKey] : [];
const subheader = entityKey
? `${pluralize(entityKey, entities.length, true)}, sorted by cost`
: null;
const headerProps = {
classes: classes,
@@ -20,6 +20,7 @@ import {
TooltipProps as RechartsTooltipProps,
RechartsFunction,
} from 'recharts';
import pluralize from 'pluralize';
import { Box, Typography } from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import { LegendItem } from '../LegendItem';
@@ -32,8 +33,13 @@ import {
BarChartTooltipItem,
BarChartLegendOptions,
} from '../BarChart';
import { pluralOf } from '../../utils/grammar';
import { findAlways, notEmpty, isUndefined } from '../../utils/assert';
import {
findAlways,
notEmpty,
isUndefined,
findAnyKey,
assertAlways,
} from '../../utils/assert';
import { formatPeriod, formatPercent } from '../../utils/formatters';
import {
titleOf,
@@ -62,19 +68,26 @@ export const ProductInsightsChart = ({
}: ProductInsightsChartProps) => {
const classes = useStyles();
const layoutClasses = useLayoutStyles();
// Only a single entities Record for the root product entity is supported
const entityLabel = assertAlways(findAnyKey(entity.entities));
const entities = entity.entities[entityLabel] ?? [];
const [activeLabel, setActive] = useState<Maybe<string>>();
const [selectLabel, setSelected] = useState<Maybe<string>>();
const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]);
const isClickable = useMemo(() => {
const breakdownEntities =
entity.entities.find(e => e.id === activeLabel)?.entities ?? [];
return breakdownEntities.length > 0;
}, [entity, activeLabel]);
const breakdowns = Object.keys(
entities.find(e => e.id === activeLabel)?.entities ?? {},
);
return breakdowns.length > 0;
}, [entities, activeLabel]);
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 resources = entities.map(resourceOf);
const options: Partial<BarChartLegendOptions> = {
previousName: formatPeriod(duration, billingDate, false),
@@ -120,15 +133,14 @@ export const ProductInsightsChart = ({
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
const activeEntity = findAlways(entity.entities, e => e.id === id);
const activeEntity = findAlways(entities, e => e.id === id);
const ratio = activeEntity.change.ratio;
const breakdownEntities = activeEntity.entities;
const subtitle = `${breakdownEntities.length} ${pluralOf(
breakdownEntities.length,
entity.entitiesLabel || 'SKU',
)}`;
const breakdowns = Object.keys(activeEntity.entities);
if (breakdownEntities.length) {
if (breakdowns.length) {
const subtitle = breakdowns
.map(b => pluralize(b, activeEntity.entities[b].length, true))
.join(', ');
return (
<BarChartTooltip
title={title}
@@ -194,13 +206,12 @@ export const ProductInsightsChart = ({
options={options}
{...barChartProps}
/>
{isSelected && entity.entities.length && (
{isSelected && entities.length && (
<ProductEntityDialog
open={isSelected}
onClose={() => setSelected(undefined)}
entity={entity.entities.find(e => e.id === selectLabel)}
entity={findAlways(entities, e => e.id === selectLabel)}
options={options}
entitiesLabel={entity.entitiesLabel || 'SKU'}
/>
)}
</Box>
@@ -15,10 +15,10 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
import { ProjectGrowthData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
alert: ProjectGrowthData;
@@ -26,7 +26,7 @@ type ProjectGrowthAlertProps = {
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
const subheader = `
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
${pluralize('product', alert.products.length, true)}${
alert.products.length > 1 ? ', sorted by cost' : ''
}`;
@@ -72,26 +72,28 @@ export const ProjectGrowthInstructionsPage = () => {
ratio: 3,
amount: 40_000,
},
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 },
},
],
entities: {
service: [
{
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 (
@@ -15,11 +15,11 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
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 = {
@@ -30,9 +30,9 @@ export const UnlabeledDataflowAlertCard = ({
alert,
}: UnlabeledDataflowAlertProps) => {
const classes = useStyles();
const projects = pluralOf(alert.projects.length, 'project');
const projects = pluralize('project', alert.projects.length, true);
const subheader = `
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days.
`;
const options = {
previousName: 'Unlabeled Cost',
+71 -41
View File
@@ -20,9 +20,8 @@ import { Maybe } from './Maybe';
export interface Entity {
id: Maybe<string>;
aggregation: [number, number];
entities: Entity[];
entities: Record<string, Entity[]>;
change: ChangeStatistic;
entitiesLabel?: string;
}
/*
@@ -31,8 +30,15 @@ export interface Entity {
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.
A composite entity is divided into sub-entities that account for portions
of the total cost **over the same time period**. The root entity is
expected to only have _one_ Record consisting of the sub-entities to display
in the product panel (keyed by the entity type, such as "service" for
compute entities).
The root sub-entities may have multiple breakdowns - for example, a
breakdown of an entity cost by SKU vs deployment environment. The sum
aggregated cost of each keyed breakdown should equal the sub-entity's 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
@@ -45,44 +51,68 @@ export interface Entity {
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: []
entities: {
service: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: []
entities: {}
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
]
},
]
entities: {
SKU: [
{
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: {}
},
],
deployment: [
{
id: 'service-b-env-a',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
{
id: 'service-b-env-b',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
]
}
},
]
}
}
*/
@@ -50,3 +50,9 @@ export function findAlways<T>(
): T {
return assertAlways(collection.find(callback));
}
export function findAnyKey<T>(
record: Record<string, T> | undefined,
): string | undefined {
return Object.keys(record ?? {}).find(_ => true);
}
@@ -15,9 +15,9 @@
*/
import moment from 'moment';
import pluralize from 'pluralize';
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
import { pluralOf } from '../utils/grammar';
export type Period = {
periodStart: string;
@@ -75,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string {
const n = Math.round(amount);
const numString = numberFormatter.format(n);
return currency ? `${numString} ${pluralOf(n, currency)}` : numString;
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
}
export function formatPercent(n: number): string {
@@ -22,20 +22,6 @@ const vowels = {
u: 'U',
};
export const pluralOf = (
n: number,
string: string,
plural?: string,
): string => {
if (n !== 1) {
if (plural) {
return plural;
}
return string.concat('s');
}
return string;
};
export const indefiniteArticleOf = (
articles: [string, string],
word: string,
+420 -353
View File
@@ -52,7 +52,7 @@ export const createMockEntity = (
const defaultEntity: Entity = {
id: 'test-entity',
aggregation: [100, 200],
entities: [],
entities: {},
change: {
ratio: 0,
amount: 0,
@@ -517,35 +517,37 @@ export const SampleBigQueryInsights: Entity = {
ratio: 3,
amount: 20_000,
},
entities: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
entities: {
dataset: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudDataflowInsights: Entity = {
@@ -555,110 +557,118 @@ export const SampleCloudDataflowInsights: Entity = {
ratio: 0.58,
amount: 58_000,
},
entities: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
entities: {
pipeline: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: {},
},
],
},
],
},
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudStorageInsights: Entity = {
@@ -668,91 +678,97 @@ export const SampleCloudStorageInsights: Entity = {
ratio: 0,
amount: 0,
},
entities: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
entities: {
bucket: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleComputeEngineInsights: Entity = {
@@ -762,91 +778,137 @@ export const SampleComputeEngineInsights: Entity = {
ratio: 0.125,
amount: 10_000,
},
entities: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
entities: {
service: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleEventsInsights: Entity = {
@@ -856,83 +918,88 @@ export const SampleEventsInsights: Entity = {
ratio: -0.5,
amount: -10_000,
},
entitiesLabel: 'Product',
entities: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
entities: {
event: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
},
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: [],
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: {},
},
],
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
entities: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: [],
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: [],
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: [],
},
],
},
],
],
},
};
export function entityOf(product: string): Entity {
+10
View File
@@ -5698,6 +5698,11 @@
dependencies:
"@types/express" "*"
"@types/pluralize@^0.0.29":
version "0.0.29"
resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c"
integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==
"@types/prettier@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4"
@@ -18867,6 +18872,11 @@ please-upgrade-node@^3.2.0:
dependencies:
semver-compare "^1.0.0"
pluralize@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
pn@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"