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); }