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 diff --git a/app-config.yaml b/app-config.yaml index 12fe211a4b..0d348ef51e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -240,10 +240,11 @@ costInsights: name: Big Query icon: search metrics: - dailyCost: - name: Your Company's Daily Cost DAU: - name: Cost Per DAU + name: Daily Active Users + default: true + MSC: + name: Monthly 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..a6ef15f3b9 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, - metric: string | null, + async getDailyMetricData( + metric: string, 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/README.md b/plugins/cost-insights/README.md index 26f9d9371a..8c2fd11761 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 `getDailyMetricData` 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 bdf673b7d1..028aead853 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 + */ + getDailyMetricData(metric: string, intervals: string): Promise; /** * Get cost aggregations for a particular cloud product and interval timeframe. This includes @@ -104,7 +104,7 @@ 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( 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 8922ac31e0..77d9f5a05e 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, @@ -92,28 +101,26 @@ const CostInsightsPage = () => { try { if (pageFilters.group) { dispatchLoadingInsights(true); + const intervals = intervalsOf(pageFilters.duration); const [ fetchedProjects, - fetchedCosts, fetchedAlerts, + fetchedMetricData, + fetchedDailyCost, ] = await Promise.all([ client.getGroupProjects(pageFilters.group), - pageFilters.project - ? client.getProjectDailyCost( - pageFilters.project, - pageFilters.metric, - intervalsOf(pageFilters.duration), - ) - : client.getGroupDailyCost( - pageFilters.group, - pageFilters.metric, - intervalsOf(pageFilters.duration), - ), client.getAlerts(pageFilters.group), + pageFilters.metric + ? client.getDailyMetricData(pageFilters.metric, intervals) + : null, + pageFilters.project + ? client.getProjectDailyCost(pageFilters.project, intervals) + : client.getGroupDailyCost(pageFilters.group, intervals), ]); setProjects(fetchedProjects); - setDailyCost(fetchedCosts); setAlerts(fetchedAlerts); + setMetricData(fetchedMetricData); + setDailyCost(fetchedDailyCost); } else { dispatchLoadingNone(loadingActions); } @@ -133,11 +140,11 @@ const CostInsightsPage = () => { }, [ client, pageFilters, + loadingActions, loadingGroups, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, - loadingActions, ]); if (loadingInitial) { @@ -166,7 +173,6 @@ const CostInsightsPage = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy if (!dailyCost || !alerts) { return ( @@ -195,7 +201,7 @@ const CostInsightsPage = () => { @@ -254,10 +260,8 @@ 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..a08e7fc789 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -15,42 +15,47 @@ */ 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 = { + dailyCostData: Cost; + metricData: MetricData | null; }; const CostOverviewCard = ({ - change, - aggregation, - trendline, + dailyCostData, + metricData, }: CostOverviewCardProps) => { - const { metrics } = useConfig(); + 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); + const metric = filters.metric + ? findAlways(config.metrics, m => m.kind === filters.metric) + : null; + const comparedChange = metricData + ? getComparedChange(dailyCostData, metricData) + : null; return ( @@ -60,22 +65,50 @@ const CostOverviewCard = ({ - - + + + + + {formatPercent(dailyCostData.change.ratio)} + + + {metric && metricData && comparedChange && ( + <> + + + {formatPercent(metricData.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..9612589542 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx @@ -0,0 +1,185 @@ +/* + * 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 { + ComposedChart, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Line, + ResponsiveContainer, + TooltipPayload, +} from 'recharts'; +import { + ChartData, + Cost, + Maybe, + Metric, + MetricData, + CostInsightsTheme, +} from '../../types'; +import { + overviewGraphTickFormatter, + formatGraphValue, +} from '../../utils/graphs'; +import CostOverviewTooltip from './CostOverviewTooltip'; +import { TooltipItemProps } from '../Tooltip'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { groupByDate, toDataMax, trendFrom } from '../../utils/charts'; +import { aggregationSort } from '../../utils/sort'; + +type CostOverviewChartProps = { + metric: Maybe; + metricData: Maybe; + dailyCostData: Cost; + responsive?: boolean; +}; + +const CostOverviewChart = ({ + dailyCostData, + metric, + metricData, + responsive = true, +}: CostOverviewChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + const data = { + dailyCost: { + dataKey: 'dailyCost', + name: `Daily Cost`, + format: 'currency', + data: dailyCostData, + }, + metric: { + dataKey: metric?.kind ?? 'Unknown', + name: metric?.name ?? 'Unknown', + format: metricData?.format ?? 'number', + data: metricData, + }, + }; + + const metricsByDate = data.metric.data + ? data.metric.data.aggregation.reduce(groupByDate, {}) + : {}; + + const chartData: ChartData[] = data.dailyCost.data.aggregation + .slice() + .sort(aggregationSort) + .map(entry => ({ + date: Date.parse(entry.date), + trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)), + dailyCost: entry.amount, + ...(metric && data.metric.data + ? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] } + : {}), + })); + + function tooltipFormatter(payload: TooltipPayload): TooltipItemProps { + return { + label: + payload.dataKey === data.dailyCost.dataKey + ? data.dailyCost.name + : data.metric.name, + value: + payload.dataKey === data.dailyCost.dataKey + ? formatGraphValue(payload.value as number, data.dailyCost.format) + : formatGraphValue(payload.value as number, data.metric.format), + fill: + payload.dataKey === data.dailyCost.dataKey + ? theme.palette.blue + : theme.palette.magenta, + }; + } + + return ( + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={data.dailyCost.dataKey} + /> + {metric && ( + 0, toDataMax(data.metric.dataKey, chartData)]} + width={styles.yAxis.width} + yAxisId={data.metric.dataKey} + /> + )} + + + {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/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/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 aee6567056..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 @@ -44,6 +42,7 @@ export const NULL_METRIC_NAME = 'Daily Cost'; * metrics: * metricA: * name: Metric A + * default: true * metricB: * name: Metric B */ @@ -61,7 +60,7 @@ export const ConfigContext = createContext( ); const defaultState: ConfigContextProps = { - metrics: [{ kind: null, name: NULL_METRIC_NAME }], + metrics: [], products: [], icons: [], engineerCost: 0, @@ -87,8 +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`), + default: metrics.getOptionalBoolean(`${key}.default`) ?? false, })); } @@ -115,23 +115,16 @@ 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); } @@ -149,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 c2053d6b0f..7dcf8ddb26 100644 --- a/plugins/cost-insights/src/hooks/useFilters.tsx +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -69,14 +69,14 @@ export const FilterContext = React.createContext< >(undefined); export const FilterProvider = ({ children }: FilterProviderProps) => { + const config = useConfig(); const navigate = useNavigate(); const location = useLocation(); 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, })); @@ -101,7 +101,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 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 538692bb1d..cdd2e02da6 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { Cost } from './Cost'; +import { MetricData } from './MetricData'; +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 +50,16 @@ export function growthOf(amount: number, ratio: number) { return Growth.Negligible; } + +// Used by for displaying engineer totals +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/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/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..b0a0c9cc76 100644 --- a/plugins/cost-insights/src/types/Metric.ts +++ b/plugins/cost-insights/src/types/Metric.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { Maybe } from './Maybe'; - export type Metric = { - kind: Maybe; + kind: string; name: string; + default: boolean; }; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/cost-insights/src/types/MetricData.ts similarity index 70% rename from plugins/cost-insights/src/components/CostOverviewChart/index.ts rename to plugins/cost-insights/src/types/MetricData.ts index 21f345a282..bd3bdde735 100644 --- a/plugins/cost-insights/src/components/CostOverviewChart/index.ts +++ b/plugins/cost-insights/src/types/MetricData.ts @@ -14,4 +14,12 @@ * limitations under the License. */ -export { default } from './CostOverviewChart'; +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 c3d9a87886..398110ec80 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'; @@ -26,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/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/config.ts b/plugins/cost-insights/src/utils/config.ts new file mode 100644 index 0000000000..4af7140d9c --- /dev/null +++ b/plugins/cost-insights/src/utils/config.ts @@ -0,0 +1,26 @@ +/* + * 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[]) { + 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}`, + ); + } +} 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); }