From 49ac6cec38c138b9dd1a1fe2be141737df30ef14 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 2 Oct 2020 21:02:10 -0600 Subject: [PATCH 01/66] Better example data --- packages/app/package.json | 3 + .../ExampleCostInsightsClient.ts | 118 +++++++++++------- .../CostOverviewCard/CostOverviewCard.tsx | 2 +- yarn.lock | 10 ++ 4 files changed, 88 insertions(+), 45 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 2373887cc6..d194d8dd2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -36,6 +36,7 @@ "@roadiehq/backstage-plugin-github-pull-requests": "0.3.0", "@roadiehq/backstage-plugin-travis-ci": "^0.2.3", "history": "^5.0.0", + "moment": "^2.27.0", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -43,6 +44,7 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", + "regression": "^2.0.1", "zen-observable": "^0.8.15" }, "devDependencies": { @@ -54,6 +56,7 @@ "@types/jquery": "^3.3.34", "@types/node": "^12.0.0", "@types/react-dom": "^16.9.8", + "@types/regression": "^2.0.0", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", "cypress": "^4.2.0", diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index c01092dc51..ede37758fd 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -15,16 +15,72 @@ */ /* eslint-disable no-restricted-imports */ +import moment from 'moment'; +import regression from 'regression'; import { - CostInsightsApi, Alert, + ChangeStatistic, Cost, + CostInsightsApi, + DateAggregation, Duration, - Project, - ProductCost, Group, + ProductCost, + Project, + Trendline, } from '@backstage/plugin-cost-insights'; +function durationOf(intervals: string): Duration { + const match = intervals.match(/\/(?P\d+[DM])\//); + const { duration } = match!.groups!; + return duration as Duration; +} + +function aggregationFor( + duration: Duration, + baseline: number, +): DateAggregation[] { + const days = moment.duration(duration).asDays() * 2; + + return [...Array(days).keys()].reduce( + (values: DateAggregation[], i: number): DateAggregation[] => { + const last = values.length ? values[values.length - 1].amount : baseline; + values.push({ + date: moment() + .subtract(days, 'days') + .add(i, 'days') + .format('YYYY-MM-DD'), + amount: last + (baseline / 20) * (Math.random() * 2 - 1), + }); + return values; + }, + [], + ); +} + +function trendlineOf(aggregation: DateAggregation[]): Trendline { + const data = [...aggregation.map(a => [Date.parse(a.date) / 1000, a.amount])]; + const result = regression.linear(data, { precision: 5 }); + return { + slope: result.equation[0], + intercept: result.equation[1], + }; +} + +function changeOf(aggregation: DateAggregation[]): ChangeStatistic { + const half = Math.ceil(aggregation.length / 2); + const before = aggregation + .slice(0, half) + .reduce((sum, a) => sum + a.amount, 0); + const after = aggregation + .slice(half, aggregation.length) + .reduce((sum, a) => sum + a.amount, 0); + return { + ratio: (after - before) / before, + amount: after - before, + }; +} + export class ExampleCostInsightsClient implements CostInsightsApi { private request(_: any, res: any): Promise { return new Promise(resolve => setTimeout(resolve, 0, res)); @@ -53,30 +109,17 @@ export class ExampleCostInsightsClient implements CostInsightsApi { metric: string | null, intervals: string, ): Promise { + const aggregation = aggregationFor( + durationOf(intervals), + metric ? 0.3 : 8_000, + ); const groupDailyCost: Cost = await this.request( { group, metric, intervals }, { id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel - aggregation: [ - { date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) }, - { date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) }, - ], - change: { - ratio: 0.86, - amount: 65_000, - }, - trendline: { - slope: 0, - intercept: 90_000, - }, + aggregation: aggregation, + change: changeOf(aggregation), + trendline: trendlineOf(aggregation), }, ); @@ -88,30 +131,17 @@ export class ExampleCostInsightsClient implements CostInsightsApi { metric: string | null, intervals: string, ): Promise { + const aggregation = aggregationFor( + durationOf(intervals), + metric ? 0.1 : 1_500, + ); const projectDailyCost: Cost = await this.request( { project, metric, intervals }, { id: 'project-a', - aggregation: [ - { date: '2020-08-01', amount: 1000 }, - { date: '2020-08-02', amount: 2000 }, - { date: '2020-08-03', amount: 3000 }, - { date: '2020-08-04', amount: 4000 }, - { date: '2020-08-05', amount: 5000 }, - { date: '2020-08-06', amount: 6000 }, - { date: '2020-08-07', amount: 7000 }, - { date: '2020-08-08', amount: 8000 }, - { date: '2020-08-09', amount: 9000 }, - { date: '2020-08-10', amount: 10_000 }, - ], - change: { - ratio: 0.5, - amount: 10000, - }, - trendline: { - slope: 0, - intercept: 0, - }, + aggregation: aggregation, + change: changeOf(aggregation), + trendline: trendlineOf(aggregation), }, ); @@ -134,7 +164,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { entities: [ { id: null, // entities with null ids will be appear as "Unlabeled" in product panels - aggregation: [45_000, 50_000], + aggregation: [15_000, 30_000], }, { id: 'entity-a', diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index ef174132a3..8bcdc5b323 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -64,7 +64,7 @@ const CostOverviewCard = ({ - + Date: Fri, 2 Oct 2020 21:14:03 -0600 Subject: [PATCH 02/66] Use actual duration days --- .../plugins/cost-insights/ExampleCostInsightsClient.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index ede37758fd..b05ba1b5e9 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -24,7 +24,9 @@ import { CostInsightsApi, DateAggregation, Duration, + exclusiveEndDateOf, Group, + inclusiveStartDateOf, ProductCost, Project, Trendline, @@ -40,14 +42,16 @@ function aggregationFor( duration: Duration, baseline: number, ): DateAggregation[] { - const days = moment.duration(duration).asDays() * 2; + const days = moment(exclusiveEndDateOf(duration)).diff( + inclusiveStartDateOf(duration), + 'days', + ); return [...Array(days).keys()].reduce( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; values.push({ - date: moment() - .subtract(days, 'days') + date: moment(inclusiveStartDateOf(duration)) .add(i, 'days') .format('YYYY-MM-DD'), amount: last + (baseline / 20) * (Math.random() * 2 - 1), From 0181b365e4fe8a61a2636e2bb4fb785b518d46d2 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Wed, 7 Oct 2020 14:52:26 -0400 Subject: [PATCH 03/66] Add project field to product cost api calls --- .../ExampleCostInsightsClient.ts | 34 +++++++++++++++++-- .../cost-insights/src/api/CostInsightsApi.ts | 15 +++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index c01092dc51..e1ef77c537 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -23,6 +23,7 @@ import { Project, ProductCost, Group, + Maybe, } from '@backstage/plugin-cost-insights'; export class ExampleCostInsightsClient implements CostInsightsApi { @@ -122,9 +123,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi { product: string, group: string, duration: Duration, + project: Maybe, ): Promise { + const projectProductInsights = await this.request( + { product, group, duration, project }, + { + 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], + }, + { + id: 'entity-a', + aggregation: [15_000, 20_000], + }, + { + id: 'entity-b', + aggregation: [20_000, 30_000], + }, + { + id: 'entity-e', + aggregation: [0, 10_000], + }, + ], + }, + ); const productInsights: ProductCost = await this.request( - { product, group, duration }, + { product, group, duration, project }, { aggregation: [200_000, 250_000], change: { @@ -172,7 +202,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { }, ); - return productInsights; + return project ? projectProductInsights : productInsights; } async getAlerts(group: string): Promise { diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index b7628e2f9e..bdf673b7d1 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -15,7 +15,15 @@ */ import { createApiRef } from '@backstage/core'; -import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types'; +import { + Alert, + Cost, + Duration, + Group, + Project, + ProductCost, + Maybe, +} from '../types'; export type CostInsightsApi = { /** @@ -87,6 +95,9 @@ export type CostInsightsApi = { * in this product. The type of entity depends on the product - it may be deployed services, * storage buckets, managed database instances, etc. * + * If project is supplied, this should only return product costs for the given billing entity + * (project in GCP). + * * The time period is supplied as a Duration rather than intervals, since this is always expected * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). * @@ -94,11 +105,13 @@ export type CostInsightsApi = { * @param group * @param duration A time duration, such as P1M. See the Duration type for a detailed explanation * of how the durations are interpreted in Cost Insights. + * @param project (optional) The project id from getGroupProjects or query parameters */ getProductInsights( product: string, group: string, duration: Duration, + project: Maybe, ): Promise; /** From 58db835fbd5f4ddab0153ff7fe99062fb2ed2e30 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Wed, 7 Oct 2020 14:53:05 -0400 Subject: [PATCH 04/66] Remove project select from cost overview footer --- .../CostOverviewCard/CostOverviewCard.tsx | 12 ++----- .../CostOverviewCard/CostOverviewFooter.tsx | 36 ------------------- 2 files changed, 2 insertions(+), 46 deletions(-) delete mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index ef174132a3..1d9543c8c0 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -19,10 +19,8 @@ import { Box, Card, CardContent, Divider } from '@material-ui/core'; import CostOverviewChart from '../CostOverviewChart'; import CostOverviewChartLegend from '../CostOverviewChartLegend'; import CostOverviewHeader from './CostOverviewHeader'; -import CostOverviewFooter from './CostOverviewFooter'; import MetricSelect from '../MetricSelect'; import PeriodSelect from '../PeriodSelect'; -import ProjectSelect from '../ProjectSelect'; import { useScroll, useFilters, useConfig } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; @@ -45,7 +43,6 @@ const CostOverviewCard = ({ change, aggregation, trendline, - projects, }: CostOverviewCardProps) => { const { metrics } = useConfig(); const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); @@ -73,18 +70,13 @@ const CostOverviewCard = ({ trendline={trendline} /> - - + - + ); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx deleted file mode 100644 index 03920887b8..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx +++ /dev/null @@ -1,36 +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 { Box } from '@material-ui/core'; - -type CostOverviewFooterProps = { - children?: React.ReactNode; -}; - -const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => ( - - {React.Children.map(children, child => ( - {child} - ))} - -); - -export default CostOverviewFooter; From d8f874d39ae5609fe339e3ca9ce7ce3bc3cc7914 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Wed, 7 Oct 2020 14:54:18 -0400 Subject: [PATCH 05/66] Add cost overview banner --- .../CostInsightsPage/CostInsightsPage.tsx | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index c426eff8bc..dfd90e428a 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -15,7 +15,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { Box, Container, Divider, Grid } from '@material-ui/core'; +import { Box, Container, Divider, Grid, Typography } from '@material-ui/core'; import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; import { default as MaterialAlert } from '@material-ui/lab/Alert'; import { costInsightsApiRef } from '../../api'; @@ -41,6 +41,7 @@ import { } from '../../hooks'; import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; import { mapLoadingToProps } from './selector'; +import ProjectSelect from '../ProjectSelect'; const CostInsightsPage = () => { const flags = useApi(featureFlagsApiRef).getFlags(); @@ -55,7 +56,7 @@ const CostInsightsPage = () => { const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); - const { pageFilters } = useFilters(p => p); + const { pageFilters, setPageFilters } = useFilters(p => p); const { loadingActions, loadingGroups, @@ -63,6 +64,7 @@ const CostInsightsPage = () => { dispatchInitial, dispatchInsights, dispatchNone, + dispatchReset, } = useLoading(mapLoadingToProps); /* eslint-disable react-hooks/exhaustive-deps */ @@ -75,8 +77,15 @@ const CostInsightsPage = () => { const dispatchLoadingInitial = useCallback(dispatchInitial, []); const dispatchLoadingInsights = useCallback(dispatchInsights, []); const dispatchLoadingNone = useCallback(dispatchNone, []); + const dispatchLoadingReset = useCallback(dispatchReset, []); /* eslint-enable react-hooks/exhaustive-deps */ + const setProject = (project: Maybe) => + setPageFilters({ + ...pageFilters, + project: project === 'all' ? null : project, + }); + useEffect(() => { async function getInsights() { setError(null); @@ -165,6 +174,43 @@ const CostInsightsPage = () => { ); } + const onProjectSelect = (project: Maybe) => { + setProject(project); + dispatchLoadingReset(loadingActions); + }; + + const CostOverviewBanner = () => ( + + + + Cost Overview + + + + {!!flags.get('cost-insights-currencies') && ( + + + + )} + + + + ); + return ( @@ -180,15 +226,6 @@ const CostInsightsPage = () => { justifyContent="flex-end" mb={2} > - {!!flags.get('cost-insights-currencies') && ( - - - - )} @@ -212,6 +249,9 @@ const CostInsightsPage = () => { )} + + + {!!dailyCost.aggregation.length && ( From d0241306fa1ed6736ca38f4c079828b169422d5b Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Wed, 7 Oct 2020 14:54:37 -0400 Subject: [PATCH 06/66] Update cost insights select styles --- .../src/components/CostInsightsPage/selector.tsx | 9 ++++++++- .../ProductInsightsCard/ProductInsightsCard.tsx | 4 +++- .../src/components/ProjectSelect/ProjectSelect.tsx | 2 +- plugins/cost-insights/src/utils/styles.ts | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx index 51db47c082..8a9cf021c9 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ import { MapLoadingToProps } from '../../hooks'; -import { getResetState, DefaultLoadingAction } from '../../types'; +import { + getResetState, + DefaultLoadingAction, + getResetStateWithoutInitial, +} from '../../types'; type CostInsightsPageLoadingProps = { loadingActions: Array; @@ -23,6 +27,7 @@ type CostInsightsPageLoadingProps = { dispatchInitial: (isLoading: boolean) => void; dispatchInsights: (isLoading: boolean) => void; dispatchNone: (loadingActions: string[]) => void; + dispatchReset: (loadingActions: string[]) => void; }; export const mapLoadingToProps: MapLoadingToProps = ({ @@ -39,4 +44,6 @@ export const mapLoadingToProps: MapLoadingToProps dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }), dispatchNone: (loadingActions: string[]) => dispatch(getResetState(loadingActions)), + dispatchReset: (loadingActions: string[]) => + dispatch(getResetStateWithoutInitial(loadingActions)), }); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 1d24139c64..c9790a5bfa 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -39,7 +39,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const [resource, setResource] = useState>(null); const [error, setError] = useState>(null); - const { group, product: productFilter, setProduct } = useFilters( + const { group, product: productFilter, setProduct, project } = useFilters( mapFiltersToProps(product.kind), ); const { loadingProduct, dispatchLoading } = useLoading( @@ -68,6 +68,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { product.kind, group!, productFilter!.duration, + project, ); setResource(p); } catch (e) { @@ -87,6 +88,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { productFilter, group, product.kind, + project, ]); const onPeriodSelect = (duration: Duration) => { diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index cbcd8ede4e..934ad99f63 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -48,7 +48,7 @@ const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => {