stack rank product menu items; add mock data
This commit is contained in:
@@ -35,7 +35,7 @@ import {
|
||||
UnlabeledDataflowAlert,
|
||||
} from '../src/utils/alerts';
|
||||
import { inclusiveStartDateOf } from '../src/utils/duration';
|
||||
import { trendlineOf, changeOf } from './utils/mockData';
|
||||
import { trendlineOf, changeOf, entityOf } from './utils/mockData';
|
||||
|
||||
type IntervalFields = {
|
||||
duration: Duration;
|
||||
@@ -161,170 +161,18 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
return projectDailyCost;
|
||||
}
|
||||
|
||||
async getProductInsights(
|
||||
productInsightsOptions: ProductInsightsOptions,
|
||||
): Promise<Entity> {
|
||||
const projectProductInsights = await this.request(productInsightsOptions, {
|
||||
id: productInsightsOptions.product,
|
||||
aggregation: [80_000, 110_000],
|
||||
change: {
|
||||
ratio: 0.375,
|
||||
amount: 30_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
async getProductInsights(options: ProductInsightsOptions): Promise<Entity> {
|
||||
const projectProductInsights = await this.request(
|
||||
options,
|
||||
entityOf(options.product),
|
||||
);
|
||||
|
||||
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: Entity = await this.request(
|
||||
options,
|
||||
entityOf(options.product),
|
||||
);
|
||||
|
||||
return productInsightsOptions.project
|
||||
? projectProductInsights
|
||||
: productInsights;
|
||||
return options.project ? projectProductInsights : productInsights;
|
||||
}
|
||||
|
||||
async getAlerts(group: string): Promise<Alert[]> {
|
||||
@@ -332,14 +180,14 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
project: 'example-project',
|
||||
periodStart: '2020-Q2',
|
||||
periodEnd: '2020-Q3',
|
||||
aggregation: [100, 120_000],
|
||||
aggregation: [60_000, 120_000],
|
||||
change: {
|
||||
ratio: 10.123123,
|
||||
amount: 60000,
|
||||
ratio: 1,
|
||||
amount: 60_000,
|
||||
},
|
||||
products: [
|
||||
{ id: 'Compute Engine', aggregation: [58_000, 118_000] },
|
||||
{ id: 'Cloud Dataflow', aggregation: [0, 15_000] },
|
||||
{ id: 'Cloud Dataflow', aggregation: [1200, 1500] },
|
||||
{ id: 'Cloud Storage', aggregation: [800, 500] },
|
||||
],
|
||||
};
|
||||
|
||||
+7
-3
@@ -46,7 +46,7 @@ const renderWrapped = (children: React.ReactNode) =>
|
||||
describe('<CostInsightsNavigation />', () => {
|
||||
it('should render each navigation item', async () => {
|
||||
const { getByText } = await renderWrapped(
|
||||
<CostInsightsNavigation alerts={3} />,
|
||||
<CostInsightsNavigation products={mockProducts} alerts={3} />,
|
||||
);
|
||||
getDefaultNavigationItems(3)
|
||||
.map(item => item.title)
|
||||
@@ -55,12 +55,16 @@ describe('<CostInsightsNavigation />', () => {
|
||||
});
|
||||
|
||||
it('should not display action items navigation if there are no action items', async () => {
|
||||
const rendered = await renderWrapped(<CostInsightsNavigation alerts={0} />);
|
||||
const rendered = await renderWrapped(
|
||||
<CostInsightsNavigation products={mockProducts} alerts={0} />,
|
||||
);
|
||||
expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the correct amount of action items in the badge', async () => {
|
||||
const rendered = await renderWrapped(<CostInsightsNavigation alerts={3} />);
|
||||
const rendered = await renderWrapped(
|
||||
<CostInsightsNavigation products={mockProducts} alerts={3} />,
|
||||
);
|
||||
expect(rendered.getByText(/3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+64
-39
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Collapse,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
Typography,
|
||||
Badge,
|
||||
} from '@material-ui/core';
|
||||
import { useNavigationStyles } from '../../utils/styles';
|
||||
import { useNavigationStyles as useStyles } from '../../utils/styles';
|
||||
import { useConfig, useScroll } from '../../hooks';
|
||||
import { findAlways } from '../../utils/assert';
|
||||
import {
|
||||
@@ -31,55 +32,79 @@ import {
|
||||
NavigationItem,
|
||||
getDefaultNavigationItems,
|
||||
} from '../../utils/navigation';
|
||||
import { Maybe, Product } from '../../types';
|
||||
|
||||
type CostInsightsNavigationProps = {
|
||||
alerts: number;
|
||||
products: Maybe<Product[]>;
|
||||
};
|
||||
|
||||
export const CostInsightsNavigation = ({
|
||||
alerts,
|
||||
}: CostInsightsNavigationProps) => {
|
||||
const classes = useNavigationStyles();
|
||||
const { products, icons } = useConfig();
|
||||
export const CostInsightsNavigation = React.memo(
|
||||
({ alerts, products }: CostInsightsNavigationProps) => {
|
||||
const classes = useStyles();
|
||||
const { icons } = useConfig();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const productNavigationItems: NavigationItem[] = products.map(product => ({
|
||||
navigation: product.kind,
|
||||
icon: findAlways(icons, i => i.kind === product.kind).component,
|
||||
title: product.name,
|
||||
}));
|
||||
const defaultNavigationItems = getDefaultNavigationItems(alerts);
|
||||
const productNavigationItems: NavigationItem[] =
|
||||
products?.map(product => ({
|
||||
title: product.name,
|
||||
navigation: product.kind,
|
||||
icon: findAlways(icons, i => i.kind === product.kind).component,
|
||||
})) ?? [];
|
||||
|
||||
const navigationItems = getDefaultNavigationItems(alerts).concat(
|
||||
productNavigationItems,
|
||||
);
|
||||
useEffect(
|
||||
function toggleProductMenuItems() {
|
||||
if (products?.length) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[products],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuList className={classes.menuList}>
|
||||
{navigationItems.map((item: NavigationItem) => (
|
||||
<NavigationMenuItem
|
||||
key={`navigation-menu-item-${item.navigation}`}
|
||||
navigation={item.navigation}
|
||||
icon={
|
||||
item.navigation === DefaultNavigation.AlertInsightsHeader ? (
|
||||
<Badge badgeContent={alerts} color="secondary">
|
||||
{React.cloneElement(item.icon, {
|
||||
return (
|
||||
<MenuList className={classes.menuList}>
|
||||
{defaultNavigationItems.map(item => (
|
||||
<NavigationMenuItem
|
||||
key={`navigation-menu-item-${item.navigation}`}
|
||||
navigation={item.navigation}
|
||||
title={item.title}
|
||||
icon={
|
||||
item.navigation === DefaultNavigation.AlertInsightsHeader ? (
|
||||
<Badge badgeContent={alerts} color="secondary">
|
||||
{React.cloneElement(item.icon, {
|
||||
className: classes.navigationIcon,
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
React.cloneElement(item.icon, {
|
||||
className: classes.navigationIcon,
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
React.cloneElement(item.icon, {
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Collapse in={isOpen} timeout={850}>
|
||||
{productNavigationItems.map((item: NavigationItem) => (
|
||||
<NavigationMenuItem
|
||||
key={`navigation-menu-item-${item.navigation}`}
|
||||
navigation={item.navigation}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
className: classes.navigationIcon,
|
||||
})
|
||||
)
|
||||
}
|
||||
title={item.title}
|
||||
/>
|
||||
))}
|
||||
</MenuList>
|
||||
);
|
||||
};
|
||||
})}
|
||||
title={item.title}
|
||||
/>
|
||||
))}
|
||||
</Collapse>
|
||||
</MenuList>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => {
|
||||
const classes = useNavigationStyles();
|
||||
const classes = useStyles();
|
||||
const { scrollIntoView } = useScroll(navigation);
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
useLastCompleteBillingDate,
|
||||
useLoading,
|
||||
} from '../../hooks';
|
||||
import { Alert, Cost, Maybe, MetricData, Project } from '../../types';
|
||||
import { Alert, Cost, Maybe, MetricData, Product, Project } from '../../types';
|
||||
import { mapLoadingToProps } from './selector';
|
||||
import { ProjectSelect } from '../ProjectSelect';
|
||||
import { intervalsOf } from '../../utils/duration';
|
||||
@@ -55,6 +55,7 @@ export const CostInsightsPage = () => {
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const [currency, setCurrency] = useCurrency();
|
||||
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
|
||||
const [products, setProducts] = useState<Maybe<Product[]>>(null);
|
||||
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
|
||||
const [metricData, setMetricData] = useState<Maybe<MetricData>>(null);
|
||||
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
|
||||
@@ -224,7 +225,10 @@ export const CostInsightsPage = () => {
|
||||
<Grid container wrap="nowrap">
|
||||
<Grid item>
|
||||
<Box position="sticky" top={20}>
|
||||
<CostInsightsNavigation alerts={alerts.length} />
|
||||
<CostInsightsNavigation
|
||||
products={products}
|
||||
alerts={alerts.length}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
@@ -281,7 +285,12 @@ export const CostInsightsPage = () => {
|
||||
{!alerts.length && <Divider />}
|
||||
<Grid item xs>
|
||||
<Box px={3} py={6}>
|
||||
<ProductInsights />
|
||||
<ProductInsights
|
||||
group={pageFilters.group}
|
||||
project={pageFilters.project}
|
||||
products={config.products}
|
||||
onLoaded={setProducts}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
+86
-14
@@ -16,9 +16,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ProductInsightsCardList } from './ProductInsightsCardList';
|
||||
import { ProductInsights } from './ProductInsights';
|
||||
import { ProductInsightsOptions } from '../../api';
|
||||
import { mockDefaultLoadingState, MockProducts } from '../../utils/mockData';
|
||||
import { mockDefaultLoadingState } from '../../utils/mockData';
|
||||
import {
|
||||
MockConfigProvider,
|
||||
MockCostInsightsApiProvider,
|
||||
@@ -28,11 +28,16 @@ import {
|
||||
MockScrollProvider,
|
||||
MockLoadingProvider,
|
||||
} from '../../utils/tests';
|
||||
import { Entity } from '../../types';
|
||||
import { Entity, Product } from '../../types';
|
||||
|
||||
// suppress recharts componentDidUpdate warnings
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const MockComputeEngine: Product = {
|
||||
kind: 'compute-engine',
|
||||
name: 'Compute Engine',
|
||||
};
|
||||
|
||||
const MockComputeEngineInsights: Entity = {
|
||||
id: 'compute-engine',
|
||||
entities: [],
|
||||
@@ -43,8 +48,13 @@ const MockComputeEngineInsights: Entity = {
|
||||
},
|
||||
};
|
||||
|
||||
const MockCloudDataflow: Product = {
|
||||
kind: 'cloud-dataflow',
|
||||
name: 'Cloud Dataflow',
|
||||
};
|
||||
|
||||
const MockCloudDataflowInsights: Entity = {
|
||||
id: 'cloud-dataflow',
|
||||
id: MockCloudDataflow.kind,
|
||||
entities: [],
|
||||
aggregation: [1_000, 2_000],
|
||||
change: {
|
||||
@@ -53,8 +63,13 @@ const MockCloudDataflowInsights: Entity = {
|
||||
},
|
||||
};
|
||||
|
||||
const MockCloudStorage: Product = {
|
||||
kind: 'cloud-storage',
|
||||
name: 'Cloud Storage',
|
||||
};
|
||||
|
||||
const MockCloudStorageInsights: Entity = {
|
||||
id: 'cloud-storage',
|
||||
id: MockCloudStorage.kind,
|
||||
entities: [],
|
||||
aggregation: [2_000, 4_000],
|
||||
change: {
|
||||
@@ -63,8 +78,13 @@ const MockCloudStorageInsights: Entity = {
|
||||
},
|
||||
};
|
||||
|
||||
const MockBigQuery: Product = {
|
||||
kind: 'big-query',
|
||||
name: 'BigQuery',
|
||||
};
|
||||
|
||||
const MockBigQueryInsights: Entity = {
|
||||
id: 'big-query',
|
||||
id: MockBigQuery.kind,
|
||||
entities: [],
|
||||
aggregation: [8_000, 16_000],
|
||||
change: {
|
||||
@@ -73,8 +93,13 @@ const MockBigQueryInsights: Entity = {
|
||||
},
|
||||
};
|
||||
|
||||
const MockBigTable: Product = {
|
||||
kind: 'big-table',
|
||||
name: 'BigTable',
|
||||
};
|
||||
|
||||
const MockBigTableInsights: Entity = {
|
||||
id: 'big-table',
|
||||
id: MockBigTable.kind,
|
||||
entities: [],
|
||||
aggregation: [16_000, 32_000],
|
||||
change: {
|
||||
@@ -83,8 +108,13 @@ const MockBigTableInsights: Entity = {
|
||||
},
|
||||
};
|
||||
|
||||
const MockCloudPubSub: Product = {
|
||||
kind: 'cloud-pub-sub',
|
||||
name: 'Cloud Pub/Sub',
|
||||
};
|
||||
|
||||
const MockCloudPubSubInsights: Entity = {
|
||||
id: 'cloud-pub-sub',
|
||||
id: MockCloudPubSub.kind,
|
||||
entities: [],
|
||||
aggregation: [32_000, 64_000],
|
||||
change: {
|
||||
@@ -125,12 +155,31 @@ function renderInContext(children: JSX.Element) {
|
||||
);
|
||||
}
|
||||
|
||||
describe('<ProductInsightsCardList />', () => {
|
||||
describe('<ProductInsights />', () => {
|
||||
const MockProducts: Product[] = [
|
||||
MockComputeEngine,
|
||||
MockCloudDataflow,
|
||||
MockCloudStorage,
|
||||
MockBigQuery,
|
||||
MockBigTable,
|
||||
MockCloudPubSub,
|
||||
];
|
||||
|
||||
function reverse(products: Product[]): Product[] {
|
||||
return products.slice().reverse();
|
||||
}
|
||||
|
||||
it('should render each product panel', async () => {
|
||||
const noComputeEngineCostsRgx = /There are no Compute Engine costs within this timeframe for your team's projects./;
|
||||
const { getByText } = await renderInContext(
|
||||
<ProductInsightsCardList products={MockProducts} />,
|
||||
<ProductInsights
|
||||
onLoaded={jest.fn()}
|
||||
group="test-group"
|
||||
project="test-project"
|
||||
products={MockProducts}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText(noComputeEngineCostsRgx)).toBeInTheDocument();
|
||||
MockProducts.forEach(product =>
|
||||
expect(getByText(product.name)).toBeInTheDocument(),
|
||||
@@ -139,12 +188,18 @@ describe('<ProductInsightsCardList />', () => {
|
||||
|
||||
it('product panels should be sorted by total aggregated cost', async () => {
|
||||
const { queryAllByTestId } = await renderInContext(
|
||||
<ProductInsightsCardList products={MockProducts} />,
|
||||
<ProductInsights
|
||||
onLoaded={jest.fn()}
|
||||
group="test-group"
|
||||
project="test-project"
|
||||
products={MockProducts}
|
||||
/>,
|
||||
);
|
||||
const expectedOrder = MockProducts.map(
|
||||
product => `product-list-item-${product.kind}`,
|
||||
).reverse();
|
||||
|
||||
const productPanels = queryAllByTestId(/^product-list-item/);
|
||||
const expectedOrder = reverse(MockProducts).map(
|
||||
product => `product-list-item-${product.kind}`,
|
||||
);
|
||||
|
||||
expect(productPanels.length).toBe(MockProducts.length);
|
||||
Array.from(productPanels)
|
||||
@@ -153,4 +208,21 @@ describe('<ProductInsightsCardList />', () => {
|
||||
expect(id).toBe(expectedOrder[i]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onLoaded with the correct sorted order', async () => {
|
||||
const mockOnLoaded = jest.fn();
|
||||
const expectedOrder = reverse(MockProducts);
|
||||
|
||||
await renderInContext(
|
||||
<ProductInsights
|
||||
onLoaded={mockOnLoaded}
|
||||
group="test-group"
|
||||
project="test-project"
|
||||
products={MockProducts}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockOnLoaded).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnLoaded).toHaveBeenCalledWith(expectedOrder);
|
||||
});
|
||||
});
|
||||
@@ -14,24 +14,143 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Grid } from '@material-ui/core';
|
||||
import { ProductInsightsCardList } from '../ProductInsightsCard';
|
||||
import { useConfig } from '../../hooks';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { default as Alert } from '@material-ui/lab/Alert';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { costInsightsApiRef } from '../../api';
|
||||
import { ProductInsightsCardList } from '../ProductInsightsCard/ProductInsightsCardList';
|
||||
import { Duration, Entity, Maybe, Product } from '../../types';
|
||||
import { DEFAULT_DURATION } from '../../utils/duration';
|
||||
import {
|
||||
DefaultLoadingAction,
|
||||
initialStatesOf,
|
||||
settledResponseOf,
|
||||
ProductState,
|
||||
} from '../../utils/loading';
|
||||
import { totalAggregationSort } from '../../utils/sort';
|
||||
import {
|
||||
useLoading,
|
||||
useLastCompleteBillingDate,
|
||||
MapLoadingToProps,
|
||||
} from '../../hooks';
|
||||
|
||||
export const ProductInsights = ({}) => {
|
||||
const config = useConfig();
|
||||
type LoadingProps = (isLoading: boolean) => void;
|
||||
|
||||
const mapLoadingToProps: MapLoadingToProps<LoadingProps> = ({ dispatch }) => (
|
||||
isLoading: boolean,
|
||||
) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading });
|
||||
|
||||
type ProductInsightsProps = {
|
||||
group: string;
|
||||
project: Maybe<string>;
|
||||
products: Product[];
|
||||
onLoaded: (entities: Product[]) => void;
|
||||
};
|
||||
|
||||
export const ProductInsights = ({
|
||||
group,
|
||||
project,
|
||||
products,
|
||||
onLoaded,
|
||||
}: ProductInsightsProps) => {
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const onceRef = useRef(false);
|
||||
const [initialStates, setStates] = useState<ProductState[]>([]);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const dispatchLoading = useLoading(mapLoadingToProps);
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// See @CostInsightsPage
|
||||
const dispatchLoadingProducts = useCallback(dispatchLoading, []);
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const onSelectAsyncMemo = useCallback(
|
||||
async function onSelectAsync(
|
||||
product: Product,
|
||||
duration: Duration,
|
||||
): Promise<Entity> {
|
||||
return client.getProductInsights({
|
||||
group: group,
|
||||
project: project,
|
||||
product: product.kind,
|
||||
duration: duration,
|
||||
lastCompleteBillingDate: lastCompleteBillingDate,
|
||||
});
|
||||
},
|
||||
[client, group, project, lastCompleteBillingDate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function getAllProductInsights(
|
||||
group: string,
|
||||
project: Maybe<string>,
|
||||
products: Product[],
|
||||
lastCompleteBillingDate: string,
|
||||
) {
|
||||
try {
|
||||
dispatchLoadingProducts(true);
|
||||
const responses = await Promise.allSettled(
|
||||
products.map(product =>
|
||||
client.getProductInsights({
|
||||
group: group,
|
||||
project: project,
|
||||
product: product.kind,
|
||||
duration: DEFAULT_DURATION,
|
||||
lastCompleteBillingDate: lastCompleteBillingDate,
|
||||
}),
|
||||
),
|
||||
).then(settledResponseOf);
|
||||
|
||||
const initialStates = initialStatesOf(products, responses).sort(
|
||||
totalAggregationSort,
|
||||
);
|
||||
setStates(initialStates);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
dispatchLoadingProducts(false);
|
||||
}
|
||||
}
|
||||
|
||||
getAllProductInsights(group, project, products, lastCompleteBillingDate);
|
||||
}, [
|
||||
client,
|
||||
group,
|
||||
project,
|
||||
products,
|
||||
lastCompleteBillingDate,
|
||||
dispatchLoadingProducts,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
function handleOnLoaded() {
|
||||
if (onceRef.current) {
|
||||
const products = initialStates.map(state => state.product);
|
||||
onLoaded(products);
|
||||
} else {
|
||||
onceRef.current = true;
|
||||
}
|
||||
},
|
||||
[initialStates, onLoaded],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box px={3} py={6}>
|
||||
<Box mt={0} mb={5} textAlign="center">
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Your team's product usage
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container direction="column">
|
||||
<ProductInsightsCardList products={config.products} />
|
||||
</Grid>
|
||||
</>
|
||||
{error ? (
|
||||
<Alert severity="error">{error.message}</Alert>
|
||||
) : (
|
||||
<ProductInsightsCardList
|
||||
initialStates={initialStates}
|
||||
onSelectAsync={onSelectAsyncMemo}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,9 +23,9 @@ import React, {
|
||||
} from 'react';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { default as Alert } from '@material-ui/lab/Alert';
|
||||
import { PeriodSelect } from '../PeriodSelect';
|
||||
import { ProductInsightsChart } from './ProductInsightsChart';
|
||||
import { ProductInsightsErrorCard } from './ProductInsightsErrorCard';
|
||||
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
|
||||
import { DefaultLoadingAction } from '../../utils/loading';
|
||||
import { Duration, Entity, Maybe, Product } from '../../types';
|
||||
@@ -86,44 +86,48 @@ export const ProductInsightsCard = ({
|
||||
|
||||
if (mountedRef.current) {
|
||||
handleOnSelectAsync();
|
||||
} else {
|
||||
mountedRef.current = true;
|
||||
}
|
||||
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
|
||||
|
||||
useEffect(function hasComponentMounted() {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const amount = entity?.entities?.length || 0;
|
||||
const hasCostsWithinTimeframe = !!(entity?.change && amount);
|
||||
|
||||
const subheader = hasCostsWithinTimeframe
|
||||
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
|
||||
const entities = entity?.entities ?? [];
|
||||
const subheader = entities.length
|
||||
? `${entities.length} ${pluralOf(
|
||||
entities.length,
|
||||
'entity',
|
||||
'entities',
|
||||
)}, sorted by cost`
|
||||
: null;
|
||||
|
||||
const infoCardProps = {
|
||||
headerProps: {
|
||||
classes: classes,
|
||||
action: <PeriodSelect duration={duration} onSelect={setDuration} />,
|
||||
},
|
||||
const headerProps = {
|
||||
classes: classes,
|
||||
action: <PeriodSelect duration={duration} onSelect={setDuration} />,
|
||||
};
|
||||
|
||||
if (error || !entity) {
|
||||
return (
|
||||
<ProductInsightsErrorCard
|
||||
product={product}
|
||||
duration={duration}
|
||||
onSelect={setDuration}
|
||||
/>
|
||||
<InfoCard title={product.name} headerProps={headerProps}>
|
||||
<ScrollAnchor behavior="smooth" top={-12} />
|
||||
<Alert severity="error">
|
||||
{error
|
||||
? error.message
|
||||
: `Error: Could not fetch product insights for ${product.name}`}
|
||||
</Alert>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title={product.name} subheader={subheader} {...infoCardProps}>
|
||||
<InfoCard
|
||||
title={product.name}
|
||||
subheader={subheader}
|
||||
headerProps={headerProps}
|
||||
>
|
||||
<ScrollAnchor behavior="smooth" top={-12} />
|
||||
{hasCostsWithinTimeframe ? (
|
||||
{entities.length ? (
|
||||
<ProductInsightsChart
|
||||
duration={duration}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
billingDate={lastCompleteBillingDate}
|
||||
/>
|
||||
) : (
|
||||
|
||||
+18
-148
@@ -14,162 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
|
||||
import { Box, CircularProgress } from '@material-ui/core';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { costInsightsApiRef } from '../../api';
|
||||
import React from 'react';
|
||||
import { Box, CircularProgress, Collapse } from '@material-ui/core';
|
||||
import { ProductInsightsCard } from './ProductInsightsCard';
|
||||
import { Duration, Entity, Maybe, Product } from '../../types';
|
||||
import { DefaultLoadingAction } from '../../utils/loading';
|
||||
import {
|
||||
useFilters,
|
||||
useLoading,
|
||||
useLastCompleteBillingDate,
|
||||
MapLoadingToProps,
|
||||
} from '../../hooks';
|
||||
|
||||
type State = {
|
||||
[kind: string]: ProductState;
|
||||
};
|
||||
|
||||
type ProductState = {
|
||||
product: Product;
|
||||
entity: Maybe<Entity>;
|
||||
duration: Duration;
|
||||
};
|
||||
|
||||
type LoadingProps = (isLoading: boolean) => void;
|
||||
import { Duration, Entity, Product } from '../../types';
|
||||
import { ProductState } from '../../utils/loading';
|
||||
|
||||
type ProductInsightsCardListProps = {
|
||||
products: Product[];
|
||||
initialStates: ProductState[];
|
||||
onSelectAsync: (product: Product, duration: Duration) => Promise<Entity>;
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = Duration.P30D;
|
||||
|
||||
function totalAggregationSort(a: ProductState, b: ProductState): number {
|
||||
const [prevA, currA] = a.entity?.aggregation ?? [0, 0];
|
||||
const [prevB, currB] = b.entity?.aggregation ?? [0, 0];
|
||||
return prevB + currB - (prevA + currA);
|
||||
}
|
||||
|
||||
const mapLoadingToProps: MapLoadingToProps<LoadingProps> = ({ dispatch }) => (
|
||||
isLoading: boolean,
|
||||
) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading });
|
||||
|
||||
function reducer(prevState: State, action: State): State {
|
||||
return {
|
||||
...prevState,
|
||||
...action,
|
||||
};
|
||||
}
|
||||
|
||||
export const ProductInsightsCardList = ({
|
||||
products,
|
||||
initialStates,
|
||||
onSelectAsync,
|
||||
}: ProductInsightsCardListProps) => {
|
||||
const onceRef = useRef(false);
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const filters = useFilters(p => p.pageFilters);
|
||||
const [state, dispatch] = useReducer(reducer, {});
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const dispatchLoading = useLoading(mapLoadingToProps);
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// See @CostInsightsPage
|
||||
const dispatchLoadingProducts = useCallback(dispatchLoading, []);
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const initialProductStates = onceRef.current
|
||||
? Object.values(state).sort(totalAggregationSort)
|
||||
: Object.values(state);
|
||||
|
||||
const onSelectAsyncMemo = useCallback(
|
||||
async function onSelectAsync(
|
||||
product: Product,
|
||||
duration: Duration,
|
||||
): Promise<Entity> {
|
||||
if (filters.group) {
|
||||
return client.getProductInsights({
|
||||
group: filters.group,
|
||||
project: filters.project,
|
||||
product: product.kind,
|
||||
duration: duration,
|
||||
lastCompleteBillingDate: lastCompleteBillingDate,
|
||||
});
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Cannot fetch insights for ${product.name}: Expected to have a group but found none.`,
|
||||
),
|
||||
);
|
||||
},
|
||||
[client, filters.group, filters.project, lastCompleteBillingDate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function getAllProductInsights(
|
||||
products: Product[],
|
||||
group: string,
|
||||
project: Maybe<string>,
|
||||
lastCompleteBillingDate: string,
|
||||
) {
|
||||
const requests = products.map(product =>
|
||||
client.getProductInsights({
|
||||
group: group,
|
||||
project: project,
|
||||
product: product.kind,
|
||||
duration: DEFAULT_DURATION,
|
||||
lastCompleteBillingDate: lastCompleteBillingDate,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
dispatchLoadingProducts(true);
|
||||
const responses = await Promise.allSettled(requests);
|
||||
const results = responses.map(response =>
|
||||
response.status === 'fulfilled' ? response.value : null,
|
||||
);
|
||||
const initialState = results.reduce((acc, entity, index) => {
|
||||
const product = products[index];
|
||||
return {
|
||||
...acc,
|
||||
[product.kind]: {
|
||||
product: product,
|
||||
entity: entity,
|
||||
duration: DEFAULT_DURATION,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
dispatch(initialState);
|
||||
} finally {
|
||||
dispatchLoadingProducts(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.group) {
|
||||
onceRef.current = true;
|
||||
getAllProductInsights(
|
||||
products,
|
||||
filters.group,
|
||||
filters.project,
|
||||
lastCompleteBillingDate,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
products,
|
||||
filters.group,
|
||||
filters.project,
|
||||
lastCompleteBillingDate,
|
||||
dispatchLoadingProducts,
|
||||
]);
|
||||
|
||||
if (!initialProductStates.length) {
|
||||
return <CircularProgress />;
|
||||
if (!initialStates.length) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="space-around" alignItems="center">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{initialProductStates.map(({ product, entity, duration }) => (
|
||||
<Collapse in timeout={1000}>
|
||||
{initialStates.map(({ product, entity, duration }) => (
|
||||
<Box
|
||||
key={product.kind}
|
||||
mb={6}
|
||||
@@ -178,11 +48,11 @@ export const ProductInsightsCardList = ({
|
||||
>
|
||||
<ProductInsightsCard
|
||||
product={product}
|
||||
onSelectAsync={onSelectAsyncMemo}
|
||||
onSelectAsync={onSelectAsync}
|
||||
initialState={{ entity: entity, duration: duration }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +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 { InfoCard } from '@backstage/core';
|
||||
import { default as Alert } from '@material-ui/lab/Alert';
|
||||
import { PeriodSelect } from '../PeriodSelect';
|
||||
import { useScroll } from '../../hooks';
|
||||
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
|
||||
import { Duration, Product } from '../../types';
|
||||
|
||||
export type ProductInsightsErrorCardProps = {
|
||||
product: Product;
|
||||
duration: Duration;
|
||||
onSelect: (duration: Duration) => void;
|
||||
};
|
||||
|
||||
export const ProductInsightsErrorCard = ({
|
||||
product,
|
||||
duration,
|
||||
onSelect,
|
||||
}: ProductInsightsErrorCardProps) => {
|
||||
const classes = useStyles();
|
||||
const { ScrollAnchor } = useScroll(product.kind);
|
||||
|
||||
const headerProps = {
|
||||
classes: classes,
|
||||
action: <PeriodSelect duration={duration} onSelect={onSelect} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<InfoCard title={product.name} headerProps={headerProps}>
|
||||
<ScrollAnchor behavior="smooth" top={-12} />
|
||||
<Alert severity="error">{`Error: Could not fetch product insights for ${product.name}`}</Alert>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -14,5 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ProductInsightsCardList } from './ProductInsightsCardList';
|
||||
export { ProductInsightsCard } from './ProductInsightsCard';
|
||||
export { ProductInsightsChart } from './ProductInsightsChart';
|
||||
|
||||
@@ -18,6 +18,8 @@ import moment from 'moment';
|
||||
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
|
||||
import { assertNever } from './assert';
|
||||
|
||||
export const DEFAULT_DURATION = Duration.P30D;
|
||||
|
||||
/**
|
||||
* Derive the start date of a given period, assuming two repeating intervals.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Loading } from '../types';
|
||||
import { Duration, Entity, Loading, Maybe, Product } from '../types';
|
||||
import { DEFAULT_DURATION } from './duration';
|
||||
|
||||
export type ProductState = {
|
||||
product: Product;
|
||||
entity: Maybe<Entity>;
|
||||
duration: Duration;
|
||||
};
|
||||
|
||||
export enum DefaultLoadingAction {
|
||||
UserGroups = 'user-groups',
|
||||
@@ -56,3 +63,22 @@ export const getResetStateWithoutInitial = (
|
||||
return { ...defaultState, [action]: loadingActionState };
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const settledResponseOf = (
|
||||
responses: PromiseSettledResult<Entity | any>[],
|
||||
): Array<Maybe<Entity>> => {
|
||||
return responses.map(response =>
|
||||
response.status === 'fulfilled' ? response.value : null,
|
||||
);
|
||||
};
|
||||
|
||||
export const initialStatesOf = (
|
||||
products: Product[],
|
||||
responses: Array<Maybe<Entity>>,
|
||||
): ProductState[] => {
|
||||
return products.map((product, index) => ({
|
||||
entity: responses[index],
|
||||
product: product,
|
||||
duration: DEFAULT_DURATION,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -462,3 +462,340 @@ export const MockAggregatedDailyCosts: DateAggregation[] = [
|
||||
amount: 5500,
|
||||
},
|
||||
];
|
||||
|
||||
export const SampleBigQueryInsights: Entity = {
|
||||
id: 'bigQuery',
|
||||
aggregation: [10_000, 30_000],
|
||||
change: {
|
||||
ratio: 3,
|
||||
amount: 20_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const SampleCloudDataflowInsights: Entity = {
|
||||
id: 'cloudDataflow',
|
||||
aggregation: [100_000, 158_000],
|
||||
change: {
|
||||
ratio: 0.58,
|
||||
amount: 58_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: null,
|
||||
aggregation: [10_000, 12_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 2_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: '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-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: '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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const SampleCloudStorageInsights: Entity = {
|
||||
id: 'cloudStorage',
|
||||
aggregation: [45_000, 45_000],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
change: {
|
||||
ratio: 0.333,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const SampleComputeEngineInsights: Entity = {
|
||||
id: 'computeEngine',
|
||||
aggregation: [80_000, 90_000],
|
||||
change: {
|
||||
ratio: 0.125,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [20_000, 10_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -10_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 0.5,
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function entityOf(product: string): Entity {
|
||||
switch (product) {
|
||||
case 'computeEngine':
|
||||
return SampleComputeEngineInsights;
|
||||
case 'cloudDataflow':
|
||||
return SampleCloudDataflowInsights;
|
||||
case 'cloudStorage':
|
||||
return SampleCloudStorageInsights;
|
||||
case 'bigQuery':
|
||||
return SampleBigQueryInsights;
|
||||
default:
|
||||
throw new Error(
|
||||
`Cannot get insights for ${product}. Make sure product matches product property in app-info.yaml`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { DateAggregation, ResourceData } from '../types';
|
||||
import { ProductState } from './loading';
|
||||
|
||||
export const aggregationSort = (
|
||||
a: DateAggregation,
|
||||
@@ -22,3 +23,9 @@ export const aggregationSort = (
|
||||
|
||||
export const resourceSort = (a: ResourceData, b: ResourceData) =>
|
||||
b.previous + b.current - (a.previous + a.current);
|
||||
|
||||
export function totalAggregationSort(a: ProductState, b: ProductState): number {
|
||||
const [prevA, currA] = a.entity?.aggregation ?? [0, 0];
|
||||
const [prevB, currB] = b.entity?.aggregation ?? [0, 0];
|
||||
return prevB + currB - (prevA + currA);
|
||||
}
|
||||
|
||||
@@ -353,6 +353,7 @@ export const useNavigationStyles = makeStyles<CostInsightsTheme>(
|
||||
menuList: {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.navigation.background,
|
||||
minWidth: 250,
|
||||
},
|
||||
menuItem: {
|
||||
background: 'transparent',
|
||||
|
||||
Reference in New Issue
Block a user