From 12355bfe466d5f193dac3d2146caf53cdea39950 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 30 Nov 2020 11:05:37 -0500 Subject: [PATCH] stack rank product menu items; add mock data --- plugins/cost-insights/src/client.ts | 182 +--------- .../CostInsightsNavigation.test.tsx | 10 +- .../CostInsightsNavigation.tsx | 103 ++++-- .../CostInsightsPage/CostInsightsPage.tsx | 15 +- .../ProductInsights.test.tsx} | 100 +++++- .../ProductInsights/ProductInsights.tsx | 141 +++++++- .../ProductInsightsCard.tsx | 52 +-- .../ProductInsightsCardList.tsx | 166 +-------- .../ProductInsightsErrorCard.tsx | 50 --- .../components/ProductInsightsCard/index.ts | 2 +- plugins/cost-insights/src/utils/duration.ts | 2 + plugins/cost-insights/src/utils/loading.ts | 28 +- plugins/cost-insights/src/utils/mockData.ts | 337 ++++++++++++++++++ plugins/cost-insights/src/utils/sort.ts | 7 + plugins/cost-insights/src/utils/styles.ts | 1 + 15 files changed, 735 insertions(+), 461 deletions(-) rename plugins/cost-insights/src/components/{ProductInsightsCard/ProductInsightsCardList.test.tsx => ProductInsights/ProductInsights.test.tsx} (67%) delete mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsErrorCard.tsx diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index db999584a0..bf7a6fb206 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -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 { - 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 { + 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 { @@ -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] }, ], }; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx index e2a0dd08b1..f94ab48c6f 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx @@ -46,7 +46,7 @@ const renderWrapped = (children: React.ReactNode) => describe('', () => { it('should render each navigation item', async () => { const { getByText } = await renderWrapped( - , + , ); getDefaultNavigationItems(3) .map(item => item.title) @@ -55,12 +55,16 @@ describe('', () => { }); it('should not display action items navigation if there are no action items', async () => { - const rendered = await renderWrapped(); + const rendered = await renderWrapped( + , + ); expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument(); }); it('should display the correct amount of action items in the badge', async () => { - const rendered = await renderWrapped(); + const rendered = await renderWrapped( + , + ); expect(rendered.getByText(/3/)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index 810fc767a7..a30b6c5a90 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -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; }; -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 ( - - {navigationItems.map((item: NavigationItem) => ( - - {React.cloneElement(item.icon, { + return ( + + {defaultNavigationItems.map(item => ( + + {React.cloneElement(item.icon, { + className: classes.navigationIcon, + })} + + ) : ( + React.cloneElement(item.icon, { className: classes.navigationIcon, - })} - - ) : ( - React.cloneElement(item.icon, { + }) + ) + } + /> + ))} + + {productNavigationItems.map((item: NavigationItem) => ( + - ))} - - ); -}; + })} + title={item.title} + /> + ))} + + + ); + }, +); const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { - const classes = useNavigationStyles(); + const classes = useStyles(); const { scrollIntoView } = useScroll(navigation); return ( { const lastCompleteBillingDate = useLastCompleteBillingDate(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); + const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); const [alerts, setAlerts] = useState>(null); @@ -224,7 +225,10 @@ export const CostInsightsPage = () => { - + @@ -281,7 +285,12 @@ export const CostInsightsPage = () => { {!alerts.length && } - + diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx similarity index 67% rename from plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.test.tsx rename to plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx index 256ee8ba96..85f39f9388 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -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('', () => { +describe('', () => { + 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( - , + , ); + expect(getByText(noComputeEngineCostsRgx)).toBeInTheDocument(); MockProducts.forEach(product => expect(getByText(product.name)).toBeInTheDocument(), @@ -139,12 +188,18 @@ describe('', () => { it('product panels should be sorted by total aggregated cost', async () => { const { queryAllByTestId } = await renderInContext( - , + , ); - 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('', () => { 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( + , + ); + + expect(mockOnLoaded).toHaveBeenCalledTimes(1); + expect(mockOnLoaded).toHaveBeenCalledWith(expectedOrder); + }); }); diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index affef5d182..52abe3fd8b 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -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 = ({ dispatch }) => ( + isLoading: boolean, +) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading }); + +type ProductInsightsProps = { + group: string; + project: Maybe; + 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([]); + const [error, setError] = useState>(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 { + 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, + 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 ( - <> + Your team's product usage - - - - + {error ? ( + {error.message} + ) : ( + + )} + ); }; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 6d6d8d5cd9..e633d6653d 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -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: , - }, + const headerProps = { + classes: classes, + action: , }; if (error || !entity) { return ( - + + + + {error + ? error.message + : `Error: Could not fetch product insights for ${product.name}`} + + ); } return ( - + - {hasCostsWithinTimeframe ? ( + {entities.length ? ( ) : ( diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx index 5c096553e1..2dfd5bfc75 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx @@ -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; - 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; }; -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 = ({ 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 { - 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, - 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 ; + if (!initialStates.length) { + return ( + + + + ); } return ( - <> - {initialProductStates.map(({ product, entity, duration }) => ( + + {initialStates.map(({ product, entity, duration }) => ( ))} - + ); }; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsErrorCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsErrorCard.tsx deleted file mode 100644 index 6f6c5a21c8..0000000000 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsErrorCard.tsx +++ /dev/null @@ -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: , - }; - - return ( - - - {`Error: Could not fetch product insights for ${product.name}`} - - ); -}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts index 0a67bf459e..33a402234c 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts +++ b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { ProductInsightsCardList } from './ProductInsightsCardList'; +export { ProductInsightsCard } from './ProductInsightsCard'; export { ProductInsightsChart } from './ProductInsightsChart'; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index ae8c255b91..810160c7b6 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -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. * diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 40101051b8..3328f23cfc 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -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; + duration: Duration; +}; export enum DefaultLoadingAction { UserGroups = 'user-groups', @@ -56,3 +63,22 @@ export const getResetStateWithoutInitial = ( return { ...defaultState, [action]: loadingActionState }; }, {}); }; + +export const settledResponseOf = ( + responses: PromiseSettledResult[], +): Array> => { + return responses.map(response => + response.status === 'fulfilled' ? response.value : null, + ); +}; + +export const initialStatesOf = ( + products: Product[], + responses: Array>, +): ProductState[] => { + return products.map((product, index) => ({ + entity: responses[index], + product: product, + duration: DEFAULT_DURATION, + })); +}; diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index e40e5e1ddc..b12a4b2cbe 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -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`, + ); + } +} diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts index f4fc1987d1..e58d2961b6 100644 --- a/plugins/cost-insights/src/utils/sort.ts +++ b/plugins/cost-insights/src/utils/sort.ts @@ -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); +} diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index 542896d6fc..ccfa3100bd 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -353,6 +353,7 @@ export const useNavigationStyles = makeStyles( menuList: { borderRadius: theme.shape.borderRadius, backgroundColor: theme.palette.navigation.background, + minWidth: 250, }, menuItem: { background: 'transparent',