add metric comparison to top panel

This commit is contained in:
Ryan Vazquez
2020-10-13 16:01:50 -04:00
parent 01830866e7
commit 1268eadd19
22 changed files with 417 additions and 392 deletions
+5 -2
View File
@@ -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
@@ -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<Cost> {
): Promise<MetricData> {
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<Cost> {
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<Cost> {
const aggregation = aggregationFor(
durationOf(intervals),
metric ? 0.1 : 1_500,
);
async getProjectDailyCost(project: string, intervals: string): Promise<Cost> {
const aggregation = aggregationFor(durationOf(intervals), 1_500);
const projectDailyCost: Cost = await this.request(
{ project, metric, intervals },
{ project, intervals },
{
id: 'project-a',
aggregation: aggregation,
@@ -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<Cost>;
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
/**
* 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<Cost>;
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
/**
* 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<MetricData>;
/**
* 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,
@@ -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<Maybe<Project[]>>(null);
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
const [metricData, setMetricData] = useState<Maybe<MetricData>>(null);
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
const [error, setError] = useState<Maybe<Error>>(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 = () => {
</CostInsightsLayout>
);
}
// These should be defined, alerts can be an empty array but that's truthy
if (!dailyCost || !alerts) {
return (
@@ -195,7 +209,7 @@ const CostInsightsPage = () => {
<Box mr={1}>
<CurrencySelect
currency={currency}
currencies={currencies}
currencies={config.currencies}
onSelect={setCurrency}
/>
</Box>
@@ -253,12 +267,7 @@ const CostInsightsPage = () => {
<Grid item xs>
<Box px={3} py={6}>
{!!dailyCost.aggregation.length && (
<CostOverviewCard
change={dailyCost.change}
aggregation={dailyCost.aggregation}
trendline={dailyCost.trendline}
projects={projects || []}
/>
<CostOverviewCard data={[dailyCost, metricData]} />
)}
<WhyCostsMatter />
</Box>
@@ -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<DateAggregation>;
trendline: Trendline;
projects: Array<Project>;
export type CostOverviewCardProps = {
data: [Cost, MetricData | null];
};
const CostOverviewCard = ({
change,
aggregation,
trendline,
}: CostOverviewCardProps) => {
const { metrics } = useConfig();
const CostOverviewCard = ({ data }: CostOverviewCardProps) => {
const theme = useTheme<CostInsightsTheme>();
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 (
<Card style={{ position: 'relative' }}>
@@ -60,22 +59,53 @@ const CostOverviewCard = ({
<PeriodSelect duration={filters.duration} onSelect={setDuration} />
</CostOverviewHeader>
<Divider />
<Box marginY={1} display="flex" flexDirection="column">
<CostOverviewChartLegend change={change} title={`${name} Trend`} />
<Box my={1} display="flex" flexDirection="column">
<Box display="flex" flexDirection="row">
<Box mr={2}>
<LegendItem
title={dailyCost.name}
markerColor={theme.palette.blue}
>
{formatPercent(data[0].change.ratio)}
</LegendItem>
</Box>
{metric && metric.kind && data[1] && comparedChange && (
<>
<Box mr={2}>
<LegendItem
title={metric.name}
markerColor={theme.palette.magenta}
>
{formatPercent(data[1].change.ratio)}
</LegendItem>
</Box>
<LegendItem
title={
comparedChange.amount <= 0 ? 'Your Savings' : 'Your Excess'
}
>
<CostGrowth
change={comparedChange}
duration={filters.duration}
/>
</LegendItem>
</>
)}
</Box>
<CostOverviewChart
responsive
metric={metric}
tooltip={name}
aggregation={aggregation}
trendline={trendline}
data={data}
name={dailyCost.name}
compare={metric}
/>
</Box>
<Box display="flex" justifyContent="flex-end" alignItems="center">
<MetricSelect
metric={metric}
metrics={metrics}
onSelect={setMetric}
/>
{config.metrics.length > 1 && (
<MetricSelect
metric={filters.metric}
metrics={config.metrics}
onSelect={setMetric}
/>
)}
</Box>
</CardContent>
</Card>
@@ -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<CostInsightsTheme>();
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 (
<ResponsiveContainer
width={responsive ? '100%' : styles.container.width}
height={styles.container.height}
className="cost-overview-chart"
>
<ComposedChart margin={styles.chart.margin} data={chartData}>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
yAxisId={dailyCost.id}
/>
{metric && (
<YAxis
hide
domain={[() => 0, metricDataMax]}
width={styles.yAxis.width}
yAxisId={metric.id}
/>
)}
<Area
dataKey={dailyCost.id}
isAnimationActive={false}
fill={theme.palette.blue}
fillOpacity={0.4}
stroke="none"
yAxisId={dailyCost.id}
/>
<Line
activeDot={false}
dataKey="trend"
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.blue}
yAxisId={dailyCost.id}
/>
{metric && (
<Line
activeDot={false}
dataKey={metric.id}
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.magenta}
yAxisId={metric.id}
/>
)}
<Tooltip
content={
<CostOverviewTooltip
dataKeys={[dailyCost.id, metric.id]}
format={tooltipFormatter}
/>
}
animationDuration={100}
/>
</ComposedChart>
</ResponsiveContainer>
);
};
export default CostOverviewChart;
@@ -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<string>;
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 <Tooltip label={tooltipLabel} items={items} />;
};
@@ -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<DateAggregation>;
const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline;
const mockMetric = 'mock-metric';
describe('<CostOverviewChart/>', () => {
it('Renders without exploding', async () => {
const rendered = await renderInTestApp(
<CostInsightsThemeProvider>
<CostOverviewChart
responsive={false}
aggregation={mockAggregation}
trendline={mockTrendline}
metric={mockMetric}
tooltip="Mock tooltip text"
/>
</CostInsightsThemeProvider>,
);
expect(
rendered.container.querySelector('.cost-overview-chart'),
).toBeInTheDocument();
});
});
@@ -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<DateAggregation>;
trendline?: Maybe<Trendline>;
metric: string | null;
tooltip: string;
};
const CostOverviewChart = ({
responsive = true,
aggregation,
trendline,
metric,
tooltip,
}: CostOverviewChartProps) => {
const theme = useTheme<CostInsightsTheme>();
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 (
<ResponsiveContainer
width={responsive ? '100%' : styles.container.width}
height={styles.container.height}
className="cost-overview-chart"
>
<ComposedChart margin={styles.chart.margin} data={dailyCostData}>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
yAxisId={id}
/>
<Area
dataKey={id}
isAnimationActive={false}
fill={theme.palette.blue}
fillOpacity={0.4}
stroke="none"
yAxisId={id}
/>
<Line
activeDot={false}
dataKey="trend"
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.blue}
yAxisId={id}
/>
<Tooltip
content={<CostOverviewTooltip name={tooltip} metric={id} />}
animationDuration={100}
/>
</ComposedChart>
</ResponsiveContainer>
);
};
export default CostOverviewChart;
@@ -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';
@@ -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('<CostOverviewChartLegend />', () => {
it('Correctly displays text if change is not supplied', async () => {
const rendered = await renderInTestApp(
<CostOverviewChartLegend title="mock-metric-name" />,
);
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(
<CostOverviewChartLegend change={change} title="mock-metric-name" />,
);
expect(rendered.queryByText('Unclear')).not.toBeInTheDocument();
expect(rendered.queryByText('35%')).toBeInTheDocument();
});
});
@@ -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<CostInsightsTheme>();
return (
<Box marginRight={2}>
<LegendItem
title={title}
markerColor={theme.palette.blue}
tooltipText={tooltip}
>
{change ? formatPercent(change.ratio) : 'Unclear'}
</LegendItem>
</Box>
);
};
export default CostOverviewChartLegend;
+12 -17
View File
@@ -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);
}
@@ -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(() => {
@@ -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 <CostOverviewCard /> 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,
};
}
@@ -14,4 +14,9 @@
* limitations under the License.
*/
export { default } from './CostOverviewChartLegend';
export type ChartData = {
date: number;
trend: number;
dailyCost: number;
[key: string]: number;
};
+4
View File
@@ -24,3 +24,7 @@ export interface Cost {
change: ChangeStatistic;
trendline: Trendline;
}
export interface MetricData extends Cost {
format: 'number' | 'currency';
}
+1 -1
View File
@@ -22,7 +22,7 @@ export interface PageFilters {
group: Maybe<string>;
project: Maybe<string>;
duration: Duration;
metric: Maybe<string>;
metric: string | null;
}
export type ProductFilters = Array<ProductPeriod>;
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { Maybe } from './Maybe';
import { Maybe } from '../types';
export type Metric = {
kind: Maybe<string>;
name: string;
compare?: string;
};
+1
View File
@@ -16,6 +16,7 @@
export * from './Alert';
export * from './ChangeStatistic';
export * from './ChartData';
export * from './Cost';
export * from './DateAggregation';
export * from './Duration';
+39
View File
@@ -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<string, number>,
entry: DateAggregation,
): Record<string, number> {
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]
);
}
+6 -1
View File
@@ -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);
}