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:
@@ -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[]`.
|
||||
@@ -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,
|
||||
|
||||
+113
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -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>
|
||||
|
||||
+2
-2
@@ -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' : ''
|
||||
}`;
|
||||
|
||||
|
||||
+22
-20
@@ -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 (
|
||||
|
||||
+3
-3
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user