diff --git a/.changeset/stupid-taxis-sneeze.md b/.changeset/stupid-taxis-sneeze.md new file mode 100644 index 0000000000..d8e0fb130a --- /dev/null +++ b/.changeset/stupid-taxis-sneeze.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': minor +'@backstage/plugin-cost-insights': patch +--- + +sort product panels and navigation menu by greatest cost +update tsconfig.json to use ES2020 api diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index 62609aad5b..a55b215f74 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -11,7 +11,7 @@ "incremental": true, "isolatedModules": true, "jsx": "react", - "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019", "ESNext.Promise"], + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"], "module": "ESNext", "moduleResolution": "node", "noEmit": false, diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index a20344ffb3..0ba57f76fa 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -35,6 +35,7 @@ import { import { trendlineOf, changeOf, + entityOf, getGroupedProducts, aggregationFor, } from './utils/mockData'; @@ -125,170 +126,13 @@ 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 productInsights: Entity = 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: [], - }, - ], - }, - ], - }); - - return productInsightsOptions.project - ? projectProductInsights - : productInsights; + return productInsights; } async getAlerts(group: string): Promise { @@ -299,7 +143,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: [60_000, 120_000], change: { ratio: 1, - amount: 60000, + amount: 60_000, }, products: [ { id: 'Compute Engine', aggregation: [58_000, 118_000] }, 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/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx new file mode 100644 index 0000000000..85f39f9388 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ProductInsights } from './ProductInsights'; +import { ProductInsightsOptions } from '../../api'; +import { mockDefaultLoadingState } from '../../utils/mockData'; +import { + MockConfigProvider, + MockCostInsightsApiProvider, + MockCurrencyProvider, + MockFilterProvider, + MockBillingDateProvider, + MockScrollProvider, + MockLoadingProvider, +} from '../../utils/tests'; +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: [], + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0, + }, +}; + +const MockCloudDataflow: Product = { + kind: 'cloud-dataflow', + name: 'Cloud Dataflow', +}; + +const MockCloudDataflowInsights: Entity = { + id: MockCloudDataflow.kind, + entities: [], + aggregation: [1_000, 2_000], + change: { + ratio: 1, + amount: 1_000, + }, +}; + +const MockCloudStorage: Product = { + kind: 'cloud-storage', + name: 'Cloud Storage', +}; + +const MockCloudStorageInsights: Entity = { + id: MockCloudStorage.kind, + entities: [], + aggregation: [2_000, 4_000], + change: { + ratio: 1, + amount: 2_000, + }, +}; + +const MockBigQuery: Product = { + kind: 'big-query', + name: 'BigQuery', +}; + +const MockBigQueryInsights: Entity = { + id: MockBigQuery.kind, + entities: [], + aggregation: [8_000, 16_000], + change: { + ratio: 1, + amount: 8_000, + }, +}; + +const MockBigTable: Product = { + kind: 'big-table', + name: 'BigTable', +}; + +const MockBigTableInsights: Entity = { + id: MockBigTable.kind, + entities: [], + aggregation: [16_000, 32_000], + change: { + ratio: 1, + amount: 16_000, + }, +}; + +const MockCloudPubSub: Product = { + kind: 'cloud-pub-sub', + name: 'Cloud Pub/Sub', +}; + +const MockCloudPubSubInsights: Entity = { + id: MockCloudPubSub.kind, + entities: [], + aggregation: [32_000, 64_000], + change: { + ratio: 1, + amount: 32_000, + }, +}; + +const ProductEntityMap = { + [MockBigQueryInsights.id!]: MockBigQueryInsights, + [MockBigTableInsights.id!]: MockBigTableInsights, + [MockCloudPubSubInsights.id!]: MockCloudPubSubInsights, + [MockCloudStorageInsights.id!]: MockCloudStorageInsights, + [MockCloudDataflowInsights.id!]: MockCloudDataflowInsights, + [MockComputeEngineInsights.id!]: MockComputeEngineInsights, +}; + +const costInsightsApi = { + getProductInsights: ({ product }: ProductInsightsOptions): Promise => + Promise.resolve(ProductEntityMap[product]), +}; + +function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + + + + + {children} + + + + + + , + ); +} + +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(), + ); + }); + + it('product panels should be sorted by total aggregated cost', async () => { + const { queryAllByTestId } = await renderInContext( + , + ); + + 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) + .map(el => el.getAttribute('data-testid')) + .forEach((id, i) => { + 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 c5d39a5139..52abe3fd8b 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -14,28 +14,143 @@ * limitations under the License. */ -import React from 'react'; -import { Box, Typography, Grid } from '@material-ui/core'; -import { ProductInsightsCard } 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 - - {config.products.map(product => ( - - - - ))} - - + {error ? ( + {error.message} + ) : ( + + )} + ); }; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index cdbbb79806..385ff82f9b 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -22,19 +22,19 @@ import { createMockEntity, mockDefaultLoadingState, MockComputeEngine, - MockProductFilters, } from '../../utils/mockData'; import { - MockCostInsightsApiProvider, - MockBillingDateProvider, MockConfigProvider, + MockCostInsightsApiProvider, MockCurrencyProvider, - MockFilterProvider, - MockGroupsProvider, + MockBillingDateProvider, MockScrollProvider, MockLoadingProvider, } from '../../utils/tests'; -import { Duration, Entity, Product, ProductPeriod } from '../../types'; +import { Duration, Entity, Product } from '../../types'; + +// suppress recharts componentDidUpdate warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); const costInsightsApi = (entity: Entity): Partial => ({ getProductInsights: () => Promise.resolve(entity), @@ -53,29 +53,25 @@ const mockProductCost = createMockEntity(() => ({ const renderProductInsightsCardInTestApp = async ( entity: Entity, product: Product, - duration: Duration, + duration = Duration.P30D, + onSelectAsync = jest.fn(() => Promise.resolve(mockProductCost)), ) => await renderInTestApp( - - + + - ({ - ...p, - duration: duration, - }))} - > - - - - - - + + + - - + + , ); @@ -100,7 +96,6 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, - Duration.P1M, ); const subheader = 'entities, sorted by cost'; const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 2a1f962000..e633d6653d 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -14,125 +14,121 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { InfoCard, useApi } from '@backstage/core'; -import Alert from '@material-ui/lab/Alert'; +import React, { + PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { InfoCard } from '@backstage/core'; import { Typography } from '@material-ui/core'; -import { costInsightsApiRef } from '../../api'; -import { ProductInsightsChart } from './ProductInsightsChart'; +import { default as Alert } from '@material-ui/lab/Alert'; import { PeriodSelect } from '../PeriodSelect'; -import { - useFilters, - useLastCompleteBillingDate, - useLoading, - useScroll, -} from '../../hooks'; +import { ProductInsightsChart } from './ProductInsightsChart'; import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; -import { mapFiltersToProps, mapLoadingToProps } from './selector'; -import { Duration, Maybe, Product, Entity } from '../../types'; +import { DefaultLoadingAction } from '../../utils/loading'; +import { Duration, Entity, Maybe, Product } from '../../types'; +import { + useLastCompleteBillingDate, + useScroll, + useLoading, + MapLoadingToProps, +} from '../../hooks'; import { pluralOf } from '../../utils/grammar'; -type ProductInsightsCardProps = { +type LoadingProps = (isLoading: boolean) => void; + +export type ProductInsightsCardProps = { product: Product; + initialState: { + entity: Maybe; + duration: Duration; + }; + onSelectAsync: (product: Product, duration: Duration) => Promise; }; -export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { - const client = useApi(costInsightsApiRef); +const mapLoadingToProps: MapLoadingToProps = ({ dispatch }) => ( + isLoading: boolean, +) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading }); + +export const ProductInsightsCard = ({ + initialState, + product, + onSelectAsync, +}: PropsWithChildren) => { const classes = useStyles(); + const mountedRef = useRef(false); const { ScrollAnchor } = useScroll(product.kind); - const lastCompleteBillingDate = useLastCompleteBillingDate(); - const [entity, setEntity] = useState>(null); const [error, setError] = useState>(null); + const dispatchLoading = useLoading(mapLoadingToProps); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [entity, setEntity] = useState>(initialState.entity); + const [duration, setDuration] = useState(initialState.duration); - const { group, product: productFilter, setProduct, project } = useFilters( - mapFiltersToProps(product.kind), - ); - const { loadingProduct, dispatchLoading } = useLoading( - mapLoadingToProps(product.kind), - ); - - // @see CostInsightsPage - // eslint-disable-next-line react-hooks/exhaustive-deps - const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); - - const amount = entity?.entities?.length || 0; - const hasCostsWithinTimeframe = !!(entity?.change && amount); - - const subheader = hasCostsWithinTimeframe - ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` - : null; + /* eslint-disable react-hooks/exhaustive-deps */ + const dispatchLoadingProduct = useCallback(dispatchLoading, []); + /* eslint-enable react-hooks/exhaustive-deps */ useEffect(() => { - async function load() { - if (loadingProduct) { - try { - const e: Entity = await client.getProductInsights({ - product: product.kind, - group: group!, - duration: productFilter!.duration, - lastCompleteBillingDate, - project, - }); - setEntity(e); - } catch (e) { - setError(e); - } finally { - dispatchLoadingProduct(false); - } + async function handleOnSelectAsync() { + dispatchLoadingProduct(true); + try { + const e = await onSelectAsync(product, duration); + setEntity(e); + } catch (e) { + setEntity(null); + setError(e); + } finally { + dispatchLoadingProduct(false); } } - load(); - }, [ - client, - product, - setEntity, - loadingProduct, - dispatchLoadingProduct, - productFilter, - group, - product.kind, - project, - lastCompleteBillingDate, - ]); - const onPeriodSelect = (duration: Duration) => { - dispatchLoadingProduct(true); - setProduct(duration); + if (mountedRef.current) { + handleOnSelectAsync(); + } else { + mountedRef.current = true; + } + }, [product, duration, onSelectAsync, dispatchLoadingProduct]); + + const entities = entity?.entities ?? []; + const subheader = entities.length + ? `${entities.length} ${pluralOf( + entities.length, + 'entity', + 'entities', + )}, sorted by cost` + : null; + const headerProps = { + classes: classes, + action: , }; - const infoCardProps = { - headerProps: { - classes: classes, - action: ( - - ), - }, - }; - - if (error) { + if (error || !entity) { return ( - + - {`Error: Could not fetch product insights for ${product.name}`} + + {error + ? error.message + : `Error: Could not fetch product insights for ${product.name}`} + ); } - if (!entity) { - return null; - } - return ( - + - {hasCostsWithinTimeframe ? ( + {entities.length ? ( ) : ( diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx new file mode 100644 index 0000000000..2dfd5bfc75 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, CircularProgress, Collapse } from '@material-ui/core'; +import { ProductInsightsCard } from './ProductInsightsCard'; +import { Duration, Entity, Product } from '../../types'; +import { ProductState } from '../../utils/loading'; + +type ProductInsightsCardListProps = { + initialStates: ProductState[]; + onSelectAsync: (product: Product, duration: Duration) => Promise; +}; + +export const ProductInsightsCardList = ({ + initialStates, + onSelectAsync, +}: ProductInsightsCardListProps) => { + if (!initialStates.length) { + return ( + + + + ); + } + + return ( + + {initialStates.map(({ product, entity, duration }) => ( + + + + ))} + + ); +}; diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx index 420ac7ed56..07f8a0f4e7 100644 --- a/plugins/cost-insights/src/hooks/useLoading.tsx +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -21,7 +21,6 @@ import React, { SetStateAction, useContext, useEffect, - useMemo, useReducer, useState, } from 'react'; @@ -30,10 +29,9 @@ import { Loading } from '../types'; import { DefaultLoadingAction, getDefaultState, - getLoadingActions, + INITIAL_LOADING_ACTIONS, } from '../utils/loading'; import { useBackdropStyles as useStyles } from '../utils/styles'; -import { useConfig } from './useConfig'; export type LoadingContextProps = { state: Loading; @@ -56,10 +54,7 @@ function reducer(prevState: Loading, action: Partial): Loading { export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => { const classes = useStyles(); - const { products } = useConfig(); - const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ - products, - ]); + const actions = INITIAL_LOADING_ACTIONS; const [state, dispatch] = useReducer(reducer, getDefaultState(actions)); const [isBackdropVisible, setBackdropVisible] = useState(false); 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 4652603926..3328f23cfc 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -14,18 +14,27 @@ * 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', LastCompleteBillingDate = 'billing-date', CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', + CostInsightsProducts = 'cost-insights-products', } export const INITIAL_LOADING_ACTIONS = [ DefaultLoadingAction.UserGroups, DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsProducts, ]; export const getDefaultState = (loadingActions: string[]): Loading => { @@ -55,10 +64,21 @@ export const getResetStateWithoutInitial = ( }, {}); }; -export function getLoadingActions(products: string[]): string[] { - return ([ - DefaultLoadingAction.UserGroups, - DefaultLoadingAction.CostInsightsInitial, - DefaultLoadingAction.CostInsightsPage, - ] as string[]).concat(products); -} +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 cf9f13daa7..f188bd7a57 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -153,14 +153,14 @@ export const MockProducts: Product[] = Object.keys(MockProductTypes).map( })), ); -export const MockLoadingActions = ([ +export const MockDefaultLoadingActions = ([ DefaultLoadingAction.UserGroups, DefaultLoadingAction.CostInsightsInitial, DefaultLoadingAction.CostInsightsPage, ] as string[]).concat(MockProducts.map(product => product.kind)); export const mockDefaultLoadingState = getDefaultLoadingState( - MockLoadingActions, + MockDefaultLoadingActions, ); export const MockComputeEngine = findAlways( @@ -510,6 +510,343 @@ export const MockAggregatedDailyCosts: DateAggregation[] = [ }, ]; +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`, + ); + } +} + export const getGroupedProducts = (intervals: string) => [ { id: 'Cloud Dataflow', 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 1d398fe84b..5a42604311 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -391,6 +391,7 @@ export const useNavigationStyles = makeStyles( menuList: { borderRadius: theme.shape.borderRadius, backgroundColor: theme.palette.navigation.background, + minWidth: 250, }, menuItem: { background: 'transparent',