From 1268eadd193f7e93ba41f9781d63bdcb29582baa Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 13 Oct 2020 16:01:50 -0400 Subject: [PATCH 01/10] add metric comparison to top panel --- app-config.yaml | 7 +- .../ExampleCostInsightsClient.ts | 44 ++-- .../cost-insights/src/api/CostInsightsApi.ts | 29 +-- .../CostInsightsPage/CostInsightsPage.tsx | 41 ++-- .../CostOverviewCard/CostOverviewCard.tsx | 94 ++++++--- .../CostOverviewCard/CostOverviewChart.tsx | 188 ++++++++++++++++++ .../CostOverviewTooltip.tsx | 21 +- .../CostOverviewChart.test.tsx | 50 ----- .../CostOverviewChart/CostOverviewChart.tsx | 120 ----------- .../src/components/CostOverviewChart/index.ts | 17 -- .../CostOverviewChartLegend.test.tsx | 40 ---- .../CostOverviewChartLegend.tsx | 48 ----- plugins/cost-insights/src/hooks/useConfig.tsx | 29 ++- .../cost-insights/src/hooks/useFilters.tsx | 5 +- .../src/types/ChangeStatistic.ts | 13 ++ .../index.ts => types/ChartData.tsx} | 7 +- plugins/cost-insights/src/types/Cost.ts | 4 + plugins/cost-insights/src/types/Filters.ts | 2 +- plugins/cost-insights/src/types/Metric.ts | 3 +- plugins/cost-insights/src/types/index.ts | 1 + plugins/cost-insights/src/utils/charts.ts | 39 ++++ plugins/cost-insights/src/utils/graphs.tsx | 7 +- 22 files changed, 417 insertions(+), 392 deletions(-) create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx rename plugins/cost-insights/src/components/{CostOverviewChart => CostOverviewCard}/CostOverviewTooltip.tsx (72%) delete mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx delete mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx delete mode 100644 plugins/cost-insights/src/components/CostOverviewChart/index.ts delete mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx delete mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx rename plugins/cost-insights/src/{components/CostOverviewChartLegend/index.ts => types/ChartData.tsx} (84%) create mode 100644 plugins/cost-insights/src/utils/charts.ts diff --git a/app-config.yaml b/app-config.yaml index 228eafc588..aad474ea33 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -237,9 +237,12 @@ costInsights: icon: search metrics: dailyCost: - name: Your Company's Daily Cost + name: Pied Piper's Daily Cost + compare: DAU DAU: - name: Cost Per DAU + name: Daily Active Users + MSC: + name: Monthly Active Subscribers homepage: clocks: - label: UTC diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index aa795e9eee..fc2249fadc 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -28,6 +28,7 @@ import { Group, inclusiveStartDateOf, Maybe, + MetricData, ProductCost, Project, ProjectGrowthAlert, @@ -116,19 +117,33 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return projects; } - async getGroupDailyCost( - group: string, + async getMetricData( metric: string | null, intervals: string, - ): Promise { + ): Promise { const aggregation = aggregationFor( durationOf(intervals), - metric ? 0.3 : 8_000, - ); - const groupDailyCost: Cost = await this.request( - { group, metric, intervals }, + 100_000, + ).map(entry => ({ ...entry, amount: Math.round(entry.amount) })); + + const cost: MetricData = await this.request( + { metric, intervals }, + { + format: 'number', + aggregation: aggregation, + change: changeOf(aggregation), + trendline: trendlineOf(aggregation), + }, + ); + + return cost; + } + + async getGroupDailyCost(group: string, intervals: string): Promise { + const aggregation = aggregationFor(durationOf(intervals), 8_000); + const groupDailyCost: Cost = await this.request( + { group, intervals }, { - id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), @@ -138,17 +153,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return groupDailyCost; } - async getProjectDailyCost( - project: string, - metric: string | null, - intervals: string, - ): Promise { - const aggregation = aggregationFor( - durationOf(intervals), - metric ? 0.1 : 1_500, - ); + async getProjectDailyCost(project: string, intervals: string): Promise { + const aggregation = aggregationFor(durationOf(intervals), 1_500); const projectDailyCost: Cost = await this.request( - { project, metric, intervals }, + { project, intervals }, { id: 'project-a', aggregation: aggregation, diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index bdf673b7d1..52b3f335d9 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -23,6 +23,7 @@ import { Project, ProductCost, Maybe, + MetricData, } from '../types'; export type CostInsightsApi = { @@ -54,16 +55,10 @@ export type CostInsightsApi = { * reduction) and compare it to metrics important to the business. * * @param group The group id from getUserGroups or query parameters - * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend - * should divide the actual daily cost by the corresponding metric for the same date. * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ - getGroupDailyCost( - group: string, - metric: string | null, - intervals: string, - ): Promise; + getGroupDailyCost(group: string, intervals: string): Promise; /** * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar @@ -78,16 +73,21 @@ export type CostInsightsApi = { * (or reduction) and compare it to metrics important to the business. * * @param project The project id from getGroupProjects or query parameters - * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend - * should divide the actual daily cost by the corresponding metric for the same date. * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ - getProjectDailyCost( - project: string, - metric: string | null, - intervals: string, - ): Promise; + getProjectDailyCost(project: string, intervals: string): Promise; + + /** + * Get aggregations for a particular metric and interval timeframe. Teams + * can see metrics important to their business in comparison to the growth + * (or reduction) of a project or group's daily costs. + * + * @param metric A metric from the cost-insights configuration in app-config.yaml. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getMetricData(metric: string | null, intervals: string): Promise; /** * Get cost aggregations for a particular cloud product and interval timeframe. This includes @@ -107,6 +107,7 @@ export type CostInsightsApi = { * 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, diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 8922ac31e0..9bc396537b 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -39,7 +39,14 @@ import { useCurrency, useConfig, } from '../../hooks'; -import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; +import { + Alert, + Cost, + intervalsOf, + Maybe, + MetricData, + Project, +} from '../../types'; import { mapLoadingToProps } from './selector'; import ProjectSelect from '../ProjectSelect'; @@ -48,15 +55,17 @@ const CostInsightsPage = () => { // There is not currently a UI to set feature flags // flags.set('cost-insights-currencies', FeatureFlagState.On); const client = useApi(costInsightsApiRef); - const { currencies } = useConfig(); + const config = useConfig(); const groups = useGroups(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); + const [metricData, setMetricData] = useState>(null); const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); const { pageFilters, setPageFilters } = useFilters(p => p); + const { loadingActions, loadingGroups, @@ -94,26 +103,32 @@ const CostInsightsPage = () => { dispatchLoadingInsights(true); const [ fetchedProjects, - fetchedCosts, fetchedAlerts, + fetchedMetricData, + fetchedCosts, ] = await Promise.all([ client.getGroupProjects(pageFilters.group), + client.getAlerts(pageFilters.group), + pageFilters.metric + ? client.getMetricData( + pageFilters.metric, + intervalsOf(pageFilters.duration), + ) + : null, pageFilters.project ? client.getProjectDailyCost( pageFilters.project, - pageFilters.metric, intervalsOf(pageFilters.duration), ) : client.getGroupDailyCost( pageFilters.group, - pageFilters.metric, intervalsOf(pageFilters.duration), ), - client.getAlerts(pageFilters.group), ]); setProjects(fetchedProjects); - setDailyCost(fetchedCosts); setAlerts(fetchedAlerts); + setMetricData(fetchedMetricData); + setDailyCost(fetchedCosts); } else { dispatchLoadingNone(loadingActions); } @@ -133,11 +148,11 @@ const CostInsightsPage = () => { }, [ client, pageFilters, + loadingActions, loadingGroups, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, - loadingActions, ]); if (loadingInitial) { @@ -166,7 +181,6 @@ const CostInsightsPage = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy if (!dailyCost || !alerts) { return ( @@ -195,7 +209,7 @@ const CostInsightsPage = () => { @@ -253,12 +267,7 @@ const CostInsightsPage = () => { {!!dailyCost.aggregation.length && ( - + )} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index b8d1be5d01..7f270e2374 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -15,42 +15,41 @@ */ import React from 'react'; -import { Box, Card, CardContent, Divider } from '@material-ui/core'; -import CostOverviewChart from '../CostOverviewChart'; -import CostOverviewChartLegend from '../CostOverviewChartLegend'; +import { Box, Card, CardContent, Divider, useTheme } from '@material-ui/core'; +import CostGrowth from '../CostGrowth'; +import CostOverviewChart from './CostOverviewChart'; import CostOverviewHeader from './CostOverviewHeader'; +import LegendItem from '../LegendItem'; import MetricSelect from '../MetricSelect'; import PeriodSelect from '../PeriodSelect'; import { useScroll, useFilters, useConfig } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; +import { formatPercent } from '../../utils/formatters'; import { - ChangeStatistic, - DateAggregation, - Project, - Trendline, + Cost, + CostInsightsTheme, + MetricData, findAlways, + getComparedChange, } from '../../types'; -type CostOverviewCardProps = { - change: ChangeStatistic; - aggregation: Array; - trendline: Trendline; - projects: Array; +export type CostOverviewCardProps = { + data: [Cost, MetricData | null]; }; -const CostOverviewCard = ({ - change, - aggregation, - trendline, -}: CostOverviewCardProps) => { - const { metrics } = useConfig(); +const CostOverviewCard = ({ data }: CostOverviewCardProps) => { + const theme = useTheme(); + const config = useConfig(); const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); - const { setDuration, setProject, metric, setMetric, ...filters } = useFilters( + const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, ); - const { name } = findAlways(metrics, m => m.kind === metric); + // There should always be a daily cost metric but a comparison metric is optional. + const dailyCost = findAlways(config.metrics, m => m.kind === null); + const metric = config.metrics.find(m => m.kind === filters.metric); + const comparedChange = data[1] ? getComparedChange(data[0], data[1]) : null; return ( @@ -60,22 +59,53 @@ const CostOverviewCard = ({ - - + + + + + {formatPercent(data[0].change.ratio)} + + + {metric && metric.kind && data[1] && comparedChange && ( + <> + + + {formatPercent(data[1].change.ratio)} + + + + + + + )} + - + {config.metrics.length > 1 && ( + + )} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx new file mode 100644 index 0000000000..40cefbb589 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx @@ -0,0 +1,188 @@ +/* + * 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 { useTheme } from '@material-ui/core'; +import { + AxisDomain, + ComposedChart, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Line, + ResponsiveContainer, + TooltipPayload, +} from 'recharts'; +import { + ChartData, + Cost, + Metric, + MetricData, + CostInsightsTheme, +} from '../../types'; +import { + overviewGraphTickFormatter, + formatGraphValue, +} from '../../utils/graphs'; +import CostOverviewTooltip from './CostOverviewTooltip'; +import { TooltipItemProps } from '../Tooltip'; +import { NULL_METRIC } from '../../hooks/useConfig'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { groupByDate, toDataMax, trendFrom } from '../../utils/charts'; +import { aggregationSort } from '../../utils/sort'; + +type CostOverviewChartProps = { + data: [Cost, MetricData | null]; + name: string; + compare?: Metric; + responsive?: boolean; +}; + +const CostOverviewChart = ({ + data, + name, + compare, + responsive = true, +}: CostOverviewChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + const { dailyCost, metric } = { + dailyCost: { + id: NULL_METRIC, + name: name, + format: 'number', + data: data[0], + }, + metric: { + id: compare?.kind ?? 'Unknown', + name: compare?.name ?? 'Unknown', + format: data[1]?.format ?? 'number', + data: data[1], + }, + }; + + const metricsByDate = metric.data + ? metric.data.aggregation.reduce(groupByDate, {}) + : {}; + + const chartData: ChartData[] = dailyCost + .data!.aggregation.slice() + .sort(aggregationSort) + .map(entry => ({ + date: Date.parse(entry.date), + trend: trendFrom(dailyCost.data!.trendline, Date.parse(entry.date)), + dailyCost: entry.amount, + ...(metric && metric.data + ? { [metric.id]: metricsByDate[`${entry.date}`] } + : {}), + })); + + const metricDataMax: AxisDomain = metric + ? toDataMax(metric.id, chartData) + : 'dataMax'; + + function tooltipFormatter(payload: TooltipPayload): TooltipItemProps { + return { + label: payload.dataKey === dailyCost.id ? dailyCost.name : metric.name, + value: + payload.dataKey === dailyCost.id + ? formatGraphValue(payload.value as number, dailyCost.format) + : formatGraphValue(payload.value as number, metric.format), + fill: + payload.dataKey === dailyCost.id + ? theme.palette.blue + : theme.palette.magenta, + }; + } + + return ( + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={dailyCost.id} + /> + {metric && ( + 0, metricDataMax]} + width={styles.yAxis.width} + yAxisId={metric.id} + /> + )} + + + {metric && ( + + )} + + } + animationDuration={100} + /> + + + ); +}; + +export default CostOverviewChart; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx similarity index 72% rename from plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx rename to plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx index 17d466441d..a30f370166 100644 --- a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx @@ -16,29 +16,24 @@ import React from 'react'; import moment from 'moment'; import { TooltipPayload, TooltipProps } from 'recharts'; -import Tooltip from '../../components/Tooltip'; +import Tooltip, { TooltipItemProps } from '../../components/Tooltip'; import { DEFAULT_DATE_FORMAT } from '../../types'; -import { formatGraphValue } from '../../utils/graphs'; -type CostOverviewTooltipProps = TooltipProps & { - metric: string; - name: string; +export type CostOverviewTooltipProps = TooltipProps & { + dataKeys: Array; + format: (payload: TooltipPayload) => TooltipItemProps; }; const CostOverviewTooltip = ({ label, payload, - metric, - name, + dataKeys, + format, }: CostOverviewTooltipProps) => { const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT); const items = payload - ?.filter(data => data.name === metric) - .map((data: TooltipPayload) => ({ - label: name, - value: formatGraphValue(data.value as number), - fill: data.fill as string, - })); + ?.filter((p: TooltipPayload) => dataKeys.includes(p.dataKey as string)) + .map(p => format(p)); return ; }; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx deleted file mode 100644 index e6311d147a..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.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 CostOverviewChart from './CostOverviewChart'; -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { DateAggregation, Trendline } from '../../types'; -import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; - -const mockAggregation = [ - { date: '2020-04-01', amount: 100 }, - { date: '2020-04-02', amount: 101 }, - { date: '2020-04-03', amount: 102 }, - { date: '2020-04-04', amount: 103 }, -] as Array; - -const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline; -const mockMetric = 'mock-metric'; - -describe('', () => { - it('Renders without exploding', async () => { - const rendered = await renderInTestApp( - - - , - ); - expect( - rendered.container.querySelector('.cost-overview-chart'), - ).toBeInTheDocument(); - }); -}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx deleted file mode 100644 index 2b40668409..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx +++ /dev/null @@ -1,120 +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 { - ComposedChart, - XAxis, - YAxis, - Tooltip, - CartesianGrid, - Area, - Line, - ResponsiveContainer, -} from 'recharts'; -import { - Maybe, - DateAggregation, - Trendline, - CostInsightsTheme, -} from '../../types'; -import { - overviewGraphTickFormatter, - formatGraphValue, -} from '../../utils/graphs'; -import CostOverviewTooltip from './CostOverviewTooltip'; -import { useTheme } from '@material-ui/core'; -import { useCostOverviewStyles as useStyles } from '../../utils/styles'; -import { NULL_METRIC } from '../../hooks/useConfig'; - -type CostOverviewChartProps = { - responsive: boolean; - aggregation: Array; - trendline?: Maybe; - metric: string | null; - tooltip: string; -}; - -const CostOverviewChart = ({ - responsive = true, - aggregation, - trendline, - metric, - tooltip, -}: CostOverviewChartProps) => { - const theme = useTheme(); - const styles = useStyles(theme); - - const id = metric ? metric : NULL_METRIC; - - const dailyCostData = aggregation.map((entry: DateAggregation) => ({ - date: Date.parse(entry.date), - [id]: entry.amount, - trend: trendline - ? trendline.slope * (Date.parse(entry.date) / 1000) + trendline.intercept - : null, - })); - - return ( - - - - - 0, 'dataMax']} - tick={{ fill: styles.axis.fill }} - tickFormatter={formatGraphValue} - width={styles.yAxis.width} - yAxisId={id} - /> - - - } - animationDuration={100} - /> - - - ); -}; - -export default CostOverviewChart; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/cost-insights/src/components/CostOverviewChart/index.ts deleted file mode 100644 index 21f345a282..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewChart/index.ts +++ /dev/null @@ -1,17 +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. - */ - -export { default } from './CostOverviewChart'; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx deleted file mode 100644 index a40ff58ec1..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx +++ /dev/null @@ -1,40 +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 { renderInTestApp } from '@backstage/test-utils'; -import CostOverviewChartLegend from './CostOverviewChartLegend'; -import React from 'react'; -import { ChangeStatistic } from '../../types'; - -describe('', () => { - it('Correctly displays text if change is not supplied', async () => { - const rendered = await renderInTestApp( - , - ); - expect(rendered.queryByText('Unclear')).toBeInTheDocument(); - }); - it('Correctly displays formatted change percentage', async () => { - const change = { - ratio: 0.3456, - amount: 40000, - } as ChangeStatistic; - const rendered = await renderInTestApp( - , - ); - expect(rendered.queryByText('Unclear')).not.toBeInTheDocument(); - expect(rendered.queryByText('35%')).toBeInTheDocument(); - }); -}); diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx deleted file mode 100644 index 538cd2a63a..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx +++ /dev/null @@ -1,48 +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, useTheme } from '@material-ui/core'; -import LegendItem from '../LegendItem'; -import { formatPercent } from '../../utils/formatters'; -import { ChangeStatistic, CostInsightsTheme } from '../../types'; - -type CostOverviewChartLegendProps = { - change?: ChangeStatistic; - title: string; - tooltip?: string; -}; - -const CostOverviewChartLegend = ({ - change, - title, - tooltip, -}: CostOverviewChartLegendProps) => { - const theme = useTheme(); - - return ( - - - {change ? formatPercent(change.ratio) : 'Unclear'} - - - ); -}; - -export default CostOverviewChartLegend; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index aee6567056..a73a7bf301 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -42,6 +42,9 @@ export const NULL_METRIC_NAME = 'Daily Cost'; * name: Product B * icon: data * metrics: + * dailyCost: + * name: Daily Cost + * compare: metricA * metricA: * name: Metric A * metricB: @@ -89,6 +92,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { return metrics.keys().map(key => ({ kind: key === NULL_METRIC ? null : key, name: metrics.getString(`${key}.name`), + compare: metrics.getOptionalString(`${key}.compare`), })); } @@ -115,23 +119,14 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const engineerCost = getEngineerCost(); const icons = getIcons(); - if (metrics.find((m: Metric) => m.kind === null)) { - setConfig(prevState => ({ - ...prevState, - metrics, - products, - engineerCost, - icons, - })); - } else { - setConfig(prevState => ({ - ...prevState, - metrics: [...prevState.metrics, ...metrics], - products, - engineerCost, - icons, - })); - } + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); + setLoading(false); } diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx index c2053d6b0f..0ad4c24267 100644 --- a/plugins/cost-insights/src/hooks/useFilters.tsx +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -69,6 +69,7 @@ export const FilterContext = React.createContext< >(undefined); export const FilterProvider = ({ children }: FilterProviderProps) => { + const config = useConfig(); const navigate = useNavigate(); const location = useLocation(); const queryParams = useQueryParams(); @@ -101,7 +102,9 @@ export const FilterProvider = ({ children }: FilterProviderProps) => { // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. useEffect(() => { - setPageFilters(getInitialPageState(groups, queryParams.pageFilters)); + const initialState = getInitialPageState(groups, queryParams.pageFilters); + const compared = config.metrics.find(m => m.kind === null)?.compare; + setPageFilters({ ...initialState, metric: compared || null }); }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index 538692bb1d..cf7310ef97 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { Cost } from './Cost'; +import { aggregationSort } from '../utils/sort'; + export interface ChangeStatistic { // The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum ratio: number; @@ -46,3 +49,13 @@ export function growthOf(amount: number, ratio: number) { return Growth.Negligible; } + +// Used by for displaying engineer totals +export function getComparedChange(a: Cost, b: Cost): ChangeStatistic { + const ratio = a.change.ratio - b.change.ratio; + const amount = a.aggregation.slice().sort(aggregationSort)[0].amount; + return { + ratio: ratio, + amount: amount * ratio, + }; +} diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts b/plugins/cost-insights/src/types/ChartData.tsx similarity index 84% rename from plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts rename to plugins/cost-insights/src/types/ChartData.tsx index 1b6c1fe975..d99e9d9bc3 100644 --- a/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts +++ b/plugins/cost-insights/src/types/ChartData.tsx @@ -14,4 +14,9 @@ * limitations under the License. */ -export { default } from './CostOverviewChartLegend'; +export type ChartData = { + date: number; + trend: number; + dailyCost: number; + [key: string]: number; +}; diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index 84a8bfae51..8a9ce3c69b 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -24,3 +24,7 @@ export interface Cost { change: ChangeStatistic; trendline: Trendline; } + +export interface MetricData extends Cost { + format: 'number' | 'currency'; +} diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts index def1d74d1a..f90b9b4c78 100644 --- a/plugins/cost-insights/src/types/Filters.ts +++ b/plugins/cost-insights/src/types/Filters.ts @@ -22,7 +22,7 @@ export interface PageFilters { group: Maybe; project: Maybe; duration: Duration; - metric: Maybe; + metric: string | null; } export type ProductFilters = Array; diff --git a/plugins/cost-insights/src/types/Metric.ts b/plugins/cost-insights/src/types/Metric.ts index 5a440a8d1f..4fa9cd189b 100644 --- a/plugins/cost-insights/src/types/Metric.ts +++ b/plugins/cost-insights/src/types/Metric.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { Maybe } from './Maybe'; +import { Maybe } from '../types'; export type Metric = { kind: Maybe; name: string; + compare?: string; }; diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts index c3d9a87886..8523c7db7b 100644 --- a/plugins/cost-insights/src/types/index.ts +++ b/plugins/cost-insights/src/types/index.ts @@ -16,6 +16,7 @@ export * from './Alert'; export * from './ChangeStatistic'; +export * from './ChartData'; export * from './Cost'; export * from './DateAggregation'; export * from './Duration'; diff --git a/plugins/cost-insights/src/utils/charts.ts b/plugins/cost-insights/src/utils/charts.ts new file mode 100644 index 0000000000..60a8e85816 --- /dev/null +++ b/plugins/cost-insights/src/utils/charts.ts @@ -0,0 +1,39 @@ +/* + * 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 { DateAggregation, Trendline, ChartData } from '../types'; + +export function trendFrom(trendline: Trendline, date: number): number { + return trendline.slope * (date / 1000) + trendline.intercept; +} + +export function groupByDate( + acc: Record, + entry: DateAggregation, +): Record { + return { ...acc, [entry.date]: entry.amount }; +} + +export function toMaxCost(acc: ChartData, entry: ChartData): ChartData { + return acc.dailyCost > entry.dailyCost ? acc : entry; +} + +export function toDataMax(metric: string, data: ChartData[]): number { + return ( + (data.reduce(toMaxCost).dailyCost / Math.abs(data[0].trend)) * + data[0][metric] + ); +} diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/plugins/cost-insights/src/utils/graphs.tsx index 5200b14b1f..167132cb36 100644 --- a/plugins/cost-insights/src/utils/graphs.tsx +++ b/plugins/cost-insights/src/utils/graphs.tsx @@ -20,10 +20,15 @@ import { lengthyCurrencyFormatter, } from './formatters'; -export function formatGraphValue(value: number) { +export function formatGraphValue(value: number, format?: string) { + if (format === 'number') { + return value.toLocaleString(); + } + if (value < 1) { return lengthyCurrencyFormatter.format(value); } + return currencyFormatter.format(value); } From 94b248e6826bfb9301b2312601e8743e578c94b2 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 13 Oct 2020 16:25:24 -0400 Subject: [PATCH 02/10] set default null metric --- plugins/cost-insights/src/hooks/useConfig.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index a73a7bf301..830cafc4f1 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -119,13 +119,23 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const engineerCost = getEngineerCost(); const icons = getIcons(); - setConfig(prevState => ({ - ...prevState, - metrics, - products, - engineerCost, - icons, - })); + if (metrics.find((m: Metric) => m.kind === null)) { + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); + } else { + setConfig(prevState => ({ + ...prevState, + metrics: [...prevState.metrics, ...metrics], + products, + engineerCost, + icons, + })); + } setLoading(false); } From 54504deb135ce71395ad8a5b5505575dac7e220f Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 13 Oct 2020 16:43:45 -0400 Subject: [PATCH 03/10] tweak custom metric verbiage --- app-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-config.yaml b/app-config.yaml index aad474ea33..d1414582ed 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -242,7 +242,7 @@ costInsights: DAU: name: Daily Active Users MSC: - name: Monthly Active Subscribers + name: Monthly Subscribers homepage: clocks: - label: UTC From 22e1195419d3c39923fb46aebf85fb900a5db636 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Wed, 14 Oct 2020 14:18:34 -0400 Subject: [PATCH 04/10] cost -> cost trend --- .../src/components/CostOverviewCard/CostOverviewCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 7f270e2374..e7d2bbf5be 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -63,7 +63,7 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => { {formatPercent(data[0].change.ratio)} From 921d705d510f00b6dc4c90516d478dbadb8fbaf5 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Wed, 14 Oct 2020 14:21:33 -0400 Subject: [PATCH 05/10] use ratio for engineer cost display --- .../src/components/CostOverviewCard/CostOverviewCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index e7d2bbf5be..daffe335c5 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -81,7 +81,7 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => { Date: Fri, 16 Oct 2020 09:54:28 -0400 Subject: [PATCH 06/10] compare -> default; deprecate dailyCost as metric; cleanup --- app-config.yaml | 4 +- .../ExampleCostInsightsClient.ts | 5 +- plugins/cost-insights/README.md | 7 +- .../cost-insights/src/api/CostInsightsApi.ts | 5 +- .../CostInsightsNavigation.test.tsx | 1 + .../CostInsightsPage/CostInsightsPage.tsx | 25 +++--- .../CostOverviewCard/CostOverviewCard.tsx | 37 +++++---- .../CostOverviewCard/CostOverviewChart.tsx | 83 +++++++++---------- .../MetricSelect/MetricSelect.test.tsx | 26 ++---- .../components/MetricSelect/MetricSelect.tsx | 54 ++++++------ plugins/cost-insights/src/hooks/useConfig.tsx | 49 ++++------- .../cost-insights/src/hooks/useFilters.tsx | 7 +- .../src/types/ChangeStatistic.ts | 10 ++- plugins/cost-insights/src/types/Cost.ts | 4 - plugins/cost-insights/src/types/Metric.ts | 6 +- plugins/cost-insights/src/types/MetricData.ts | 25 ++++++ plugins/cost-insights/src/types/index.ts | 1 + plugins/cost-insights/src/utils/config.ts | 32 +++++++ 18 files changed, 196 insertions(+), 185 deletions(-) create mode 100644 plugins/cost-insights/src/types/MetricData.ts create mode 100644 plugins/cost-insights/src/utils/config.ts diff --git a/app-config.yaml b/app-config.yaml index d1414582ed..966f067ff5 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -236,11 +236,9 @@ costInsights: name: Big Query icon: search metrics: - dailyCost: - name: Pied Piper's Daily Cost - compare: DAU DAU: name: Daily Active Users + default: true MSC: name: Monthly Subscribers homepage: diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index fc2249fadc..0b275baf74 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -117,10 +117,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return projects; } - async getMetricData( - metric: string | null, - intervals: string, - ): Promise { + async getMetricData(metric: string, intervals: string): Promise { const aggregation = aggregationFor( durationOf(intervals), 100_000, diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 26f9d9371a..3028d3a09c 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -79,9 +79,9 @@ costInsights: ### Metrics (Optional) -In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter. +In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getMetricData` API method via the `metric` parameter. -**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overridden by assigning it a value on the `dailyCost` field. +An optional `default` field can be set to `true` to set the default comparison metric to daily cost in the Cost Overview panel. ```yaml ## ./app-config.yaml @@ -95,10 +95,9 @@ costInsights: name: Some Other Cloud Product icon: data metrics: - dailyCost: - name: Earth Rotation metricA: name: Metric A ## required + default: true metricB: name: Metric B metricC: diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 52b3f335d9..3136fb4c27 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -87,7 +87,7 @@ export type CostInsightsApi = { * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ - getMetricData(metric: string | null, intervals: string): Promise; + getMetricData(metric: string, intervals: string): Promise; /** * Get cost aggregations for a particular cloud product and interval timeframe. This includes @@ -104,10 +104,9 @@ export type CostInsightsApi = { * @param product The product from the cost-insights configuration in app-config.yaml * @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. + * 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, diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx index 3ec2828029..7ca4958c8e 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx @@ -40,6 +40,7 @@ const mockMetrics: Metric[] = [ { kind: 'some-metric', name: 'Some Metric', + default: false, }, ]; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 9bc396537b..20f965f1c7 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -101,34 +101,26 @@ const CostInsightsPage = () => { try { if (pageFilters.group) { dispatchLoadingInsights(true); + const intervals = intervalsOf(pageFilters.duration); const [ fetchedProjects, fetchedAlerts, fetchedMetricData, - fetchedCosts, + fetchedDailyCost, ] = await Promise.all([ client.getGroupProjects(pageFilters.group), client.getAlerts(pageFilters.group), pageFilters.metric - ? client.getMetricData( - pageFilters.metric, - intervalsOf(pageFilters.duration), - ) + ? client.getMetricData(pageFilters.metric, intervals) : null, pageFilters.project - ? client.getProjectDailyCost( - pageFilters.project, - intervalsOf(pageFilters.duration), - ) - : client.getGroupDailyCost( - pageFilters.group, - intervalsOf(pageFilters.duration), - ), + ? client.getProjectDailyCost(pageFilters.project, intervals) + : client.getGroupDailyCost(pageFilters.group, intervals), ]); setProjects(fetchedProjects); setAlerts(fetchedAlerts); setMetricData(fetchedMetricData); - setDailyCost(fetchedCosts); + setDailyCost(fetchedDailyCost); } else { dispatchLoadingNone(loadingActions); } @@ -267,7 +259,10 @@ const CostInsightsPage = () => { {!!dailyCost.aggregation.length && ( - + )} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index daffe335c5..a08e7fc789 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -35,10 +35,14 @@ import { } from '../../types'; export type CostOverviewCardProps = { - data: [Cost, MetricData | null]; + dailyCostData: Cost; + metricData: MetricData | null; }; -const CostOverviewCard = ({ data }: CostOverviewCardProps) => { +const CostOverviewCard = ({ + dailyCostData, + metricData, +}: CostOverviewCardProps) => { const theme = useTheme(); const config = useConfig(); const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); @@ -46,10 +50,12 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => { mapFiltersToProps, ); - // There should always be a daily cost metric but a comparison metric is optional. - const dailyCost = findAlways(config.metrics, m => m.kind === null); - const metric = config.metrics.find(m => m.kind === filters.metric); - const comparedChange = data[1] ? getComparedChange(data[0], data[1]) : null; + const metric = filters.metric + ? findAlways(config.metrics, m => m.kind === filters.metric) + : null; + const comparedChange = metricData + ? getComparedChange(dailyCostData, metricData) + : null; return ( @@ -62,21 +68,18 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => { - - {formatPercent(data[0].change.ratio)} + + {formatPercent(dailyCostData.change.ratio)} - {metric && metric.kind && data[1] && comparedChange && ( + {metric && metricData && comparedChange && ( <> - {formatPercent(data[1].change.ratio)} + {formatPercent(metricData.change.ratio)} { )} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx index 40cefbb589..9612589542 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { useTheme } from '@material-ui/core'; import { - AxisDomain, ComposedChart, XAxis, YAxis, @@ -30,6 +29,7 @@ import { import { ChartData, Cost, + Maybe, Metric, MetricData, CostInsightsTheme, @@ -40,71 +40,69 @@ import { } from '../../utils/graphs'; import CostOverviewTooltip from './CostOverviewTooltip'; import { TooltipItemProps } from '../Tooltip'; -import { NULL_METRIC } from '../../hooks/useConfig'; import { useCostOverviewStyles as useStyles } from '../../utils/styles'; import { groupByDate, toDataMax, trendFrom } from '../../utils/charts'; import { aggregationSort } from '../../utils/sort'; type CostOverviewChartProps = { - data: [Cost, MetricData | null]; - name: string; - compare?: Metric; + metric: Maybe; + metricData: Maybe; + dailyCostData: Cost; responsive?: boolean; }; const CostOverviewChart = ({ - data, - name, - compare, + dailyCostData, + metric, + metricData, responsive = true, }: CostOverviewChartProps) => { const theme = useTheme(); const styles = useStyles(theme); - const { dailyCost, metric } = { + const data = { dailyCost: { - id: NULL_METRIC, - name: name, - format: 'number', - data: data[0], + dataKey: 'dailyCost', + name: `Daily Cost`, + format: 'currency', + data: dailyCostData, }, metric: { - id: compare?.kind ?? 'Unknown', - name: compare?.name ?? 'Unknown', - format: data[1]?.format ?? 'number', - data: data[1], + dataKey: metric?.kind ?? 'Unknown', + name: metric?.name ?? 'Unknown', + format: metricData?.format ?? 'number', + data: metricData, }, }; - const metricsByDate = metric.data - ? metric.data.aggregation.reduce(groupByDate, {}) + const metricsByDate = data.metric.data + ? data.metric.data.aggregation.reduce(groupByDate, {}) : {}; - const chartData: ChartData[] = dailyCost - .data!.aggregation.slice() + const chartData: ChartData[] = data.dailyCost.data.aggregation + .slice() .sort(aggregationSort) .map(entry => ({ date: Date.parse(entry.date), - trend: trendFrom(dailyCost.data!.trendline, Date.parse(entry.date)), + trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)), dailyCost: entry.amount, - ...(metric && metric.data - ? { [metric.id]: metricsByDate[`${entry.date}`] } + ...(metric && data.metric.data + ? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] } : {}), })); - const metricDataMax: AxisDomain = metric - ? toDataMax(metric.id, chartData) - : 'dataMax'; - function tooltipFormatter(payload: TooltipPayload): TooltipItemProps { return { - label: payload.dataKey === dailyCost.id ? dailyCost.name : metric.name, + label: + payload.dataKey === data.dailyCost.dataKey + ? data.dailyCost.name + : data.metric.name, value: - payload.dataKey === dailyCost.id - ? formatGraphValue(payload.value as number, dailyCost.format) - : formatGraphValue(payload.value as number, metric.format), + payload.dataKey === data.dailyCost.dataKey + ? formatGraphValue(payload.value as number, data.dailyCost.format) + : formatGraphValue(payload.value as number, data.metric.format), fill: - payload.dataKey === dailyCost.id + payload.dataKey === data.dailyCost.dataKey ? theme.palette.blue : theme.palette.magenta, }; @@ -131,23 +129,23 @@ const CostOverviewChart = ({ tick={{ fill: styles.axis.fill }} tickFormatter={formatGraphValue} width={styles.yAxis.width} - yAxisId={dailyCost.id} + yAxisId={data.dailyCost.dataKey} /> {metric && ( 0, metricDataMax]} + domain={[() => 0, toDataMax(data.metric.dataKey, chartData)]} width={styles.yAxis.width} - yAxisId={metric.id} + yAxisId={data.metric.dataKey} /> )} {metric && ( )} } diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx index 67d1ec3ad3..e78fd77b1a 100644 --- a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx @@ -23,7 +23,7 @@ describe('', () => { it('should display a metric', async () => { const mockProps: MetricSelectProps = { metric: 'test', - metrics: [{ kind: 'test', name: 'some-name' }], + metrics: [{ kind: 'test', name: 'some-name', default: false }], onSelect: jest.fn(), }; const { getByText } = await renderInTestApp( @@ -32,25 +32,12 @@ describe('', () => { expect(getByText(/some-name/)).toBeInTheDocument(); }); - it('should display a null metric', async () => { - const mockProps: MetricSelectProps = { - metric: null, - metrics: [{ kind: null, name: 'billie-nullish' }], - onSelect: jest.fn(), - }; - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/billie-nullish/)).toBeInTheDocument(); - }); - it('should display all metrics', async () => { const mockProps: MetricSelectProps = { metric: null, metrics: [ - { kind: null, name: 'billie-nullish' }, - { kind: 'MAU1M', name: 'Cost Per Million MAU' }, - { kind: 'my-cool-metric', name: 'metric-mcmetric-face' }, + { kind: 'DAU', name: 'Daily Active Users', default: true }, + { kind: 'MSC', name: 'Monthly Subscribers', default: false }, ], onSelect: jest.fn(), }; @@ -61,11 +48,10 @@ describe('', () => { UserEvent.click(button); - await waitFor(() => getAllByText(/billie-nullish/)); + await waitFor(() => getAllByText(/None/)); // The active metric should display in the popver list and in the input - expect(getAllByText(/billie-nullish/).length).toBe(2); - expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument(); - expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument(); + expect(getByText(/Daily Active Users/)).toBeInTheDocument(); + expect(getByText(/Monthly Subscribers/)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx index 1d3af911d4..474d8622ea 100644 --- a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx @@ -15,10 +15,9 @@ */ import React from 'react'; -import { Select, MenuItem } from '@material-ui/core'; -import { Maybe, Metric, findAlways } from '../../types'; +import { InputLabel, FormControl, Select, MenuItem } from '@material-ui/core'; +import { Maybe, Metric } from '../../types'; import { useSelectStyles as useStyles } from '../../utils/styles'; -import { NULL_METRIC } from '../../hooks/useConfig'; export type MetricSelectProps = { metric: Maybe; @@ -29,38 +28,37 @@ export type MetricSelectProps = { const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => { const classes = useStyles(); - const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { - if (e.target.value === NULL_METRIC) { + function onChange(e: React.ChangeEvent<{ value: unknown }>) { + if (e.target.value === 'none') { onSelect(null); } else { onSelect(e.target.value as string); } - }; - - const renderValue = (value: unknown) => { - const kind = (value === NULL_METRIC ? null : value) as Maybe; - const { name } = findAlways(metrics, m => m.kind === kind); - return {name}; - }; + } return ( - + + None - ))} - + {metrics.map((m: Metric) => ( + + {m.name} + + ))} + + ); }; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index 830cafc4f1..6cbdd1a2ef 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -25,12 +25,10 @@ import { useApi, configApiRef } from '@backstage/core'; import { Config as BackstageConfig } from '@backstage/config'; import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; import { getIcon } from '../utils/navigation'; - -export const NULL_METRIC = 'dailyCost'; -export const NULL_METRIC_NAME = 'Daily Cost'; +import { validateMetrics } from '../utils/config'; /* - * Config schema 2020-09-28 + * Config schema 2020-10-15 * * costInsights: * engineerCost: 200000 @@ -42,11 +40,9 @@ export const NULL_METRIC_NAME = 'Daily Cost'; * name: Product B * icon: data * metrics: - * dailyCost: - * name: Daily Cost - * compare: metricA * metricA: * name: Metric A + * default: true * metricB: * name: Metric B */ @@ -64,7 +60,7 @@ export const ConfigContext = createContext( ); const defaultState: ConfigContextProps = { - metrics: [{ kind: null, name: NULL_METRIC_NAME }], + metrics: [], products: [], icons: [], engineerCost: 0, @@ -90,9 +86,9 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const metrics = c.getOptionalConfig('costInsights.metrics'); if (metrics) { return metrics.keys().map(key => ({ - kind: key === NULL_METRIC ? null : key, + kind: key, name: metrics.getString(`${key}.name`), - compare: metrics.getOptionalString(`${key}.compare`), + default: metrics.getOptionalBoolean(`${key}.default`) ?? false, })); } @@ -119,23 +115,15 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const engineerCost = getEngineerCost(); const icons = getIcons(); - if (metrics.find((m: Metric) => m.kind === null)) { - setConfig(prevState => ({ - ...prevState, - metrics, - products, - engineerCost, - icons, - })); - } else { - setConfig(prevState => ({ - ...prevState, - metrics: [...prevState.metrics, ...metrics], - products, - engineerCost, - icons, - })); - } + validateMetrics(metrics); + + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); setLoading(false); } @@ -154,12 +142,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { export function useConfig(): ConfigContextProps { const config = useContext(ConfigContext); - - if (!config) { - assertNever(); - } - - return config; + return config ? config : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx index 0ad4c24267..7dcf8ddb26 100644 --- a/plugins/cost-insights/src/hooks/useFilters.tsx +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -75,9 +75,8 @@ export const FilterProvider = ({ children }: FilterProviderProps) => { const queryParams = useQueryParams(); const qsRef = useRef(''); const groups = useGroups(); - const { products } = useConfig(); - const defaultProductFilters = products.map(product => ({ + const defaultProductFilters = config.products.map(product => ({ productType: product.kind, duration: Duration.P1M, })); @@ -103,8 +102,8 @@ export const FilterProvider = ({ children }: FilterProviderProps) => { // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. useEffect(() => { const initialState = getInitialPageState(groups, queryParams.pageFilters); - const compared = config.metrics.find(m => m.kind === null)?.compare; - setPageFilters({ ...initialState, metric: compared || null }); + const defaultMetric = config.metrics.find(m => m.default); + setPageFilters({ ...initialState, metric: defaultMetric?.kind ?? null }); }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index cf7310ef97..cdd2e02da6 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -15,6 +15,7 @@ */ import { Cost } from './Cost'; +import { MetricData } from './MetricData'; import { aggregationSort } from '../utils/sort'; export interface ChangeStatistic { @@ -51,9 +52,12 @@ export function growthOf(amount: number, ratio: number) { } // Used by for displaying engineer totals -export function getComparedChange(a: Cost, b: Cost): ChangeStatistic { - const ratio = a.change.ratio - b.change.ratio; - const amount = a.aggregation.slice().sort(aggregationSort)[0].amount; +export function getComparedChange( + dailyCost: Cost, + metricData: MetricData, +): ChangeStatistic { + const ratio = dailyCost.change.ratio - metricData.change.ratio; + const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount; return { ratio: ratio, amount: amount * ratio, diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index 8a9ce3c69b..84a8bfae51 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -24,7 +24,3 @@ export interface Cost { change: ChangeStatistic; trendline: Trendline; } - -export interface MetricData extends Cost { - format: 'number' | 'currency'; -} diff --git a/plugins/cost-insights/src/types/Metric.ts b/plugins/cost-insights/src/types/Metric.ts index 4fa9cd189b..b0a0c9cc76 100644 --- a/plugins/cost-insights/src/types/Metric.ts +++ b/plugins/cost-insights/src/types/Metric.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import { Maybe } from '../types'; - export type Metric = { - kind: Maybe; + kind: string; name: string; - compare?: string; + default: boolean; }; diff --git a/plugins/cost-insights/src/types/MetricData.ts b/plugins/cost-insights/src/types/MetricData.ts new file mode 100644 index 0000000000..bd3bdde735 --- /dev/null +++ b/plugins/cost-insights/src/types/MetricData.ts @@ -0,0 +1,25 @@ +/* + * 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 { DateAggregation } from './DateAggregation'; +import { ChangeStatistic } from './ChangeStatistic'; + +export interface MetricData { + id: string; + format: 'number' | 'currency'; + aggregation: DateAggregation[]; + change: ChangeStatistic; +} diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts index 8523c7db7b..398110ec80 100644 --- a/plugins/cost-insights/src/types/index.ts +++ b/plugins/cost-insights/src/types/index.ts @@ -27,6 +27,7 @@ export * from './Filters'; export * from './Group'; export * from './Loading'; export * from './Maybe'; +export * from './MetricData'; export * from './Metric'; export * from './Product'; export * from './Project'; diff --git a/plugins/cost-insights/src/utils/config.ts b/plugins/cost-insights/src/utils/config.ts new file mode 100644 index 0000000000..c2c2c066d9 --- /dev/null +++ b/plugins/cost-insights/src/utils/config.ts @@ -0,0 +1,32 @@ +/* + * 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 { Metric } from '../types'; + +export function validateMetrics(metrics: Metric[]) { + let defaultMetric = null; + for (const metric of metrics) { + if (metric.default) { + if (defaultMetric) { + throw new Error( + `Cannot set default for multiple metrics: Received: ${defaultMetric.kind} and ${metric.kind}`, + ); + } else { + defaultMetric = metric; + } + } + } +} From a508287b9c36ac0d6664d7837b7aeaa94d42418e Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 16 Oct 2020 10:30:48 -0400 Subject: [PATCH 07/10] throw errors for multiple defaults --- plugins/cost-insights/src/utils/config.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/plugins/cost-insights/src/utils/config.ts b/plugins/cost-insights/src/utils/config.ts index c2c2c066d9..4af7140d9c 100644 --- a/plugins/cost-insights/src/utils/config.ts +++ b/plugins/cost-insights/src/utils/config.ts @@ -17,16 +17,10 @@ import { Metric } from '../types'; export function validateMetrics(metrics: Metric[]) { - let defaultMetric = null; - for (const metric of metrics) { - if (metric.default) { - if (defaultMetric) { - throw new Error( - `Cannot set default for multiple metrics: Received: ${defaultMetric.kind} and ${metric.kind}`, - ); - } else { - defaultMetric = metric; - } - } + const defaults = metrics.filter(metric => metric.default); + if (defaults.length > 1) { + throw new Error( + `Only one default metric can be set at a time. Found ${defaults.length}`, + ); } } From e62956f96f3fbd4ec13f1829752fd009b807d1e4 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 16 Oct 2020 11:02:11 -0400 Subject: [PATCH 08/10] getMetricData -> getDailyMetricData --- .../src/plugins/cost-insights/ExampleCostInsightsClient.ts | 5 ++++- plugins/cost-insights/src/api/CostInsightsApi.ts | 2 +- .../src/components/CostInsightsPage/CostInsightsPage.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index 0b275baf74..a6ef15f3b9 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -117,7 +117,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return projects; } - async getMetricData(metric: string, intervals: string): Promise { + async getDailyMetricData( + metric: string, + intervals: string, + ): Promise { const aggregation = aggregationFor( durationOf(intervals), 100_000, diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 3136fb4c27..028aead853 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -87,7 +87,7 @@ export type CostInsightsApi = { * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ - getMetricData(metric: string, intervals: string): Promise; + getDailyMetricData(metric: string, intervals: string): Promise; /** * Get cost aggregations for a particular cloud product and interval timeframe. This includes diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 20f965f1c7..77d9f5a05e 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -111,7 +111,7 @@ const CostInsightsPage = () => { client.getGroupProjects(pageFilters.group), client.getAlerts(pageFilters.group), pageFilters.metric - ? client.getMetricData(pageFilters.metric, intervals) + ? client.getDailyMetricData(pageFilters.metric, intervals) : null, pageFilters.project ? client.getProjectDailyCost(pageFilters.project, intervals) From 6dba5f6b60ace9113abef852ae2cf24ddfbee368 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 16 Oct 2020 11:22:39 -0400 Subject: [PATCH 09/10] update README --- plugins/cost-insights/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 3028d3a09c..8c2fd11761 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -79,7 +79,7 @@ costInsights: ### Metrics (Optional) -In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getMetricData` API method via the `metric` parameter. +In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyMetricData` API method via the `metric` parameter. An optional `default` field can be set to `true` to set the default comparison metric to daily cost in the Cost Overview panel. From e7d4ac7ce3137a01336c15a12eab46ee66db5f3e Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 16 Oct 2020 11:23:04 -0400 Subject: [PATCH 10/10] add changeset --- .changeset/red-games-float.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/red-games-float.md diff --git a/.changeset/red-games-float.md b/.changeset/red-games-float.md new file mode 100644 index 0000000000..6eabba26a4 --- /dev/null +++ b/.changeset/red-games-float.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +- getProjectDailyCost and getGroupDailyCost no longer accept a metric as a parameter +- getDailyMetricData added to API for fetching daily metric data for given interval +- dailyCost removed as configurable metric +- default field added to metric configuration for displaying comparison metric data in top panel +- Metric.kind can no longer be null +- MetricData type added