add metric comparison to top panel
This commit is contained in:
+5
-2
@@ -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;
|
||||
+8
-13
@@ -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';
|
||||
-40
@@ -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();
|
||||
});
|
||||
});
|
||||
-48
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -14,4 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { default } from './CostOverviewChartLegend';
|
||||
export type ChartData = {
|
||||
date: number;
|
||||
trend: number;
|
||||
dailyCost: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
@@ -24,3 +24,7 @@ export interface Cost {
|
||||
change: ChangeStatistic;
|
||||
trendline: Trendline;
|
||||
}
|
||||
|
||||
export interface MetricData extends Cost {
|
||||
format: 'number' | 'currency';
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './Alert';
|
||||
export * from './ChangeStatistic';
|
||||
export * from './ChartData';
|
||||
export * from './Cost';
|
||||
export * from './DateAggregation';
|
||||
export * from './Duration';
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user