Merge pull request #2637 from spotify/plugin-cost-insights

Cost Insights Plugin
This commit is contained in:
Ryan Vazquez
2020-09-28 15:46:02 -04:00
committed by GitHub
161 changed files with 9477 additions and 6 deletions
+1
View File
@@ -6,6 +6,7 @@
* @spotify/backstage-core
/docs/features/techdocs @spotify/techdocs-core
/plugins/cost-insights @spotify/silver-lining
/plugins/techdocs @spotify/techdocs-core
/plugins/techdocs-backend @spotify/techdocs-core
/packages/techdocs-cli @spotify/techdocs-core
+20
View File
@@ -221,3 +221,23 @@ auth:
tenantId:
$secret:
env: AUTH_MICROSOFT_TENANT_ID
costInsights:
engineerCost: 200000
products:
computeEngine:
name: Compute Engine
icon: compute
cloudDataflow:
name: Cloud Dataflow
icon: data
cloudStorage:
name: Cloud Storage
icon: storage
bigQuery:
name: Big Query
icon: search
metrics:
dailyCost:
name: Your Company's Daily Cost
DAU:
name: Cost Per DAU
+1
View File
@@ -10,6 +10,7 @@
"@backstage/plugin-api-docs": "^0.1.1-alpha.23",
"@backstage/plugin-catalog": "^0.1.1-alpha.23",
"@backstage/plugin-circleci": "^0.1.1-alpha.23",
"@backstage/plugin-cost-insights": "^0.1.1-alpha.23",
"@backstage/plugin-explore": "^0.1.1-alpha.23",
"@backstage/plugin-gcp-projects": "^0.1.1-alpha.23",
"@backstage/plugin-github-actions": "^0.1.1-alpha.23",
+6
View File
@@ -29,11 +29,15 @@ import {
TravisCIApi,
travisCIApiRef,
} from '@roadiehq/backstage-plugin-travis-ci';
import {
GithubPullRequestsClient,
githubPullRequestsApiRef,
} from '@roadiehq/backstage-plugin-github-pull-requests';
import { costInsightsApiRef } from '@backstage/plugin-cost-insights';
import { ExampleCostInsightsClient } from './plugins/cost-insights';
export const apis = [
createApiFactory({
api: graphQlBrowseApiRef,
@@ -54,6 +58,8 @@ export const apis = [
]),
}),
createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()),
// TODO: move to plugins
createApiFactory(travisCIApiRef, new TravisCIApi()),
createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()),
@@ -23,6 +23,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
import MapIcon from '@material-ui/icons/MyLocation';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MoneyIcon from '@material-ui/icons/MonetizationOn';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import {
@@ -94,6 +95,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarDivider />
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarItem
icon={graphiQLRouteRef.icon!}
to={graphiQLRouteRef.path}
+1
View File
@@ -34,3 +34,4 @@ export { plugin as ApiDocs } from '@backstage/plugin-api-docs';
export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests';
export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects';
export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
@@ -0,0 +1,209 @@
/*
* 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.
*/
/* eslint-disable no-restricted-imports */
import {
CostInsightsApi,
Alert,
Cost,
Duration,
Project,
ProductCost,
Group,
} from '@backstage/plugin-cost-insights';
export class ExampleCostInsightsClient implements CostInsightsApi {
private request(_: any, res: any): Promise<any> {
return new Promise(resolve => setTimeout(resolve, 0, res));
}
async getUserGroups(userId: string): Promise<Group[]> {
const groups: Group[] = await this.request({ userId }, [
{ id: 'pied-piper' },
]);
return groups;
}
async getGroupProjects(group: string): Promise<Project[]> {
const projects: Project[] = await this.request({ group }, [
{ id: 'project-a' },
{ id: 'project-b' },
{ id: 'project-c' },
]);
return projects;
}
async getGroupDailyCost(
group: string,
metric: string | null,
intervals: string,
): Promise<Cost> {
const groupDailyCost: Cost = await this.request(
{ group, metric, intervals },
{
id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel
aggregation: [
{ date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) },
{ date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) },
],
change: {
ratio: 0.86,
amount: 65_000,
},
trendline: {
slope: 0,
intercept: 90_000,
},
},
);
return groupDailyCost;
}
async getProjectDailyCost(
project: string,
metric: string | null,
intervals: string,
): Promise<Cost> {
const projectDailyCost: Cost = await this.request(
{ project, metric, intervals },
{
id: 'project-a',
aggregation: [
{ date: '2020-08-01', amount: 1000 },
{ date: '2020-08-02', amount: 2000 },
{ date: '2020-08-03', amount: 3000 },
{ date: '2020-08-04', amount: 4000 },
{ date: '2020-08-05', amount: 5000 },
{ date: '2020-08-06', amount: 6000 },
{ date: '2020-08-07', amount: 7000 },
{ date: '2020-08-08', amount: 8000 },
{ date: '2020-08-09', amount: 9000 },
{ date: '2020-08-10', amount: 10_000 },
],
change: {
ratio: 0.5,
amount: 10000,
},
trendline: {
slope: 0,
intercept: 0,
},
},
);
return projectDailyCost;
}
async getProductInsights(
product: string,
group: string,
duration: Duration,
): Promise<ProductCost> {
const productInsights: ProductCost = await this.request(
{ product, group, duration },
{
aggregation: [200_000, 250_000],
change: {
ratio: 0.2,
amount: 50_000,
},
entities: [
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [45_000, 50_000],
},
{
id: 'entity-a',
aggregation: [15_000, 20_000],
},
{
id: 'entity-b',
aggregation: [20_000, 30_000],
},
{
id: 'entity-c',
aggregation: [18_000, 25_000],
},
{
id: 'entity-d',
aggregation: [36_000, 42_000],
},
{
id: 'entity-e',
aggregation: [0, 10_000],
},
{
id: 'entity-f',
aggregation: [17_000, 19_000],
},
{
id: 'entity-g',
aggregation: [49_000, 30_000],
},
{
id: 'entity-h',
aggregation: [0, 34_000],
},
],
},
);
return productInsights;
}
async getAlerts(group: string): Promise<Alert[]> {
const alerts: Alert[] = await this.request({ group }, [
{
id: 'projectGrowth',
project: 'example-project',
periodStart: 'Q1 2020',
periodEnd: 'Q2 2020',
aggregation: [60_000, 120_000],
change: {
ratio: 1,
amount: 60000,
},
products: [
{
id: 'Compute Engine',
aggregation: [58_000, 118_000],
},
{
id: 'Cloud Dataflow',
aggregation: [1200, 1500],
},
{
id: 'Cloud Storage',
aggregation: [800, 500],
},
],
},
]);
return alerts;
}
}
@@ -0,0 +1,17 @@
/*
* 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 { ExampleCostInsightsClient } from './ExampleCostInsightsClient';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+106
View File
@@ -0,0 +1,106 @@
# Cost Insights
Cost Insights is a plugin to help engineers visualize, understand and optimize their cloud costs. The Cost Insights page shows daily cost data for a team, trends over time, and comparisons with the business metrics you care about.
At Spotify, we find that cloud costs are optimized organically when:
- Engineers see cost data in their daily work (that is, in Backstage).
- It's clear when cloud costs need attention.
- The data is shown in software terms familiar to them.
- Alerts and recommendations are targeted and actionable.
Cost Insights shows trends over time, at the granularity of your software deployments - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations.
## Install
```bash
yarn add @backstage/plugin-cost-insights
```
## Setup
1. Configure `app-config.yaml`. See [Configuration](#configuration).
2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/spotify/backstage/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation.
```ts
// path/to/CostInsightsClient.ts
import { CostInsightsApi } from '@backstage/plugin-cost-insights';
export class CostInsightsClient implements CostInsightsApi { ... }
```
3. Import the client and the CostInsights plugin API to your Backstage instance.
```ts
// packages/app/src/api.ts
import { createApiFactory } from '@backstage/core';
import { costInsightsApiRef } from '@backstage/plugin-cost-insights';
import { CostInsightsClient } from './path/to/file';
export const apis = [
createApiFactory({
api: costInsightsApiRef,
deps: {},
factory: () => new CostInsightsClient(),
}),
];
```
4. Add cost-insights to your Backstage plugins.
```ts
// packages/app/src/plugins.ts
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
```
## Configuration
Cost Insights has only two required configuration fields: a map of cloud `products` for showing cost breakdowns and `engineerCost` - the average yearly cost of an engineer including benefits. Products must be defined as keys on the `products` field.
You can optionally supply a product `icon` to display in Cost Insights navigation. See the [type file](https://github.com/spotify/backstage/plugins/cost-insights/types/Icon.ts) for supported types and Material UI icon [mappings](https://github.com/spotify/backstage/plugins/cost-insights/utils/navigation.ts).
**Note:** Product keys should be unique and camelCased. Backstage does not support underscores in configuration keys.
### Basic
```yaml
## ./app-config.yaml
costInsights:
engineerCost: 200000
products:
productA:
name: Some Cloud Product ## required
icon: storage
productB:
name: Some Other Cloud Product
icon: data
```
### Metrics (Optional)
In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter.
**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overriden by assigning it a value on the `dailyCost` field.
```yaml
## ./app-config.yaml
costInsights:
engineerCost: 200000
products:
productA:
name: Some Cloud Product
icon: storage
productB:
name: Some Other Cloud Product
icon: data
metrics:
dailyCost:
name: Earth Rotation
metricA:
name: Metric A ## required
metricB:
name: Metric B
metricC:
name: Metric C
```
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@backstage/plugin-cost-insights",
"version": "0.1.1-alpha.23",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.23",
"@backstage/core": "^0.1.1-alpha.23",
"@backstage/test-utils": "^0.1.1-alpha.23",
"@backstage/theme": "^0.1.1-alpha.23",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@material-ui/styles": "^4.9.6",
"canvas": "^2.6.1",
"classnames": "^2.2.6",
"history": "^5.0.0",
"moment": "^2.27.0",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"recharts": "^1.8.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.23",
"@backstage/dev-utils": "^0.1.1-alpha.23",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/recharts": "^1.8.14",
"jest-fetch-mock": "^3.0.3",
"msw": "^0.20.5",
"node-fetch": "^2.6.1"
},
"files": [
"dist"
]
}
@@ -0,0 +1,115 @@
/*
* 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 { createApiRef } from '@backstage/core';
import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types';
export type CostInsightsApi = {
/**
* Get a list of groups the given user belongs to. These may be LDAP groups or similar
* organizational groups. Cost Insights is designed to show costs based on group membership;
* if a user has multiple groups, they are able to switch between groups to see costs for each.
*
* This method should be removed once the Backstage identity plugin provides the same concept.
*/
getUserGroups(userId: string): Promise<Group[]>;
/**
* Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a
* similar concept in billing accounts). These act as filters for the displayed costs, users can
* choose whether they see all costs for a group, or those from a particular owned project.
*/
getGroupProjects(group: string): Promise<Project[]>;
/**
* Get daily cost aggregations for a given group and interval timeframe.
*
* The return type includes an array of daily cost aggregations as well as statistics about the
* change in cost over the intervals. Calculating these statistics requires us to bucket costs
* into two or more time periods, hence a repeating interval format rather than just a start and
* end date.
*
* The rate of change in this comparison allows teams to reason about their cost growth (or
* 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>;
/**
* Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar
* concept in billing accounts) and interval timeframe.
*
* The return type includes an array of daily cost aggregations as well as statistics about the
* change in cost over the intervals. Calculating these statistics requires us to bucket costs
* into two or more time periods, hence a repeating interval format rather than just a start and
* end date.
*
* The rate of change in this comparison allows teams to reason about the project's cost growth
* (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>;
/**
* Get cost aggregations for a particular cloud product and interval timeframe. This includes
* total cost for the product, as well as a breakdown of particular entities that incurred cost
* in this product. The type of entity depends on the product - it may be deployed services,
* storage buckets, managed database instances, etc.
*
* The time period is supplied as a Duration rather than intervals, since this is always expected
* to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter).
*
* @param product The product from the cost-insights configuration in app-config.yaml
* @param group
* @param duration A time duration, such as P1M. See the Duration type for a detailed explanation
* of how the durations are interpreted in Cost Insights.
*/
getProductInsights(
product: string,
group: string,
duration: Duration,
): Promise<ProductCost>;
/**
* Get current cost alerts for a given group. These show up as Action Items for the group on the
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
* migrations, or cost-related warnings, such as an unexpected billing anomaly.
*/
getAlerts(group: string): Promise<Alert[]>;
};
export const costInsightsApiRef = createApiRef<CostInsightsApi>({
id: 'plugin.costinsights.service',
description: 'Provides cost data and alerts for the cost-insights plugin',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 * from './CostInsightsApi';
@@ -0,0 +1,50 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import AlertActionCard from './AlertActionCard';
import { AlertType, ProjectGrowthAlert } from '../../types';
import { getAlertText } from '../../utils/alerts';
import { MockScrollProvider } from '../../utils/tests';
const alert = {
id: AlertType.ProjectGrowth,
aggregation: [500000.8, 970502.8],
project: 'test-project',
periodStart: '2019-10-01',
periodEnd: '2020-03-31',
change: { ratio: 120, amount: 120000 },
products: [],
} as ProjectGrowthAlert;
describe('<AlertActionCard/>', () => {
it('Renders an alert', async () => {
const rendered = await renderInTestApp(
<MockScrollProvider>
<AlertActionCard alert={alert} number={1} />,
</MockScrollProvider>,
);
expect(rendered.getByText('1')).toBeInTheDocument();
const text = getAlertText(alert);
expect(text).toBeDefined();
if (text) {
expect(rendered.getByText(text.title)).toBeInTheDocument();
expect(rendered.getByText(text.subtitle)).toBeInTheDocument();
}
});
});
@@ -0,0 +1,49 @@
/*
* 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 { Avatar, Card, CardHeader } from '@material-ui/core';
import { useScroll } from '../../hooks';
import { Alert } from '../../types';
import { getAlertText, getAlertNavigation } from '../../utils/alerts';
import {
useAlertActionCardStyles as useStyles,
useAlertActionCardHeader as useHeaderStyles,
} from '../../utils/styles';
type AlertActionCardProps = {
alert: Alert;
number: number;
};
const AlertActionCard = ({ alert, number }: AlertActionCardProps) => {
const { scrollIntoView } = useScroll(getAlertNavigation(alert, number));
const headerClasses = useHeaderStyles();
const text = getAlertText(alert);
const classes = useStyles();
return (
<Card className={classes.card} raised={false} onClick={scrollIntoView}>
<CardHeader
classes={headerClasses}
avatar={<Avatar className={classes.avatar}>{number}</Avatar>}
title={text?.title}
subheader={text?.subtitle}
/>
</Card>
);
};
export default AlertActionCard;
@@ -0,0 +1,36 @@
/*
* 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, { FC, Fragment } from 'react';
import { Paper, Divider } from '@material-ui/core';
import AlertActionCard from './AlertActionCard';
import { Alert } from '../../types';
type AlertActionCardList = {
alerts: Array<Alert>;
};
const AlertActionCardList: FC<AlertActionCardList> = ({ alerts }) => (
<Paper>
{alerts.map((alert, index) => (
<Fragment key={`${alert.id}-${index}`}>
<AlertActionCard alert={alert} number={index + 1} />
{index < alerts.length - 1 && <Divider variant="fullWidth" />}
</Fragment>
))}
</Paper>
);
export default AlertActionCardList;
@@ -0,0 +1,17 @@
/*
* 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 './AlertActionCardList';
@@ -0,0 +1,51 @@
/*
* 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 { Grid } from '@material-ui/core';
import AlertInsightsSection from './AlertInsightsSection';
import AlertInsightsHeader from './AlertInsightsHeader';
import { Alert } from '../../types';
import { renderAlert } from '../../utils/alerts';
const title = "Your team's action items";
const subtitle =
'This section outlines suggested action items your team can address to improve cloud costs.';
type AlertInsightsProps = {
alerts: Array<Alert>;
};
const AlertInsights = ({ alerts }: AlertInsightsProps) => (
<Grid container direction="column" spacing={2}>
<Grid item>
<AlertInsightsHeader title={title} subtitle={subtitle} />
</Grid>
<Grid item container direction="column" spacing={4}>
{alerts.map((alert, index) => (
<Grid item key={`alert-card-${index}`}>
<AlertInsightsSection
alert={alert}
number={index + 1}
render={renderAlert}
/>
</Grid>
))}
</Grid>
</Grid>
);
export default AlertInsights;
@@ -0,0 +1,47 @@
/*
* 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, Typography } from '@material-ui/core';
import { useCostInsightsStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
import { DefaultNavigation } from '../../utils/navigation';
type AlertInsightsHeaderProps = {
title: string;
subtitle: string;
};
const AlertInsightsHeader = ({ title, subtitle }: AlertInsightsHeaderProps) => {
const classes = useStyles();
const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader);
return (
<Box mb={6} position="relative">
<ScrollAnchor top={-20} behavior="smooth" />
<Typography variant="h4" align="center">
{title}{' '}
<span role="img" aria-label="direct-hit">
🎯
</span>
</Typography>
<Typography className={classes.h6Subtle} align="center" gutterBottom>
{subtitle}
</Typography>
</Box>
);
};
export default AlertInsightsHeader;
@@ -0,0 +1,62 @@
/*
* 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, Button } from '@material-ui/core';
import AlertInsightsSectionHeader from './AlertInsightsSectionHeader';
import {
getAlertButtonText,
getAlertText,
getAlertUrl,
} from '../../utils/alerts';
import { Alert, Currency } from '../../types';
import { useCurrency } from '../../hooks';
type AlertInsightsSectionProps = {
alert: Alert;
number: number;
render: (alert: Alert, currency: Currency) => JSX.Element;
};
const AlertInsightsSection = ({
alert,
number,
render,
}: AlertInsightsSectionProps) => {
const [currency] = useCurrency();
const text = getAlertText(alert);
const url = getAlertUrl(alert);
const buttonText = getAlertButtonText(alert);
return (
<Box display="flex" flexDirection="column">
<AlertInsightsSectionHeader
alert={alert}
title={text.title}
subtitle={text.subtitle}
number={number}
/>
<Box textAlign="left" mt={0} mb={4}>
<Button variant="contained" color="primary" href={url}>
{buttonText}
</Button>
{/* <Button color="primary">Dismiss notification</Button> */}
</Box>
{render(alert, currency)}
</Box>
);
};
export default AlertInsightsSection;
@@ -0,0 +1,55 @@
/*
* 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 { Avatar, Box, Typography, Grid } from '@material-ui/core';
import { Alert } from '../../types';
import { getAlertNavigation } from '../../utils/alerts';
import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
type AlertInsightsSectionHeaderProps = {
alert: Alert;
number: number;
title: string;
subtitle: string;
};
const AlertInsightsSectionHeader = ({
alert,
number,
title,
subtitle,
}: AlertInsightsSectionHeaderProps) => {
const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number));
const classes = useStyles();
return (
<Box position="relative" mb={3} textAlign="left">
<ScrollAnchor top={-20} behavior="smooth" />
<Grid container spacing={2}>
<Grid item>
<Avatar className={classes.button}>{number}</Avatar>
</Grid>
<Grid item>
<Typography variant="h5">{title}</Typography>
<Typography gutterBottom>{subtitle}</Typography>
</Grid>
</Grid>
</Box>
);
};
export default AlertInsightsSectionHeader;
@@ -0,0 +1,17 @@
/*
* 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 './AlertInsights';
@@ -0,0 +1,61 @@
/*
* 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, { ReactNode } from 'react';
import { Box, Button, Container, makeStyles } from '@material-ui/core';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import { Header, Page, pageTheme } from '@backstage/core';
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
const useStyles = makeStyles(theme => ({
root: {
gridArea: 'pageContent',
padding: theme.spacing(4),
},
}));
type AlertInstructionsLayoutProps = {
title: string;
children: ReactNode;
};
const AlertInstructionsLayout = ({
title,
children,
}: AlertInstructionsLayoutProps) => {
const classes = useStyles();
return (
<CostInsightsThemeProvider>
<Page theme={pageTheme.tool}>
<Header title="Cost Insights" pageTitleOverride={title} type="Tool" />
<Container maxWidth="md" disableGutters className={classes.root}>
<Box mb={3}>
<Button
variant="outlined"
startIcon={<ChevronLeftIcon />}
href="/cost-insights"
>
Back to Cost Insights
</Button>
</Box>
{children}
</Container>
</Page>
</CostInsightsThemeProvider>
);
};
export default AlertInstructionsLayout;
@@ -0,0 +1,17 @@
/*
* 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 './AlertInstructionsLayout';
@@ -0,0 +1,191 @@
/*
* 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 { TooltipPayload } from 'recharts';
import { fireEvent } from '@testing-library/react';
import BarChart, { BarChartProps } from './BarChart';
import { BarChartData, ResourceData } from '../../types';
import { createMockEntity } from '../../utils/mockData';
import { resourceSort } from '../../utils/sort';
import { renderInTestApp } from '@backstage/test-utils';
import { TooltipItemProps } from '../Tooltip';
import { costInsightsLightTheme } from '../../utils/styles';
const MockEntities = [...Array(10)].map((_, index) =>
createMockEntity(() => ({
id: `test-id-${index + 1}`,
// grow resource costs linearly for testing
aggregation: [index * 1000, (index + 1) * 1000],
})),
);
const MockBarChartData: BarChartData = {
previousFill: costInsightsLightTheme.palette.yellow,
currentFill: costInsightsLightTheme.palette.darkBlue,
previousName: 'Last Period',
currentName: 'Current Period',
};
const MockResources: ResourceData[] = MockEntities.map(entity => ({
name: entity.id,
previous: entity.aggregation[0],
current: entity.aggregation[1],
}));
const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({
label: payload.name,
value: payload.value as string,
fill: payload.fill as string,
});
const renderWithProps = ({
responsive = false,
displayAmount = 6,
barChartData = MockBarChartData,
getTooltipItem = MockTooltipItem,
resources = MockResources,
}: BarChartProps) => {
return renderInTestApp(
<BarChart
responsive={responsive}
displayAmount={displayAmount}
barChartData={barChartData}
getTooltipItem={getTooltipItem}
resources={resources}
/>,
);
};
describe('<BarChart />', () => {
it('Renders without exploding', async () => {
const rendered = await renderWithProps({} as BarChartProps);
expect(rendered.getByText('test-id-10')).toBeInTheDocument();
});
it('Should display only 6 resources by default, sorted by cost', async () => {
const rendered = await renderWithProps({} as BarChartProps);
MockResources.sort(resourceSort).forEach((resource, index) => {
if (index < 6) {
expect(rendered.getByText(resource.name!)).toBeInTheDocument();
} else {
expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument();
}
});
});
describe('Stepper', () => {
it('should not display stepper if displaying less than 6 resources', async () => {
const rendered = await renderWithProps({
resources: MockResources.slice(0, 3),
} as BarChartProps);
expect(
rendered.queryByTestId('bar-chart-stepper'),
).not.toBeInTheDocument();
});
it('should display stepper if displaying more than 6 resources', async () => {
const rendered = await renderWithProps({} as BarChartProps);
expect(rendered.queryByTestId('bar-chart-stepper')).toBeInTheDocument();
});
it('should display the next step button if resources are remaining', async () => {
const rendered = await renderWithProps({} as BarChartProps);
fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper'));
expect(
rendered.queryByTestId('bar-chart-stepper-button-back'),
).not.toBeInTheDocument();
expect(
rendered.queryByTestId('bar-chart-stepper-button-next'),
).toBeInTheDocument();
});
it('should display the correct amount of dots for the amount of resources', async () => {
const rendered = await renderWithProps({} as BarChartProps);
fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper'));
expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2);
});
it('should not display the stepper or stepper buttons when the amount of resources is equal to the displayMax', async () => {
const MockEqualEntities = [...Array(5)].map(createMockEntity);
const MockEqualResources = MockEqualEntities.map(entity => ({
name: entity.id,
previous: entity.aggregation[0],
current: entity.aggregation[1],
}));
const rendered = await renderWithProps({
displayAmount: 5,
resources: MockEqualResources,
} as BarChartProps);
expect(
rendered.queryByTestId('bar-chart-stepper'),
).not.toBeInTheDocument();
expect(
rendered.queryByTestId('bar-chart-stepper-button-back'),
).not.toBeInTheDocument();
expect(
rendered.queryByTestId('bar-chart-stepper-button-next'),
).not.toBeInTheDocument();
});
it('should display the correct resources while scrolling', async () => {
const rendered = await renderWithProps({
displayAmount: 7,
} as BarChartProps);
const sortedByCost = MockResources.sort(resourceSort);
sortedByCost.slice(0, 7).forEach(resource => {
expect(rendered.getByText(resource.name!)).toBeInTheDocument();
});
fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper'));
fireEvent.click(rendered.getByTestId('bar-chart-stepper-button-next'));
sortedByCost.slice(7, 12).forEach(resource => {
expect(rendered.getByText(resource.name!)).toBeInTheDocument();
});
});
it('should display the correct amount of dots for a large amount of resources while scrolling', async () => {
const MockLargeEntities = [...Array(68)].map(createMockEntity);
const MockLargeResources = MockLargeEntities.map(entity => ({
name: entity.id,
previous: entity.aggregation[0],
current: entity.aggregation[1],
}));
const rendered = await renderWithProps({
resources: MockLargeResources,
} as BarChartProps);
fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper'));
const NextButton = rendered.getByTestId('bar-chart-stepper-button-next');
[...Array(9)].forEach(() => {
fireEvent.click(NextButton);
expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(10);
});
[...Array(2)].forEach(() => {
fireEvent.click(NextButton);
expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2);
});
});
});
});
@@ -0,0 +1,175 @@
/*
* 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, { useState, useCallback } from 'react';
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
ContentRenderer,
TooltipProps,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
TooltipPayload,
} from 'recharts';
import { Box, useTheme } from '@material-ui/core';
import BarChartTick from './BarChartTick';
import BarChartStepper from './BarChartStepper';
import Tooltip, { TooltipItemProps } from '../Tooltip';
import { currencyFormatter } from '../../utils/formatters';
import {
BarChartData,
Maybe,
notEmpty,
ResourceData,
DataKey,
CostInsightsTheme,
} from '../../types';
import { useBarChartStyles } from '../../utils/styles';
import { resourceSort } from '../../utils/sort';
export type BarChartProps = {
responsive?: boolean;
displayAmount?: number;
barChartData: BarChartData;
getTooltipItem: (payload: TooltipPayload) => Maybe<TooltipItemProps>;
resources: ResourceData[];
};
const BarChart = ({
responsive = true,
displayAmount = 6,
barChartData,
getTooltipItem,
resources,
}: BarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useBarChartStyles(theme);
const [activeChart, setActiveChart] = useState(false);
const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]);
const { previousFill, currentFill, previousName, currentName } = barChartData;
const [stepStart, stepEnd] = stepWindow;
const steps = Math.ceil(resources.length / displayAmount);
const disableStepper = resources.length <= displayAmount;
const sortedResources = resources
.sort(resourceSort)
.slice(stepStart, stepEnd);
// Pin the domain to the largest value in the series.
// Intentially redundant - This could simply be derived from the first element in the already sorted list,
// but that may not be the case in the future when custom sorting is implemented.
const globalResourcesMax = resources.reduce(
(max, r: ResourceData) => Math.max(max, r.current, r.previous),
0,
);
const onStepChange = useCallback(
(activeStep: number) => {
const start = activeStep * displayAmount;
const end = start + displayAmount;
if (end > resources.length) {
setStepWindow([start, resources.length]);
} else {
setStepWindow([start, end]);
}
},
[setStepWindow, resources, displayAmount],
);
const handleChartEnter = () => setActiveChart(true);
const handleChartLeave = () => setActiveChart(false);
const renderTooltipContent: ContentRenderer<TooltipProps> = ({
label,
payload,
}) => {
if (!(payload && typeof label === 'string')) return [null, null];
const items = payload.map(getTooltipItem).filter(notEmpty);
return <Tooltip label={label} items={items} />;
};
return (
<Box
position="relative"
onMouseLeave={handleChartLeave}
onMouseEnter={handleChartEnter}
data-testid="bar-chart-wrapper"
>
{/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */}
<ResponsiveContainer
height={styles.container.height}
width={responsive ? '100%' : styles.container.width}
>
<RechartsBarChart
data={sortedResources}
margin={styles.barChart.margin}
barSize={45}
data-testid="bar-chart"
>
<RechartsTooltip
cursor={styles.cursor}
animationDuration={100}
content={renderTooltipContent}
/>
<CartesianGrid
vertical={false}
stroke={styles.cartesianGrid.stroke}
/>
<XAxis
dataKey={DataKey.Name}
tickLine={false}
interval={0}
height={styles.xAxis.height}
tick={BarChartTick}
/>
<YAxis
tickFormatter={currencyFormatter.format}
domain={[() => 0, globalResourcesMax]}
tick={{ fill: styles.axis.fill }}
/>
<Bar
dataKey={DataKey.Previous}
name={previousName}
fill={previousFill}
isAnimationActive={false}
/>
<Bar
dataKey={DataKey.Current}
name={currentName}
fill={currentFill}
isAnimationActive={false}
/>
</RechartsBarChart>
</ResponsiveContainer>
{!disableStepper && (
<BarChartStepper
steps={steps}
disableScroll={!activeChart}
onChange={onStepChange}
/>
)}
</Box>
);
};
export default BarChart;
@@ -0,0 +1,51 @@
/*
* 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 } from '@material-ui/core';
import { useBarChartLabelStyles } from '../../utils/styles';
type BarChartLabel = {
x: number;
y: number;
height: number;
width: number;
children?: React.ReactNode;
};
const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => {
const classes = useBarChartLabelStyles();
const translateX = width * -0.5;
const childArray = React.Children.toArray(children);
return (
<foreignObject
className={classes.foreignObject}
style={{ transform: `translateX(${translateX}px)` }}
x={x}
y={y}
height={height}
width={width}
>
<Box display="flex" flexDirection="column" justifyContent="center">
<b className={classes.label}>{childArray[0]}</b>
{childArray.slice(1)}
</Box>
</foreignObject>
);
};
export default BarChartLabel;
@@ -0,0 +1,115 @@
/*
* 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, { useEffect, useState } from 'react';
import { Paper, Slide } from '@material-ui/core';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import BarChartStepperButton from './BarChartStepperButton';
import BarChartSteps from './BarChartSteps';
import { useBarChartStepperStyles } from '../../utils/styles';
type BarChartStepperProps = {
disableScroll: boolean;
steps: number;
onChange: (activeStep: number) => void;
};
const BarChartStepper = ({
steps,
disableScroll,
onChange,
}: BarChartStepperProps) => {
const classes = useBarChartStepperStyles();
const [activeStep, setActiveStep] = useState(0);
/*
* This calc determines how many active steps to display in the stepper.
* If the chart is displaying a large amount of resources,
* the total dots are truncated to 10. As the user clicks forward,
* eventually, there might be resources "left over" in excess of the ten dot limit.
* Once the user has reached that threshold, the difference should appear constant
* as the user clicks through the remaining resources and no extra dots should be displayed.
*/
const diff = steps % 10;
const stepsRemaining = steps - activeStep <= diff ? diff : steps;
const displayedStep = activeStep % 10;
useEffect(() => {
onChange(activeStep);
}, [activeStep, onChange]);
const handleNext = () => {
setActiveStep(prevStep => prevStep + 1);
};
const handleBack = () => {
setActiveStep(prevStep => prevStep - 1);
};
const handleClick = (index: number) => {
setActiveStep(prevStep => {
const offset = index - (prevStep % 10);
return prevStep + offset;
});
};
return (
<Paper
data-testid="bar-chart-stepper"
square
elevation={0}
className={classes.paper}
>
<Slide
direction="right"
in={!disableScroll && activeStep !== 0}
mountOnEnter
unmountOnExit
>
<BarChartStepperButton
name="back"
className={classes.backButton}
onClick={handleBack}
>
<ChevronLeftIcon />
</BarChartStepperButton>
</Slide>
<BarChartSteps
steps={Math.min(10, stepsRemaining)}
activeStep={displayedStep}
onClick={handleClick}
/>
<Slide
direction="left"
in={!disableScroll && activeStep < steps - 1}
mountOnEnter
unmountOnExit
>
<BarChartStepperButton
name="next"
className={classes.nextButton}
onClick={handleNext}
>
<ChevronRightIcon />
</BarChartStepperButton>
</Slide>
</Paper>
);
};
export default BarChartStepper;
@@ -0,0 +1,46 @@
/*
* 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, { forwardRef, Ref } from 'react';
import { ButtonBase, ButtonBaseProps } from '@material-ui/core';
import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles';
interface BarChartStepperButtonProps extends ButtonBaseProps {
name: string;
children?: React.ReactNode;
}
const BarChartStepperButton = forwardRef(
(
{ name, children, ...buttonBaseProps }: BarChartStepperButtonProps,
ref: Ref<HTMLButtonElement>,
) => {
const classes = useStyles();
return (
<ButtonBase
ref={ref}
classes={classes}
disableRipple
data-testid={`bar-chart-stepper-button-${name}`}
{...buttonBaseProps}
>
{children}
</ButtonBase>
);
},
);
export default BarChartStepperButton;
@@ -0,0 +1,52 @@
/*
* 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 { ButtonBase } from '@material-ui/core';
import { useBarChartStepperStyles as useStyles } from '../../utils/styles';
export type BarChartSteps = {
steps: number;
activeStep: number;
onClick: (index: number) => void;
};
const BarChartSteps = ({ steps, activeStep, onClick }: BarChartSteps) => {
const classes = useStyles();
const handleOnClick = (index: number) => (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
event.preventDefault();
onClick(index);
};
return (
<div className={classes.steps}>
{[...new Array(steps)].map((_, index) => (
<ButtonBase key={index} centerRipple onClick={handleOnClick(index)}>
<div
data-testid="bar-chart-step"
className={`${classes.step} ${
index === activeStep ? classes.stepActive : ''
}`}
/>
</ButtonBase>
))}
</div>
);
};
export default BarChartSteps;
@@ -0,0 +1,48 @@
/*
* 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 BarChartLabel from './BarChartLabel';
type BarChartTickProps = {
x: number;
y: number;
height: number;
width: number;
payload: {
value: any;
};
visibleTicksCount: number;
};
export const BarChartTick = ({
x,
y,
height,
width,
payload,
visibleTicksCount,
}: BarChartTickProps) => {
const gutterWidth = 5;
const labelWidth = width / visibleTicksCount - gutterWidth * 2;
return (
<BarChartLabel x={x} y={y} height={height} width={labelWidth}>
{!payload.value ? 'Unlabeled' : payload.value}
</BarChartLabel>
);
};
export default BarChartTick;
@@ -0,0 +1,17 @@
/*
* 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 './BarChart';
@@ -0,0 +1,70 @@
/*
* 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, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Tooltip, IconButton } from '@material-ui/core';
import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined';
import AssignmentTurnedInOutlinedIcon from '@material-ui/icons/AssignmentTurnedInOutlined';
import SentimentVeryDissatisfiedIcon from '@material-ui/icons/SentimentVeryDissatisfied';
const ClipboardMessage = {
default: 'Copy URL to clipboard',
success: 'Copied!',
error: "Couldn't copy to clipboard",
};
const CopyUrlToClipboard = () => {
const location = useLocation();
const [state, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const origin = window.location.origin;
const pathname = location.pathname;
const search = location.search;
const url = `${origin}${pathname}${search}`;
useEffect(() => {
if (state.error) {
setCopied(false);
} else if (state.value) {
setCopied(true);
setTimeout(setCopied, 1500, false);
}
}, [state]);
let text = ClipboardMessage.default;
let Icon = AssignmentOutlinedIcon;
if (state.error) {
text = ClipboardMessage.error;
Icon = SentimentVeryDissatisfiedIcon;
} else if (copied) {
text = ClipboardMessage.success;
Icon = AssignmentTurnedInOutlinedIcon;
}
return (
<Tooltip title={text} arrow>
<IconButton onClick={() => copyToClipboard(url)}>
<Icon />
</IconButton>
</Tooltip>
);
};
export default CopyUrlToClipboard;
@@ -0,0 +1,17 @@
/*
* 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 './CopyUrlToClipboard';
@@ -0,0 +1,113 @@
/*
* 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, { ReactNode } from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import CostGrowth from './CostGrowth';
import {
defaultCurrencies,
Currency,
CurrencyType,
Duration,
findAlways,
} from '../../types';
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD);
const carbon = findAlways(
defaultCurrencies,
c => c.kind === CurrencyType.CarbonOffsetTons,
);
const MockContext = ({
children,
currency,
engineerCost,
}: {
children: ReactNode;
currency: Currency;
engineerCost: number;
}) => (
<MockConfigProvider
engineerCost={engineerCost}
currencies={defaultCurrencies}
metrics={[]}
products={[]}
icons={[]}
>
<MockCurrencyProvider currency={currency} setCurrency={jest.fn()}>
{children}
</MockCurrencyProvider>
</MockConfigProvider>
);
describe.each`
engineerCost | ratio | amount | expected
${200_000} | ${0} | ${0} | ${'Negligible'}
${200_000} | ${0} | ${8_333} | ${'Negligible'}
${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`}
${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`}
${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`}
${200_000} | ${3} | ${600_000} | ${`300% or ~36 ${engineers.unit}s`}
`('<CostGrowth />', ({ engineerCost, ratio, amount, expected }) => {
it(`formats ${engineers.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={engineers}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
});
});
describe.each`
engineerCost | ratio | amount | expected
${200_000} | ${0} | ${0} | ${'Negligible'}
${200_000} | ${0} | ${8_333} | ${'Negligible'}
${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'}
${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'}
${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'}
${200_000} | ${3} | ${600_000} | ${'300% or ~$600,000'}
`('<CostGrowth />', ({ engineerCost, ratio, amount, expected }) => {
it(`formats ${usd.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={usd}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
});
});
describe.each`
engineerCost | ratio | amount | expected
${200_000} | ${0} | ${0} | ${'Negligible'}
${200_000} | ${0} | ${8_333} | ${'Negligible'}
${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`}
${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`}
${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`}
${200_000} | ${3} | ${600_000} | ${`300% or ~171,429 ${carbon.unit}s`}
`('<CostGrowth />', ({ engineerCost, ratio, amount, expected }) => {
it(`formats ${carbon.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={carbon}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
});
});
@@ -0,0 +1,74 @@
/*
* 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 classnames from 'classnames';
import {
ChangeStatistic,
CurrencyType,
Duration,
EngineerThreshold,
Growth,
growthOf,
rateOf,
} from '../../types';
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
import { formatPercent, formatCurrency } from '../../utils/formatters';
import { indefiniteArticleOf } from '../../utils/grammar';
import { useConfig, useCurrency } from '../../hooks';
export type CostGrowthProps = {
change: ChangeStatistic;
duration: Duration;
};
const CostGrowth = ({ change, duration }: CostGrowthProps) => {
const styles = useStyles();
const { engineerCost } = useConfig();
const [currency] = useCurrency();
// Only display costs in absolute values
const amount = Math.abs(change.amount);
const ratio = Math.abs(change.ratio);
const rate = rateOf(engineerCost, duration);
const engineers = amount / rate;
const converted = amount / (currency.rate ?? rate);
// Determine if growth is significant enough to highlight
const growth = growthOf(engineers, change.ratio);
const classes = classnames({
[styles.excess]: growth === Growth.Excess,
[styles.savings]: growth === Growth.Savings,
});
const percent = formatPercent(ratio);
let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`;
// Always display the converted value but use the cost in engineers
// to determine negligibility, as costs should be time-period aware
if (engineers < EngineerThreshold) {
cost = 'Negligible';
} else if (currency.kind === CurrencyType.USD) {
cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`;
} else if (amount < 1) {
cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`;
}
return <span className={classes}>{cost}</span>;
};
export default CostGrowth;
@@ -0,0 +1,17 @@
/*
* 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 './CostGrowth';
@@ -0,0 +1,95 @@
/*
* 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 CostInsightsHeader from './CostInsightsHeader';
import { renderInTestApp } from '@backstage/test-utils';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
} from '@backstage/core';
import React from 'react';
describe('<CostInsightsHeader/>', () => {
const identityApi: Partial<IdentityApi> = {
getProfile: () => ({
email: 'test-email@example.com',
displayName: 'User 1',
}),
};
const apis = ApiRegistry.from([[identityApiRef, identityApi]]);
it('Shows nothing to do when no alerts exist', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<CostInsightsHeader
owner="test-owner"
groups={[{ id: 'test-user-group-1' }]}
hasCostData
alerts={0}
/>
</ApiProvider>,
);
expect(rendered.queryByText(/doing great/)).toBeInTheDocument();
});
it('Shows work to do when alerts > 1', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<CostInsightsHeader
owner="test-owner"
groups={[{ id: 'test-user-group-1' }]}
hasCostData
alerts={4}
/>
</ApiProvider>,
);
expect(rendered.queryByText(/few things/)).toBeInTheDocument();
});
it('Handles grammar with a single alert', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<CostInsightsHeader
owner="test-owner"
groups={[{ id: 'test-user-group-1' }]}
hasCostData
alerts={1}
/>
</ApiProvider>,
);
expect(rendered.queryByText(/things/)).not.toBeInTheDocument();
expect(rendered.queryByText(/one thing/)).toBeInTheDocument();
});
it('Shows no costs when hasCostData is false', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<CostInsightsHeader
owner="test-owner"
groups={[{ id: 'test-user-group-1' }]}
hasCostData={false}
alerts={1}
/>
</ApiProvider>,
);
expect(rendered.queryByText(/this is awkward/)).toBeInTheDocument();
});
});
@@ -0,0 +1,136 @@
/*
* 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 { Typography } from '@material-ui/core';
import { identityApiRef, ProfileInfo, useApi } from '@backstage/core';
import { useCostInsightsStyles } from '../../utils/styles';
import { Group } from '../../types';
function name(profile: ProfileInfo | undefined): string {
return profile?.displayName || 'Mysterious Stranger';
}
type CostInsightsHeaderProps = {
owner: string;
groups: Group[];
hasCostData: boolean;
alerts: number;
};
const CostInsightsHeader = (props: CostInsightsHeaderProps) => {
if (!props.hasCostData) {
return <CostInsightsHeaderNoData {...props} />;
}
if (props.alerts) {
return <CostInsightsHeaderAlerts {...props} />;
}
return <CostInsightsHeaderNoAlerts {...props} />;
};
const CostInsightsHeaderNoData = ({
owner,
groups,
}: CostInsightsHeaderProps) => {
const profile = useApi(identityApiRef).getProfile();
const classes = useCostInsightsStyles();
const hasMultipleGroups = groups.length > 1;
return (
<>
<Typography variant="h4" align="center" gutterBottom>
<span role="img" aria-label="flushed-face">
😳
</span>{' '}
Well this is awkward
</Typography>
<Typography className={classes.h6Subtle} align="center" gutterBottom>
<b>Hey, {name(profile)}!</b> <b>{owner}</b> doesn't seem to have any
cloud costs.
</Typography>
{hasMultipleGroups && (
<Typography align="center" gutterBottom>
Maybe we picked the wrong team, choose another from the menu above?
</Typography>
)}
</>
);
};
const CostInsightsHeaderAlerts = ({
owner,
alerts,
}: CostInsightsHeaderProps) => {
const profile = useApi(identityApiRef).getProfile();
const classes = useCostInsightsStyles();
return (
<>
<Typography variant="h4" align="center" gutterBottom>
<span role="img" aria-label="magnifying-glass">
🔎
</span>{' '}
You have {alerts} thing{alerts > 1 && 's'} to look into
</Typography>
<Typography className={classes.h6Subtle} align="center" gutterBottom>
<b>Hey, {name(profile)}!</b> We've identified{' '}
{alerts > 1 ? 'a few things ' : 'one thing '}
<b>{owner}</b> should look into next.
</Typography>
</>
);
};
const CostInsightsHeaderNoAlerts = ({ owner }: CostInsightsHeaderProps) => {
const profile = useApi(identityApiRef).getProfile();
const classes = useCostInsightsStyles();
return (
<>
<Typography variant="h4" gutterBottom align="center">
<span role="img" aria-label="thumbs-up">
👍
</span>{' '}
Your team is doing great
</Typography>
<Typography className={classes.h6Subtle} align="center" gutterBottom>
<b>Hey, {name(profile)}!</b> <b>{owner}</b> is doing well. No major
changes this month.
</Typography>
</>
);
};
export const CostInsightsHeaderNoGroups = () => {
const profile = useApi(identityApiRef).getProfile();
const classes = useCostInsightsStyles();
return (
<>
<Typography variant="h4" align="center" gutterBottom>
<span role="img" aria-label="flushed-face">
😳
</span>{' '}
Well this is awkward
</Typography>
<Typography className={classes.h6Subtle} align="center" gutterBottom>
<b>Hey, {name(profile)}!</b> It doesn't look like you belong to any
teams.
</Typography>
</>
);
};
export default CostInsightsHeader;
@@ -0,0 +1,17 @@
/*
* 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, CostInsightsHeaderNoGroups } from './CostInsightsHeader';
@@ -0,0 +1,59 @@
/*
* 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 { makeStyles } from '@material-ui/core';
import { Header, Page, pageTheme } from '@backstage/core';
import { Group } from '../../types';
import CostInsightsTabs from '../CostInsightsTabs';
const useStyles = makeStyles(theme => ({
root: {
gridArea: 'pageContent',
},
header: {
boxShadow: 'none',
},
content: {
padding: theme.spacing(4),
},
}));
type CostInsightsLayoutProps = {
title?: string;
groups: Group[];
children?: React.ReactNode;
};
const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => {
const classes = useStyles();
return (
<Page theme={pageTheme.tool}>
<Header
style={{ boxShadow: 'none' }}
title="Cost Insights"
pageTitleOverride="Cost Insights"
type="Tool"
/>
<div className={classes.root}>
<CostInsightsTabs groups={groups} />
<div className={classes.content}>{children}</div>
</div>
</Page>
);
};
export default CostInsightsLayout;
@@ -0,0 +1,17 @@
/*
* 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 './CostInsightsLayout';
@@ -0,0 +1,79 @@
/*
* 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 { default as HappyFace } from '@material-ui/icons/SentimentSatisfiedAlt';
import { renderInTestApp } from '@backstage/test-utils';
import CostInsightsNavigation from './CostInsightsNavigation';
import { defaultCurrencies, Metric, Product, Icon } from '../../types';
import { MockConfigProvider, MockScrollProvider } from '../../utils/tests';
import { getDefaultNavigationItems } from '../../utils/navigation';
const mockIcons: Icon[] = [
{
kind: 'some-product',
component: <HappyFace />,
},
];
const mockProducts: Product[] = [
{
kind: 'some-product',
name: 'Some Product',
},
];
const mockMetrics: Metric[] = [
{
kind: 'some-metric',
name: 'Some Metric',
},
];
const renderWrapped = (children: React.ReactNode) =>
renderInTestApp(
<MockConfigProvider
metrics={mockMetrics}
products={mockProducts}
icons={mockIcons}
engineerCost={20_000}
currencies={defaultCurrencies}
>
<MockScrollProvider>{children}</MockScrollProvider>
</MockConfigProvider>,
);
describe('<CostInsightsNavigation />', () => {
it('should render each navigation item', async () => {
const { getByText } = await renderWrapped(
<CostInsightsNavigation alerts={3} />,
);
getDefaultNavigationItems(3)
.map(item => item.title)
.concat(mockProducts.map(p => p.name))
.forEach(name => expect(getByText(name)).toBeInTheDocument());
});
it('should not display action items navigation if there are no action items', async () => {
const rendered = await renderWrapped(<CostInsightsNavigation alerts={0} />);
expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument();
});
it('should display the correct amount of action items in the badge', async () => {
const rendered = await renderWrapped(<CostInsightsNavigation alerts={3} />);
expect(rendered.getByText(/3/)).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
/*
* 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 {
MenuList,
MenuItem,
ListItemIcon,
ListItemText,
Typography,
Badge,
} from '@material-ui/core';
import { useNavigationStyles } from '../../utils/styles';
import { useConfig, useScroll } from '../../hooks';
import { findAlways } from '../../types';
import {
DefaultNavigation,
NavigationItem,
getDefaultNavigationItems,
} from '../../utils/navigation';
type CostInsightsNavigationProps = {
alerts: number;
};
const CostInsightsNavigation = ({ alerts }: CostInsightsNavigationProps) => {
const classes = useNavigationStyles();
const { products, icons } = useConfig();
const productNavigationItems: NavigationItem[] = products.map(product => ({
navigation: product.kind,
icon: findAlways(icons, i => i.kind === product.kind).component,
title: product.name,
}));
const navigationItems = getDefaultNavigationItems(alerts).concat(
productNavigationItems,
);
return (
<MenuList className={classes.menuList}>
{navigationItems.map((item: NavigationItem) => (
<NavigationMenuItem
key={`navigation-menu-item-${item.navigation}`}
navigation={item.navigation}
icon={
item.navigation === DefaultNavigation.AlertInsightsHeader ? (
<Badge badgeContent={alerts} color="secondary">
{React.cloneElement(item.icon, {
className: classes.navigationIcon,
})}
</Badge>
) : (
React.cloneElement(item.icon, {
className: classes.navigationIcon,
})
)
}
title={item.title}
/>
))}
</MenuList>
);
};
const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => {
const classes = useNavigationStyles();
const { scrollIntoView } = useScroll(navigation);
return (
<MenuItem
button
data-testid={`menu-item-${navigation}`}
className={classes.menuItem}
onClick={scrollIntoView}
>
<ListItemIcon className={classes.listItemIcon}>{icon}</ListItemIcon>
<ListItemText
primary={<Typography className={classes.title}>{title}</Typography>}
/>
</MenuItem>
);
};
export default CostInsightsNavigation;
@@ -0,0 +1,17 @@
/*
* 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 './CostInsightsNavigation';
@@ -0,0 +1,249 @@
/*
* 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, { useCallback, useEffect, useState } from 'react';
import { Box, Container, Divider, Grid } from '@material-ui/core';
import { Progress, useApi, featureFlagsApiRef } from '@backstage/core';
import { default as MaterialAlert } from '@material-ui/lab/Alert';
import { costInsightsApiRef } from '../../api';
import AlertActionCardList from '../AlertActionCardList';
import AlertInsights from '../AlertInsights';
import CostInsightsLayout from '../CostInsightsLayout';
import CopyUrlToClipboard from '../CopyUrlToClipboard';
import CurrencySelect from '../CurrencySelect';
import WhyCostsMatter from '../WhyCostsMatter';
import CostInsightsHeader, {
CostInsightsHeaderNoGroups,
} from '../CostInsightsHeader';
import CostInsightsNavigation from '../CostInsightsNavigation';
import CostOverviewCard from '../CostOverviewCard';
import ProductInsights from '../ProductInsights';
import CostInsightsSupportButton from '../CostInsightsSupportButton';
import {
useLoading,
useFilters,
useGroups,
useCurrency,
useConfig,
} from '../../hooks';
import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types';
import { mapLoadingToProps } from './selector';
const CostInsightsPage = () => {
const flags = useApi(featureFlagsApiRef).getFlags();
// 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 groups = useGroups();
const [currency, setCurrency] = useCurrency();
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { pageFilters } = useFilters(p => p);
const {
loadingActions,
loadingGroups,
loadingInitial,
dispatchInitial,
dispatchInsights,
dispatchNone,
} = useLoading(mapLoadingToProps);
/* eslint-disable react-hooks/exhaustive-deps */
// The dispatchLoading functions are derived from loading state using mapLoadingToProps, to
// provide nicer props for the component. These are re-derived whenever loading state changes,
// which causes an infinite loop as product panels load and re-trigger the useEffect below.
// Since the functions don't change, we can memoize - but we trigger the same loop if we satisfy
// exhaustive-deps by including the function itself in dependencies.
const dispatchLoadingInitial = useCallback(dispatchInitial, []);
const dispatchLoadingInsights = useCallback(dispatchInsights, []);
const dispatchLoadingNone = useCallback(dispatchNone, []);
/* eslint-enable react-hooks/exhaustive-deps */
useEffect(() => {
async function getInsights() {
setError(null);
try {
if (pageFilters.group) {
dispatchLoadingInsights(true);
const [
fetchedProjects,
fetchedCosts,
fetchedAlerts,
] = await Promise.all([
client.getGroupProjects(pageFilters.group),
pageFilters.project
? client.getProjectDailyCost(
pageFilters.project,
pageFilters.metric,
intervalsOf(pageFilters.duration),
)
: client.getGroupDailyCost(
pageFilters.group,
pageFilters.metric,
intervalsOf(pageFilters.duration),
),
client.getAlerts(pageFilters.group),
]);
setProjects(fetchedProjects);
setDailyCost(fetchedCosts);
setAlerts(fetchedAlerts);
} else {
dispatchLoadingNone(loadingActions);
}
} catch (e) {
setError(e);
dispatchLoadingNone(loadingActions);
} finally {
dispatchLoadingInitial(false);
dispatchLoadingInsights(false);
}
}
// Wait for user groups to finish loading
if (!loadingGroups) {
getInsights();
}
}, [
client,
pageFilters,
loadingGroups,
dispatchLoadingInsights,
dispatchLoadingInitial,
dispatchLoadingNone,
loadingActions,
]);
if (loadingInitial) {
return <Progress />;
}
if (error) {
return <MaterialAlert severity="error">{error.message}</MaterialAlert>;
}
// Loaded but no groups found for the user
if (!pageFilters.group) {
return (
<CostInsightsLayout groups={groups}>
<Box textAlign="right">
<CopyUrlToClipboard />
<CostInsightsSupportButton />
</Box>
<Container maxWidth="lg">
<CostInsightsHeaderNoGroups />
</Container>
<Divider />
<Container maxWidth="lg">
<WhyCostsMatter />
</Container>
</CostInsightsLayout>
);
}
// These should be defined, alerts can be an empty array but that's truthy
if (!dailyCost || !alerts) {
return (
<MaterialAlert severity="error">{`Error: Could not fetch cost insights data for team ${pageFilters.group}`}</MaterialAlert>
);
}
return (
<CostInsightsLayout groups={groups}>
<Grid container wrap="nowrap">
<Grid item>
<Box position="sticky" top={20}>
<CostInsightsNavigation alerts={alerts.length} />
</Box>
</Grid>
<Grid item xs>
<Box
display="flex"
flexDirection="row"
justifyContent="flex-end"
mb={2}
>
{!!flags.get('cost-insights-currencies') && (
<Box mr={1}>
<CurrencySelect
currency={currency}
currencies={currencies}
onSelect={setCurrency}
/>
</Box>
)}
<CopyUrlToClipboard />
<CostInsightsSupportButton />
</Box>
<Container maxWidth="lg" disableGutters>
<Grid container direction="column">
<Grid item xs>
<CostInsightsHeader
owner={pageFilters.group}
groups={groups}
hasCostData={!!dailyCost.aggregation.length}
alerts={alerts.length}
/>
</Grid>
{!!alerts.length && (
<>
<Grid item xs>
<Box px={3} py={6}>
<AlertActionCardList alerts={alerts} />
</Box>
</Grid>
<Divider />
</>
)}
<Grid item xs>
<Box px={3} py={6}>
{!!dailyCost.aggregation.length && (
<CostOverviewCard
change={dailyCost.change}
aggregation={dailyCost.aggregation}
trendline={dailyCost.trendline}
projects={projects || []}
/>
)}
<WhyCostsMatter />
</Box>
</Grid>
<Grid item xs>
{!!alerts?.length && (
<Box px={6} py={6} mx={-3} bgcolor="alertBackground">
<AlertInsights alerts={alerts} />
</Box>
)}
</Grid>
{!alerts.length && <Divider />}
<Grid item xs>
<Box px={3} py={6}>
<ProductInsights />
</Box>
</Grid>
</Grid>
</Container>
</Grid>
</Grid>
</CostInsightsLayout>
);
};
export default CostInsightsPage;
@@ -0,0 +1,45 @@
/*
* 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 CostInsightsPage from './CostInsightsPage';
import { FilterProvider } from '../../hooks/useFilters';
import { LoadingProvider } from '../../hooks/useLoading';
import { GroupsProvider } from '../../hooks/useGroups';
import { CurrencyProvider } from '../../hooks/useCurrency';
import { ScrollProvider } from '../../hooks/useScroll';
import { ConfigProvider } from '../../hooks/useConfig';
import { CostInsightsThemeProvider } from './CostInsightsThemeProvider';
const CostInsightsPageRoot = () => (
<CostInsightsThemeProvider>
<ConfigProvider>
<LoadingProvider>
<GroupsProvider>
<FilterProvider>
<ScrollProvider>
<CurrencyProvider>
<CostInsightsPage />
</CurrencyProvider>
</ScrollProvider>
</FilterProvider>
</GroupsProvider>
</LoadingProvider>
</ConfigProvider>
</CostInsightsThemeProvider>
);
export default CostInsightsPageRoot;
@@ -0,0 +1,50 @@
/*
* 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 { createMuiTheme, ThemeProvider } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import {
costInsightsDarkTheme,
costInsightsLightTheme,
} from '../../utils/styles';
import { CostInsightsTheme } from '../../types';
interface CostInsightsThemeProviderProps {
children?: React.ReactNode;
}
export const CostInsightsThemeProvider = ({
children,
}: CostInsightsThemeProviderProps) => {
return (
<ThemeProvider
theme={(theme: BackstageTheme) =>
createMuiTheme({
...theme,
palette: {
...theme.palette,
...(theme.palette.type === 'dark'
? costInsightsDarkTheme.palette
: costInsightsLightTheme.palette),
},
}) as CostInsightsTheme
}
>
{children}
</ThemeProvider>
);
};
@@ -0,0 +1,17 @@
/*
* 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 './CostInsightsPageRoot';
@@ -0,0 +1,42 @@
/*
* 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 { MapLoadingToProps } from '../../hooks';
import { getResetState, DefaultLoadingAction } from '../../types';
type CostInsightsPageLoadingProps = {
loadingActions: Array<string>;
loadingGroups: boolean;
loadingInitial: boolean;
dispatchInitial: (isLoading: boolean) => void;
dispatchInsights: (isLoading: boolean) => void;
dispatchNone: (loadingActions: string[]) => void;
};
export const mapLoadingToProps: MapLoadingToProps<CostInsightsPageLoadingProps> = ({
state,
actions,
dispatch,
}) => ({
loadingActions: actions,
loadingGroups: state[DefaultLoadingAction.UserGroups],
loadingInitial: state[DefaultLoadingAction.CostInsightsInitial],
dispatchInitial: (isLoading: boolean) =>
dispatch({ [DefaultLoadingAction.CostInsightsInitial]: isLoading }),
dispatchInsights: (isLoading: boolean) =>
dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }),
dispatchNone: (loadingActions: string[]) =>
dispatch(getResetState(loadingActions)),
});
@@ -0,0 +1,28 @@
/*
* 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 { SupportButton } from '@backstage/core';
const CostInsightsSupportButton = () => {
return (
<SupportButton>
Insights into cloud costs for your organization
</SupportButton>
);
};
export default CostInsightsSupportButton;
@@ -0,0 +1,17 @@
/*
* 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 './CostInsightsSupportButton';
@@ -0,0 +1,104 @@
/*
* 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 CostInsightsTabs from './CostInsightsTabs';
import UserEvent from '@testing-library/user-event';
import { Group, defaultCurrencies } from '../../types';
import { MockFilterProvider, MockConfigProvider } from '../../utils/tests';
import { LoadingContext } from '../../hooks/useLoading';
import { renderInTestApp } from '@backstage/test-utils';
import { mockDefaultState } from '../../utils/mockData';
const mockSetPageFilters = jest.fn();
const mockLoadingDispatch = jest.fn();
const mockGroups: Group[] = [
{
id: 'test-group-1',
},
{
id: 'test-group-2',
},
{
id: 'test-group-3',
},
];
describe('<CostInsightsTabs />', () => {
const renderWrapped = (children: React.ReactNode) =>
renderInTestApp(
<MockConfigProvider
products={[]}
metrics={[]}
icons={[]}
engineerCost={200_000}
currencies={defaultCurrencies}
>
<MockFilterProvider
setPageFilters={mockSetPageFilters}
setProductFilters={jest.fn()}
>
<LoadingContext.Provider
value={{
state: mockDefaultState,
dispatch: mockLoadingDispatch,
actions: [],
}}
>
{children}
</LoadingContext.Provider>
</MockFilterProvider>
</MockConfigProvider>,
);
it('Does NOT display the tabs bar if owner belongs to less than two GROUPS', async () => {
const oneGroup: Group[] = [{ id: 'test-group-1' }];
const rendered = await renderWrapped(
<CostInsightsTabs groups={oneGroup} />,
);
expect(
rendered.container.querySelector('.cost-insights-tabs'),
).not.toBeInTheDocument();
});
it('Displays the correct number of groups in the groups tab', async () => {
const rendered = await renderWrapped(
<CostInsightsTabs groups={mockGroups} />,
);
expect(rendered.getByText('3 teams')).toBeInTheDocument();
});
it('Applies the correct group filter when selected', async () => {
const selectedGroup = expect.objectContaining({ group: 'test-group-1' });
const rendered = await renderWrapped(
<CostInsightsTabs groups={mockGroups} />,
);
UserEvent.click(rendered.getByTestId('cost-insights-groups-tab'));
UserEvent.click(rendered.getByTestId('test-group-1'));
expect(mockSetPageFilters).toHaveBeenCalledWith(selectedGroup);
});
it('Displays the correct group names in the menu', async () => {
const rendered = await renderWrapped(
<CostInsightsTabs groups={mockGroups} />,
);
UserEvent.click(rendered.getByTestId('cost-insights-groups-tab'));
mockGroups.forEach(group =>
expect(rendered.getByText(group.id)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,111 @@
/*
* 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, { useState } from 'react';
import { Menu, MenuItem, Tab, Tabs, Typography } from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { mapLoadingToProps, mapFiltersToProps } from './selector';
import { Group } from '../../types';
import { useFilters, useLoading } from '../../hooks';
import { useCostInsightsTabsStyles as useStyles } from '../../utils/styles';
export type CostInsightsTabsProps = {
groups: Group[];
};
const CostInsightsTabs = ({ groups }: CostInsightsTabsProps) => {
const classes = useStyles();
const [index] = useState(0); // index is fixed for now until other tabs are added
const [groupMenuEl, setGroupMenuEl] = useState<Element | null>(null);
const { group, setGroup } = useFilters(mapFiltersToProps);
const { loadingActions, dispatchReset } = useLoading(mapLoadingToProps);
const openGroupMenu = (e: any) => setGroupMenuEl(e.currentTarget as Element);
const closeGroupMenu = () => setGroupMenuEl(null);
const updateGroupFilterAndCloseMenu = (g: Group) => () => {
dispatchReset(loadingActions);
closeGroupMenu();
setGroup(g);
};
const renderTabLabel = () => (
<div className={classes.tabLabel}>
<Typography className={classes.tabLabelText} variant="overline">
{`${groups.length} teams`}
</Typography>
<ExpandMoreIcon fontSize="small" />
</div>
);
const hasAtLeastTwoGroups = groups.length >= 2;
if (!hasAtLeastTwoGroups) return null;
return (
<>
<Tabs
className={`cost-insights-tabs ${classes.tabs}`}
data-testid="cost-insights-tabs"
classes={{ indicator: classes.indicator }}
value={index}
>
<Tab
className={classes.tab}
data-testid="cost-insights-groups-tab"
key="cost-insights-groups-tab"
label={renderTabLabel()}
onClick={openGroupMenu}
component="button"
/>
</Tabs>
<Menu
id="group-menu"
data-testid="group-menu"
className={classes.menu}
getContentAnchorEl={null}
anchorEl={groupMenuEl}
keepMounted
open={Boolean(groupMenuEl)}
onClose={closeGroupMenu}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
{groups.map((g: Group) => (
<MenuItem
className={classes.menuItem}
classes={{ selected: classes.menuItemSelected }}
selected={g.id === group}
key={g.id}
data-testid={g.id}
onClick={updateGroupFilterAndCloseMenu(g)}
>
{g.id}
</MenuItem>
))}
</Menu>
</>
);
};
export default CostInsightsTabs;
@@ -0,0 +1,17 @@
/*
* 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 './CostInsightsTabs';
@@ -0,0 +1,50 @@
/*
* 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 { MapFiltersToProps } from '../../hooks/useFilters';
import { MapLoadingToProps } from '../../hooks/useLoading';
import { Group, PageFilters, getResetStateWithoutInitial } from '../../types';
type CostInsightsTabsFilterProps = PageFilters & {
setGroup: (group: Group) => void;
};
type CostInsightsTabsLoadingProps = {
loadingActions: Array<string>;
dispatchReset: (loadingActions: string[]) => void;
};
export const mapFiltersToProps: MapFiltersToProps<CostInsightsTabsFilterProps> = ({
pageFilters,
setPageFilters,
}) => ({
...pageFilters,
setGroup: (group: Group) =>
setPageFilters({
...pageFilters,
group: group.id,
project: null,
}),
});
export const mapLoadingToProps: MapLoadingToProps<CostInsightsTabsLoadingProps> = ({
actions,
dispatch,
}) => ({
loadingActions: actions,
dispatchReset: (loadingActions: string[]) =>
dispatch(getResetStateWithoutInitial(loadingActions)),
});
@@ -0,0 +1,93 @@
/*
* 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, Card, CardContent, Divider } from '@material-ui/core';
import CostOverviewChart from '../CostOverviewChart';
import CostOverviewChartLegend from '../CostOverviewChartLegend';
import CostOverviewHeader from './CostOverviewHeader';
import CostOverviewFooter from './CostOverviewFooter';
import MetricSelect from '../MetricSelect';
import PeriodSelect from '../PeriodSelect';
import ProjectSelect from '../ProjectSelect';
import { useScroll, useFilters, useConfig } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { DefaultNavigation } from '../../utils/navigation';
import {
ChangeStatistic,
DateAggregation,
Project,
Trendline,
findAlways,
} from '../../types';
type CostOverviewCardProps = {
change: ChangeStatistic;
aggregation: Array<DateAggregation>;
trendline: Trendline;
projects: Array<Project>;
};
const CostOverviewCard = ({
change,
aggregation,
trendline,
projects,
}: CostOverviewCardProps) => {
const { metrics } = useConfig();
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
const { setDuration, setProject, metric, setMetric, ...filters } = useFilters(
mapFiltersToProps,
);
const { name } = findAlways(metrics, m => m.kind === metric);
return (
<Card style={{ position: 'relative' }}>
<ScrollAnchor behavior="smooth" top={-20} />
<CardContent>
<CostOverviewHeader title="Cloud Cost">
<PeriodSelect duration={filters.duration} onSelect={setDuration} />
</CostOverviewHeader>
<Divider />
<Box marginY={1} display="flex" flexDirection="column">
<CostOverviewChartLegend change={change} title={name} />
<CostOverviewChart
responsive
metric={metric}
tooltip={name}
aggregation={aggregation}
trendline={trendline}
/>
</Box>
<CostOverviewFooter>
<ProjectSelect
project={filters.project}
projects={projects}
onSelect={setProject}
/>
<MetricSelect
metric={metric}
metrics={metrics}
onSelect={setMetric}
/>
</CostOverviewFooter>
</CardContent>
</Card>
);
};
export default CostOverviewCard;
@@ -0,0 +1,36 @@
/*
* 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 } from '@material-ui/core';
type CostOverviewFooterProps = {
children?: React.ReactNode;
};
const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => (
<Box
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
{React.Children.map(children, child => (
<Box marginY={1}>{child}</Box>
))}
</Box>
);
export default CostOverviewFooter;
@@ -0,0 +1,53 @@
/*
* 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, Typography } from '@material-ui/core';
type CostOverviewHeaderProps = {
title: string;
subtitle?: string;
children?: React.ReactNode;
};
const CostOverviewHeader = ({
title,
subtitle,
children,
}: CostOverviewHeaderProps) => (
<Box
marginY={1}
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<Box minHeight={40} paddingRight={5}>
<Typography variant="h5" gutterBottom>
{title}
</Typography>
{!!subtitle && (
<Typography variant="subtitle2" color="textSecondary" component="div">
{subtitle}
</Typography>
)}
</Box>
<Box minHeight={40} maxHeight={60} display="flex">
{children}
</Box>
</Box>
);
export default CostOverviewHeader;
@@ -0,0 +1,17 @@
/*
* 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 './CostOverviewCard';
@@ -0,0 +1,46 @@
/*
* 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 { Duration, Maybe, PageFilters } from '../../types';
import { MapFiltersToProps } from '../../hooks/useFilters';
type CostOverviewFilterProps = PageFilters & {
setDuration: (duration: Duration) => void;
setProject: (project: Maybe<string>) => void;
setMetric: (metric: Maybe<string>) => void;
};
export const mapFiltersToProps: MapFiltersToProps<CostOverviewFilterProps> = ({
pageFilters,
setPageFilters,
}) => ({
...pageFilters,
project: pageFilters.project || 'all',
setDuration: (duration: Duration) =>
setPageFilters({
...pageFilters,
duration,
}),
setProject: (project: Maybe<string>) =>
setPageFilters({
...pageFilters,
project: project === 'all' ? null : project,
}),
setMetric: (metric: Maybe<string>) =>
setPageFilters({
...pageFilters,
metric: metric,
}),
});
@@ -0,0 +1,50 @@
/*
* 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();
});
});
@@ -0,0 +1,120 @@
/*
* 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;
@@ -0,0 +1,45 @@
/*
* 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 moment from 'moment';
import { TooltipPayload, TooltipProps } from 'recharts';
import Tooltip from '../../components/Tooltip';
import { DEFAULT_DATE_FORMAT } from '../../types';
import { formatGraphValue } from '../../utils/graphs';
type CostOverviewTooltipProps = TooltipProps & {
metric: string;
name: string;
};
const CostOverviewTooltip = ({
label,
payload,
metric,
name,
}: 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,
}));
return <Tooltip label={tooltipLabel} items={items} />;
};
export default CostOverviewTooltip;
@@ -0,0 +1,17 @@
/*
* 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';
@@ -0,0 +1,40 @@
/*
* 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();
});
});
@@ -0,0 +1,48 @@
/*
* 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;
@@ -0,0 +1,17 @@
/*
* 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 './CostOverviewChartLegend';
@@ -0,0 +1,75 @@
/*
* 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 { MenuItem, Select, SelectProps } from '@material-ui/core';
import { Currency, CurrencyType, findAlways } from '../../types';
import { useSelectStyles as useStyles } from '../../utils/styles';
const NULL_VALUE = 'engineers';
type CurrencySelectProps = {
currency: Currency;
currencies: Currency[];
onSelect: (currency: Currency) => void;
};
const CurrencySelect = ({
currency,
currencies,
onSelect,
}: CurrencySelectProps) => {
const classes = useStyles();
const getOption = (value: unknown) => {
const kind = (value === NULL_VALUE ? null : value) as CurrencyType;
return findAlways(currencies, c => c.kind === kind);
};
const handleOnChange: SelectProps['onChange'] = e => {
const option = getOption(e.target.value);
onSelect(option);
};
const renderValue: SelectProps['renderValue'] = value => {
const option = getOption(value);
return <b>{option.label}</b>;
};
return (
<Select
className={classes.select}
variant="outlined"
onChange={handleOnChange}
value={currency.kind || NULL_VALUE}
renderValue={renderValue}
>
{currencies.map((c: Currency) => (
<MenuItem
className={classes.menuItem}
key={c.kind || NULL_VALUE}
value={c.kind || NULL_VALUE}
>
<span role="img" aria-label={c.label}>
{c.label}
</span>
</MenuItem>
))}
</Select>
);
};
export default CurrencySelect;
@@ -0,0 +1,17 @@
/*
* 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 './CurrencySelect';
@@ -0,0 +1,96 @@
/*
* 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, Typography } from '@material-ui/core';
import { CodeSnippet } from '@backstage/core';
import AlertInstructionsLayout from '../AlertInstructionsLayout';
const LabelDataflowInstructionsPage = () => {
return (
<AlertInstructionsLayout title="Investigating Growth">
<Typography variant="h1">Labeling Dataflow Jobs</Typography>
<Typography paragraph>
Labels in Google Cloud Platform are key-value pairs that can be added to
most types of cloud resources. Since these labels are also exported in
billing data, adding labels allows a granular breakdown of cloud cost by
software entity.
</Typography>
<Typography paragraph>
In Cloud Dataflow, labels can be added to a job either programmatically
or via the command-line when launching a job. Note that GCP has
<a href="https://cloud.google.com/compute/docs/labeling-resources#restrictions">
restrictions
</a>{' '}
on the length and characters that can be used in labels.
</Typography>
<Typography paragraph>
Labels are not retroactive, so cost tracking is only possible from when
the labels are first added to a Dataflow job.
</Typography>
<Box mt={4}>
<Typography variant="h3">DataflowPipelineOptions</Typography>
<Typography paragraph>
Dataflow jobs using Beam's{' '}
<a href="https://beam.apache.org/releases/javadoc/2.3.0/org/apache/beam/runners/dataflow/options/DataflowPipelineOptions.html">
DataflowPipelineOptions
</a>{' '}
directly can use the <b>setLabels</b> function to add one or more
labels:
<CodeSnippet
language="java"
text={`private DataflowPipelineOptions options = PipelineOptionsFactory.fromArgs(args).as(DataflowPipelineOptionsImpl.class);
options.setLabels(ImmutableMap.of("job-id", "my-dataflow-job"));`}
/>
</Typography>
<Typography paragraph>
Dataflow jobs using Scio can similarly set options on the ScioContext:
<CodeSnippet
language="scala"
text={`val (sc: ScioContext, args: Args) = ContextAndArgs(cmdLineArgs)
sc.optionsAs[DataflowPipelineOptions].setLabels(Map("job-id" -> "my-dataflow-job").asJava)`}
/>
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h3">Command-line</Typography>
<Typography paragraph>
Dataflow jobs launched from the command-line can add labels as an
argument:
<CodeSnippet
language="shell"
text={`--labels={"job-id": "my-dataflow-job", "date-argument": "2020-09-16"}`}
/>
</Typography>
<Typography paragraph>
For more information on specifying options, see the{' '}
<a href="https://cloud.google.com/dataflow/docs/guides/specifying-exec-params">
Dataflow documentation
</a>{' '}
or{' '}
<a href="https://spotify.github.io/scio/api/com/spotify/scio/ScioContext.html">
Scio Scaladoc
</a>
.
</Typography>
</Box>
</AlertInstructionsLayout>
);
};
export default LabelDataflowInstructionsPage;
@@ -0,0 +1,17 @@
/*
* 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 './LabelDataflowInstructionsPage';
@@ -0,0 +1,78 @@
/*
* 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, Typography, Tooltip } from '@material-ui/core';
import LensIcon from '@material-ui/icons/Lens';
import HelpOutlineOutlinedIcon from '@material-ui/icons/HelpOutlineOutlined';
import { useCostGrowthLegendStyles } from '../../utils/styles';
type LegendItemProps = {
title: string;
tooltipText?: string;
markerColor?: string;
children?: React.ReactNode;
};
const LegendItem = ({
title,
tooltipText,
markerColor,
children,
}: LegendItemProps) => {
const classes = useCostGrowthLegendStyles();
return (
<Box display="flex" flexDirection="column">
<Box
minHeight={25}
display="flex"
flexDirection="row"
alignItems="center"
>
{markerColor && (
<div className={classes.marker}>
<LensIcon style={{ fontSize: '1em', fill: markerColor }} />
</div>
)}
<Typography className={classes.title} variant="overline">
{title}
</Typography>
{tooltipText && (
<Tooltip
classes={{ tooltip: classes.tooltip }}
title={
<Typography className={classes.tooltipText}>
{tooltipText}
</Typography>
}
placement="top-start"
>
<span role="img" aria-label="help" className={classes.helpIcon}>
<HelpOutlineOutlinedIcon fontSize="small" />
</span>
</Tooltip>
)}
</Box>
<Box marginLeft={markerColor ? '1.5em' : 0}>
<Typography className={classes.h5} variant="h5">
{children}
</Typography>
</Box>
</Box>
);
};
export default LegendItem;
@@ -0,0 +1,17 @@
/*
* 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 './LegendItem';
@@ -0,0 +1,71 @@
/*
* 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 { waitFor } from '@testing-library/react';
import UserEvent from '@testing-library/user-event';
import MetricSelect, { MetricSelectProps } from './MetricSelect';
import { renderInTestApp } from '@backstage/test-utils';
describe('<MetricSelect />', () => {
it('should display a metric', async () => {
const mockProps: MetricSelectProps = {
metric: 'test',
metrics: [{ kind: 'test', name: 'some-name' }],
onSelect: jest.fn(),
};
const { getByText } = await renderInTestApp(
<MetricSelect {...mockProps} />,
);
expect(getByText(/some-name/)).toBeInTheDocument();
});
it('should display a null metric', async () => {
const mockProps: MetricSelectProps = {
metric: null,
metrics: [{ kind: null, name: 'billie-nullish' }],
onSelect: jest.fn(),
};
const { getByText } = await renderInTestApp(
<MetricSelect {...mockProps} />,
);
expect(getByText(/billie-nullish/)).toBeInTheDocument();
});
it('should display all metrics', async () => {
const mockProps: MetricSelectProps = {
metric: null,
metrics: [
{ kind: null, name: 'billie-nullish' },
{ kind: 'MAU1M', name: 'Cost Per Million MAU' },
{ kind: 'my-cool-metric', name: 'metric-mcmetric-face' },
],
onSelect: jest.fn(),
};
const { getAllByText, getByText, getByRole } = await renderInTestApp(
<MetricSelect {...mockProps} />,
);
const button = getByRole('button');
UserEvent.click(button);
await waitFor(() => getAllByText(/billie-nullish/));
// The active metric should display in the popver list and in the input
expect(getAllByText(/billie-nullish/).length).toBe(2);
expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument();
expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/*
* 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 { Select, MenuItem } from '@material-ui/core';
import { Maybe, Metric, findAlways } from '../../types';
import { useSelectStyles as useStyles } from '../../utils/styles';
import { NULL_METRIC } from '../../hooks/useConfig';
export type MetricSelectProps = {
metric: Maybe<string>;
metrics: Array<Metric>;
onSelect: (metric: Maybe<string>) => void;
};
const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => {
const classes = useStyles();
const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => {
if (e.target.value === NULL_METRIC) {
onSelect(null);
} else {
onSelect(e.target.value as string);
}
};
const renderValue = (value: unknown) => {
const kind = (value === NULL_METRIC ? null : value) as Maybe<string>;
const { name } = findAlways(metrics, m => m.kind === kind);
return <b>{name}</b>;
};
return (
<Select
className={classes.select}
variant="outlined"
value={metric || NULL_METRIC}
renderValue={renderValue}
onChange={handleOnChange}
>
{metrics.map((m: Metric) => (
<MenuItem
className={classes.menuItem}
key={m.kind || NULL_METRIC}
value={m.kind || NULL_METRIC}
>
{m.name}
</MenuItem>
))}
</Select>
);
};
export default MetricSelect;
@@ -0,0 +1,17 @@
/*
* 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 './MetricSelect';
@@ -0,0 +1,84 @@
/*
* 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 { getByRole, waitFor } from '@testing-library/react';
import UserEvent from '@testing-library/user-event';
import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect';
import { Duration, getDefaultPageFilters, Group } from '../../types';
import { renderInTestApp } from '@backstage/test-utils';
const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]);
Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf());
describe('<PeriodSelect />', () => {
it('Renders without exploding', async () => {
const rendered = await renderInTestApp(
<PeriodSelect
duration={DefaultPageFilters.duration}
onSelect={jest.fn()}
/>,
);
expect(rendered.getByTestId('period-select')).toBeInTheDocument();
});
it('Should display all costGrowth period options', async () => {
const rendered = await renderInTestApp(
<PeriodSelect
duration={DefaultPageFilters.duration}
onSelect={jest.fn()}
/>,
);
const periodSelectContainer = rendered.getByTestId('period-select');
const button = getByRole(periodSelectContainer, 'button');
UserEvent.click(button);
await waitFor(() => rendered.getByText('Past 60 Days'));
options.forEach(option =>
expect(
rendered.getByTestId(`period-select-option-${option.value}`),
).toBeInTheDocument(),
);
});
describe.each`
duration
${Duration.P1M}
${Duration.P3M}
${Duration.P90D}
${Duration.P30D}
`('Should select the correct duration', ({ duration }) => {
it(`Should select ${duration}`, async () => {
const mockOnSelect = jest.fn();
const mockAggregation =
DefaultPageFilters.duration === duration
? Duration.P1M
: DefaultPageFilters.duration;
const rendered = await renderInTestApp(
<PeriodSelect duration={mockAggregation} onSelect={mockOnSelect} />,
);
const periodSelect = rendered.getByTestId('period-select');
const button = getByRole(periodSelect, 'button');
UserEvent.click(button);
await waitFor(() => rendered.getByText('Past 60 Days'));
UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`));
expect(mockOnSelect).toHaveBeenLastCalledWith(duration);
});
});
});
@@ -0,0 +1,100 @@
/*
* 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 { MenuItem, Select, SelectProps } from '@material-ui/core';
import {
formatLastTwoLookaheadQuarters,
formatLastTwoMonths,
} from '../../utils/formatters';
import { Duration, findAlways } from '../../types';
import { useSelectStyles as useStyles } from '../../utils/styles';
export type PeriodOption = {
value: Duration;
label: string;
};
const LAST_6_MONTHS = 'Past 6 Months';
const LAST_60_DAYS = 'Past 60 Days';
const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths();
const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters();
export const DEFAULT_OPTIONS: PeriodOption[] = [
{
value: Duration.P90D,
label: LAST_6_MONTHS,
},
{
value: Duration.P30D,
label: LAST_60_DAYS,
},
{
value: Duration.P1M,
label: LAST_2_COMPLETED_MONTHS,
},
{
value: Duration.P3M,
label: LAST_2_LOOKAHEAD_QUARTERS,
},
];
type PeriodSelectProps = {
duration: Duration;
onSelect: (duration: Duration) => void;
options?: PeriodOption[];
};
const PeriodSelect = ({
duration,
onSelect,
options = DEFAULT_OPTIONS,
}: PeriodSelectProps) => {
const classes = useStyles();
const handleOnChange: SelectProps['onChange'] = e => {
onSelect(e.target.value as Duration);
};
const renderValue: SelectProps['renderValue'] = value => {
const option = findAlways(DEFAULT_OPTIONS, o => o.value === value);
return <b>{option.label}</b>;
};
return (
<Select
className={classes.select}
variant="outlined"
onChange={handleOnChange}
value={duration}
renderValue={renderValue}
data-testid="period-select"
>
{options.map(option => (
<MenuItem
className={classes.menuItem}
key={option.value}
value={option.value}
data-testid={`period-select-option-${option.value}`}
>
{option.label}
</MenuItem>
))}
</Select>
);
};
export default PeriodSelect;
@@ -0,0 +1,17 @@
/*
* 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 './PeriodSelect';
@@ -0,0 +1,43 @@
/*
* 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, Typography, Grid } from '@material-ui/core';
import ProductInsightsCard from '../ProductInsightsCard';
import { useConfig } from '../../hooks';
const ProductInsights = ({}) => {
const { products } = useConfig();
return (
<>
<Box mt={0} mb={5} textAlign="center">
<Typography variant="h4" gutterBottom>
Your team's product usage
</Typography>
</Box>
<Grid container direction="column">
{products.map(product => (
<Grid item key={product.kind} style={{ position: 'relative' }}>
<ProductInsightsCard product={product} />
</Grid>
))}
</Grid>
</>
);
};
export default ProductInsights;
@@ -0,0 +1,17 @@
/*
* 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 './ProductInsights';
@@ -0,0 +1,167 @@
/*
* 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 ProductInsightsCard from './ProductInsightsCard';
import {
MockComputeEngine,
createMockEntity,
mockDefaultState,
createMockProductCost,
} from '../../utils/mockData';
import {
IdentityApi,
ApiRegistry,
identityApiRef,
ApiProvider,
} from '@backstage/core';
import { costInsightsApiRef, CostInsightsApi } from '../../api';
import { renderInTestApp } from '@backstage/test-utils';
import { GroupsContext } from '../../hooks/useGroups';
import { LoadingContext } from '../../hooks/useLoading';
import {
Product,
ProductCost,
defaultCurrencies,
findAlways,
} from '../../types';
import {
MockConfigProvider,
MockFilterProvider,
MockCurrencyProvider,
MockScrollProvider,
} from '../../utils/tests';
const mockLoadingDispatch = jest.fn();
const mockSetPageFilters = jest.fn();
const mockSetProductFilters = jest.fn();
const mockSetCurrency = jest.fn();
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const identityApi: Partial<IdentityApi> = {
getProfile: () => ({
email: 'test-email@example.com',
displayName: 'User 1',
}),
};
const costInsightsApi = (
productCost: ProductCost,
): Partial<CostInsightsApi> => ({
getProductInsights: () =>
Promise.resolve(productCost) as Promise<ProductCost>,
});
const getApis = (productCost: ProductCost) => {
return ApiRegistry.from([
[identityApiRef, identityApi],
[costInsightsApiRef, costInsightsApi(productCost)],
]);
};
const mockProductCost = createMockProductCost(() => ({
entities: [],
aggregation: [3000, 4000],
change: {
ratio: 0.23,
amount: 1000,
},
}));
const renderProductInsightsCardInTestApp = async (
productCost: ProductCost,
product: Product,
) =>
await renderInTestApp(
<ApiProvider apis={getApis(productCost)}>
<MockConfigProvider
metrics={[]}
products={[]}
icons={[]}
engineerCost={0}
currencies={[]}
>
<LoadingContext.Provider
value={{
state: mockDefaultState,
actions: [],
dispatch: mockLoadingDispatch,
}}
>
<GroupsContext.Provider value={{ groups: [{ id: 'test-group' }] }}>
<MockFilterProvider
setPageFilters={mockSetPageFilters}
setProductFilters={mockSetProductFilters}
>
<MockScrollProvider>
<MockCurrencyProvider
currency={engineers}
setCurrency={mockSetCurrency}
>
<ProductInsightsCard product={product} />
</MockCurrencyProvider>
</MockScrollProvider>
</MockFilterProvider>
</GroupsContext.Provider>
</LoadingContext.Provider>
</MockConfigProvider>
</ApiProvider>,
);
describe('<ProductInsightsCard/>', () => {
it('Renders the scroll anchors', async () => {
const rendered = await renderProductInsightsCardInTestApp(
mockProductCost,
MockComputeEngine,
);
expect(
rendered.queryByTestId(`scroll-test-compute-engine`),
).toBeInTheDocument();
});
it('Should render the right subheader for products with cost data', async () => {
const productCost = {
...mockProductCost,
entities: [...Array(1000)].map(createMockEntity),
};
const rendered = await renderProductInsightsCardInTestApp(
productCost,
MockComputeEngine,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(
`${productCost.entities.length} ${subheader}`,
);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
});
it('Should render the right subheader if there is no cost data or change data', async () => {
const productCost = { entities: [], aggregation: [0, 0] } as ProductCost;
const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`;
const rendered = await renderProductInsightsCardInTestApp(
productCost,
MockComputeEngine,
);
const subheaderRgx = new RegExp(subheader);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
expect(
rendered.queryByTestId('.resource-growth-chart-legend'),
).not.toBeInTheDocument();
expect(
rendered.queryByTestId('.insights-bar-chart'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,147 @@
/*
* 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, { useCallback, useEffect, useState } from 'react';
import { InfoCard, useApi } from '@backstage/core';
import { Box } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import { costInsightsApiRef } from '../../api';
import PeriodSelect from '../PeriodSelect';
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
import { useFilters, useLoading, useScroll } from '../../hooks';
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { mapFiltersToProps, mapLoadingToProps } from './selector';
import { Duration, Maybe, Product, ProductCost } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProductInsightsCardProps = {
product: Product;
};
const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
const client = useApi(costInsightsApiRef);
const classes = useStyles();
const { ScrollAnchor } = useScroll(product.kind);
const [resource, setResource] = useState<Maybe<ProductCost>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { group, product: productFilter, setProduct } = useFilters(
mapFiltersToProps(product.kind),
);
const { loadingProduct, dispatchLoading } = useLoading(
mapLoadingToProps(product.kind),
);
// @see CostInsightsPage
// eslint-disable-next-line react-hooks/exhaustive-deps
const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]);
const amount = resource?.entities?.length || 0;
const hasCostsWithinTimeframe = resource?.change && amount;
const subheader = amount
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
: `There are no ${product.name} costs within this timeframe for your team's projects.`;
const costStart = resource?.aggregation[0] || 0;
const costEnd = resource?.aggregation[1] || 0;
useEffect(() => {
async function load() {
if (loadingProduct) {
try {
const p: ProductCost = await client.getProductInsights(
product.kind,
group!,
productFilter!.duration,
);
setResource(p);
} catch (e) {
setError(e);
} finally {
dispatchLoadingProduct(false);
}
}
}
load();
}, [
client,
product,
setResource,
loadingProduct,
dispatchLoadingProduct,
productFilter,
group,
product.kind,
]);
const onPeriodSelect = (duration: Duration) => {
dispatchLoadingProduct(true);
setProduct(duration);
};
const infoCardProps = {
headerProps: {
classes: classes,
action: (
<PeriodSelect
duration={productFilter.duration}
onSelect={onPeriodSelect}
/>
),
},
};
if (error) {
return (
<InfoCard title={product.name} {...infoCardProps}>
<ScrollAnchor behavior="smooth" top={-12} />
<Alert severity="error">{`Error: Could not fetch product insights for ${product.name}`}</Alert>
</InfoCard>
);
}
if (!resource) {
return null;
}
return (
<InfoCard title={product.name} subheader={subheader} {...infoCardProps}>
<ScrollAnchor behavior="smooth" top={-12} />
{hasCostsWithinTimeframe && (
<>
<Box display="flex" flexDirection="column">
<Box pb={2}>
<ResourceGrowthBarChartLegend
duration={productFilter.duration}
change={resource.change!}
costStart={costStart}
costEnd={costEnd}
/>
</Box>
<ResourceGrowthBarChart
duration={productFilter.duration}
resources={resource.entities || []}
/>
</Box>
</>
)}
</InfoCard>
);
};
export default ProductInsightsCard;
@@ -0,0 +1,17 @@
/*
* 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 './ProductInsightsCard';
@@ -0,0 +1,56 @@
/*
* 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 { MapFiltersToProps } from '../../hooks/useFilters';
import { MapLoadingToProps } from '../../hooks/useLoading';
import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types';
type ProductInsightsCardFilterProps = PageFilters & {
product: ProductPeriod;
setProduct: (duration: Duration) => void;
};
type ProductInsightsCardLoadingProps = {
loadingProduct: boolean;
dispatchLoading: (isLoading: boolean) => void;
};
export const mapFiltersToProps = (
product: string,
): MapFiltersToProps<ProductInsightsCardFilterProps> => ({
pageFilters,
productFilters,
setProductFilters,
}) => ({
...pageFilters,
product: findAlways(productFilters, p => p.productType === product),
setProduct: (duration: Duration) =>
setProductFilters(
productFilters.map(period =>
period.productType === product ? { ...period, duration } : period,
),
),
});
export const mapLoadingToProps = (
product: string,
): MapLoadingToProps<ProductInsightsCardLoadingProps> => ({
state,
dispatch,
}) => ({
loadingProduct: state[product],
dispatchLoading: (isLoading: boolean) => dispatch({ [product]: isLoading }),
});
@@ -0,0 +1,85 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import ProjectGrowthAlertCard from './ProjectGrowthAlertCard';
import { createMockProjectGrowthAlert } from '../../utils/mockData';
import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests';
import { AlertCost, defaultCurrencies, findAlways } from '../../types';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const MockProject = 'test-project-1';
const MockAlertCosts: AlertCost[] = [
{ id: 'test-id-1', aggregation: [150, 200] },
{ id: 'test-id-2', aggregation: [235, 400] },
];
const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({
...alert,
project: MockProject,
products: MockAlertCosts,
}));
describe('<ProjectGrowthAlertCard />', () => {
it('renders the correct title and subheader for multiple services', async () => {
const subheader = new RegExp(
`${MockAlertCosts.length} products, sorted by cost`,
);
const title = new RegExp(`Project growth for ${MockProject}`);
const rendered = await renderInTestApp(
<MockConfigProvider
metrics={[]}
products={[]}
icons={[]}
engineerCost={200_000}
currencies={[]}
>
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
<ProjectGrowthAlertCard alert={MockProjectGrowthAlert} />,
</MockCurrencyProvider>
</MockConfigProvider>,
);
expect(rendered.getByText(title)).toBeInTheDocument();
expect(rendered.getByText(subheader)).toBeInTheDocument();
});
it('renders the correct title and subheader for a single service', async () => {
const subheader = new RegExp('1 product');
const title = new RegExp(`Project growth for ${MockProject}`);
const rendered = await renderInTestApp(
<MockConfigProvider
metrics={[]}
products={[]}
icons={[]}
engineerCost={200_000}
currencies={[]}
>
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
<ProjectGrowthAlertCard
alert={{
...MockProjectGrowthAlert,
products: [{ id: 'test-alert-id', aggregation: [0, 100] }],
}}
/>
</MockCurrencyProvider>
</MockConfigProvider>,
);
expect(rendered.getByText(title)).toBeInTheDocument();
expect(rendered.getByText(subheader)).toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
/*
* 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 } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
import { Duration, ProjectGrowthAlert } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
alert: ProjectGrowthAlert;
};
const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
const [costStart, costEnd] = alert.aggregation;
const subheader = `
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
alert.products.length > 1 ? ', sorted by cost' : ''
}`;
return (
<InfoCard
title={`Project growth for ${alert.project}`}
subheader={subheader}
>
<Box display="flex" flexDirection="column">
<Box pb={2}>
<ResourceGrowthBarChartLegend
change={alert.change}
duration={Duration.P3M}
costStart={costStart}
costEnd={costEnd}
/>
</Box>
<ResourceGrowthBarChart
resources={alert.products}
duration={Duration.P3M}
/>
</Box>
</InfoCard>
);
};
export default ProjectGrowthAlertCard;
@@ -0,0 +1,17 @@
/*
* 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 './ProjectGrowthAlertCard';
@@ -0,0 +1,212 @@
/*
* 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, Typography } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import AlertInstructionsLayout from '../AlertInstructionsLayout';
import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard';
import {
AlertType,
Duration,
Entity,
Product,
ProjectGrowthAlert,
} from '../../types';
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
const ProjectGrowthInstructionsPage = () => {
const projectGrowthAlert: ProjectGrowthAlert = {
id: AlertType.ProjectGrowth,
project: 'example-project',
periodStart: 'Q1 2020',
periodEnd: 'Q2 2020',
aggregation: [60000, 120000],
change: {
ratio: 1,
amount: 60000,
},
products: [
{
id: 'Compute Engine',
aggregation: [58000, 118000],
},
{
id: 'Cloud Dataflow',
aggregation: [1200, 1500],
},
{
id: 'Cloud Storage',
aggregation: [800, 500],
},
],
};
const product: Product = {
kind: 'ComputeEngine',
name: 'Compute Engine',
};
const entities: Entity[] = [
{
id: 'service-one',
aggregation: [18200, 58500],
},
{
id: 'service-two',
aggregation: [1200, 1300],
},
{
id: 'service-three',
aggregation: [600, 200],
},
];
return (
<AlertInstructionsLayout title="Investigating Growth">
<Typography variant="h1">Investigating cloud cost growth</Typography>
<Typography paragraph>
Cost Insights shows an alert when costs for a particular billing entity,
such as a GCP project, have grown at a rate faster than our alerting
threshold. The responsible team should follow this guide to decide
whether this warrants further investigation.
</Typography>
<Box mt={4}>
<Typography variant="h3">Is the growth expected?</Typography>
<Typography paragraph>
The first question to ask is whether growth is expected. Perhaps a new
product has been deployed, or additional regions added for
reliability.
</Typography>
<Typography paragraph>
Many services increase cost linearly with load. Has the demand
increased? This may happen as you open new markets, or run marketing
offers. Costs should be compared against a business metric, such as
daily users, to normalize natural increases from business growth.
</Typography>
<Typography paragraph>
Seasonal variance may also cause cost growth; yearly campaigns, an
increase in demand during certain times of year.
</Typography>
<Typography paragraph>
Cloud costs will often go up before they go down, in the case of
migrations. Teams moving to new infrastructure may run in both the old
and new environment during the migration.
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h3">Is the growth significant?</Typography>
<Typography paragraph>
Next, evaluate whether the growth is significant. This helps avoid
premature optimization, where cost in engineering time is more than
would be saved from the optimization over a reasonable timeframe.
</Typography>
<Typography paragraph>
We recommend reframing the cost growth itself in terms of engineering
time. How much engineering time, for an <i>average</i> fully-loaded
engineer cost at the company, is being overspent each month? Compare
this to expected engineering time for optimization to decide whether
the optimization is worthwhile.
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h3">
Identifying which cloud product contributed most
</Typography>
<Typography paragraph>
For projects meeting the alert threshold, Cost Insights shows a cost
comparison of cloud products over the examined time period:
</Typography>
<Box mt={2} mb={2}>
<ProjectGrowthAlertCard alert={projectGrowthAlert} />
</Box>
<Typography paragraph>
This allows you to quickly see which cloud products contributed to the
growth in cloud costs.
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h3">
Identifying the responsible workload
</Typography>
<Typography paragraph>
After identifying the cloud product, use the corresponding product
panel in Cost Insights to find a particular workload (or <i>entity</i>
) that has grown in cost:
</Typography>
<Box mt={2} mb={2}>
{/* ProductInsightsCard without API query / PeriodSelect */}
<InfoCard title={product.name} subheader="3 entities, sorted by cost">
<Box display="flex" flexDirection="column">
<Box paddingY={1}>
<ResourceGrowthBarChartLegend
duration={Duration.P3M}
change={{ ratio: 3, amount: 40000 }}
costStart={20000}
costEnd={60000}
/>
</Box>
<Box paddingY={1}>
<ResourceGrowthBarChart
duration={Duration.P3M}
resources={entities}
/>
</Box>
</Box>
</InfoCard>
</Box>
<Typography paragraph>
From here, you can dig into commit history or deployment logs to find
probable causes of an unexpected spike in cost.
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h3">Optimizing the workload</Typography>
<Typography paragraph>
Workload optimization varies between cloud products, but there are a
few general optimization areas to consider:
</Typography>
<Typography variant="h5">Retention</Typography>
<Typography paragraph>
Is the workload or storage necessary? Truly idle or unused resources
can be cleaned up for immediate cost savings. For storage, how long do
we need the data? Many cloud products support retention policies to
automatically delete data after a certain time period.
</Typography>
<Typography variant="h5">Efficiency</Typography>
<Typography paragraph>
Is the workload using cloud resources efficiently? For compute
resources, do the utilization metrics look reasonable? Autoscaling
infrastructure, such as Kubernetes, can run workloads more efficiently
without comprimising reliability.
</Typography>
<Typography variant="h5">Lifecycle</Typography>
<Typography paragraph>
Is the workload using an optimal pricing model? Some cloud products
offer better pricing for data that is accessed less frequently.
</Typography>
</Box>
</AlertInstructionsLayout>
);
};
export default ProjectGrowthInstructionsPage;
@@ -0,0 +1,17 @@
/*
* 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 './ProjectGrowthInstructionsPage';
@@ -0,0 +1,68 @@
/*
* 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 { getByRole, waitFor } from '@testing-library/react';
import UserEvent from '@testing-library/user-event';
import ProjectSelect from './ProjectSelect';
import { MockFilterProvider } from '../../utils/tests';
import { renderInTestApp } from '@backstage/test-utils';
const mockProjects = [
{ id: 'project1' },
{ id: 'project2' },
{ id: 'project3' },
];
const mockSetPageFilters = jest.fn();
describe('<ProjectSelect />', () => {
let Component: React.ReactNode;
beforeEach(() => {
Component = () => (
<MockFilterProvider
setPageFilters={mockSetPageFilters}
setProductFilters={jest.fn()}
>
<ProjectSelect
project="all"
projects={mockProjects}
onSelect={mockSetPageFilters}
/>
</MockFilterProvider>
);
});
it('Renders without exploding', async () => {
const rendered = await renderInTestApp(Component);
expect(rendered.getByText('All Projects')).toBeInTheDocument();
});
it('shows all projects in the filter select', async () => {
const rendered = await renderInTestApp(Component);
const projectSelectContainer = rendered.getByTestId(
'project-filter-select',
);
const button = getByRole(projectSelectContainer, 'button');
UserEvent.click(button);
await waitFor(() => rendered.getByTestId('option-all'));
mockProjects.forEach(
project =>
project.id &&
expect(rendered.getByText(project.id)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,70 @@
/*
* 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 { MenuItem, Select } from '@material-ui/core';
import { Maybe, Project } from '../../types';
import { useSelectStyles as useStyles } from '../../utils/styles';
type ProjectSelectProps = {
project: Maybe<string>;
projects: Array<Project>;
onSelect: (project: Maybe<string>) => void;
};
const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => {
const classes = useStyles();
const projectOptions = [{ id: 'all' } as Project, ...projects]
.filter(p => p.id)
.sort((a, b) => (a.id as string).localeCompare(b.id as string));
const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => {
onSelect(e.target.value as string);
};
const renderValue = (value: unknown) => {
const proj = value as string;
return (
<b data-testid={`selected-${proj}`}>
{proj === 'all' ? 'All Projects' : proj}
</b>
);
};
return (
<Select
className={classes.select}
variant="outlined"
value={project}
renderValue={renderValue}
onChange={handleOnChange}
data-testid="project-filter-select"
>
{projectOptions.map(proj => (
<MenuItem
className={`${classes.menuItem} compact`}
key={proj.id}
value={proj.id}
data-testid={`option-${proj.id}`}
>
{proj.id === 'all' ? 'All Projects' : proj.id}
</MenuItem>
))}
</Select>
);
};
export default ProjectSelect;
@@ -0,0 +1,17 @@
/*
* 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 './ProjectSelect';
@@ -0,0 +1,41 @@
/*
* 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 ResourceGrowthBarChart from './ResourceGrowthBarChart';
import { Duration } from '../../types';
import { renderInTestApp } from '@backstage/test-utils';
import { createMockEntity } from '../../utils/mockData';
const MockResources = [...Array(10)].map((_, index) =>
createMockEntity(() => ({
id: `test-id-${index + 1}`,
// grow resource costs linearly for testing
aggregation: [index * 1000, (index + 1) * 1000],
})),
);
describe('<ResourceGrowthBarChart/>', () => {
it('Pre-renders without exploding', async () => {
const rendered = await renderInTestApp(
<ResourceGrowthBarChart
duration={Duration.P1M}
resources={MockResources}
/>,
);
expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
/*
* 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 { TooltipPayload } from 'recharts';
import {
currencyFormatter,
dateRegex,
formatDuration,
} from '../../utils/formatters';
import {
BarChartData,
CostInsightsTheme,
DataKey,
Duration,
Entity,
inclusiveEndDateOf,
inclusiveStartDateOf,
Maybe,
ResourceData,
AlertCost,
} from '../../types';
import BarChart from '../BarChart';
import { TooltipItemProps } from '../Tooltip';
import { useTheme } from '@material-ui/core';
export type ResourceGrowthBarChartProps = {
duration: Duration;
resources: Array<Entity | AlertCost>;
};
const ResourceGrowthBarChart = ({
duration,
resources,
}: ResourceGrowthBarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const getTooltipItem = (payload: TooltipPayload): Maybe<TooltipItemProps> => {
const label = dateRegex.test(payload.name)
? formatDuration(payload.name, duration)
: payload.name;
const value =
typeof payload.value === 'number'
? currencyFormatter.format(payload.value)
: (payload.value as string);
const fill = payload.fill as string;
switch (payload.dataKey) {
case DataKey.Current:
case DataKey.Previous:
return {
label: label,
value: value,
fill: fill,
};
default:
return null;
}
};
const barChartData: BarChartData = {
previousFill: theme.palette.lightBlue,
currentFill: theme.palette.darkBlue,
previousName: inclusiveStartDateOf(duration),
currentName: inclusiveEndDateOf(duration),
};
const resourceData: ResourceData[] = resources.map(resource => {
return {
name: resource.id,
previous: resource.aggregation[0],
current: resource.aggregation[1],
};
});
return (
<BarChart
barChartData={barChartData}
getTooltipItem={getTooltipItem}
resources={resourceData}
/>
);
};
export default ResourceGrowthBarChart;
@@ -0,0 +1,17 @@
/*
* 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 './ResourceGrowthBarChart';
@@ -0,0 +1,93 @@
/*
* 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, { ReactNode } from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import ResourceGrowthBarChartLegend from './ResourceGrowthBarChartLegend';
import { Currency, defaultCurrencies, Duration, findAlways } from '../../types';
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const MockContext = ({
children,
currency,
}: {
children: ReactNode;
currency: Currency;
}) => (
<MockConfigProvider
engineerCost={200_000}
currencies={[]}
metrics={[]}
products={[]}
icons={[]}
>
<MockCurrencyProvider currency={currency} setCurrency={jest.fn()}>
{children}
</MockCurrencyProvider>
</MockConfigProvider>
);
describe('<ResourceGrowthBarChartLegend />', () => {
describe.each`
ratio | amount | costText | engineerTest
${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/}
${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/}
`(
'Should display the cost text',
({ ratio, amount, costText, engineerTest }) => {
it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => {
const rendered = await renderInTestApp(
<MockContext currency={engineers}>
<ResourceGrowthBarChartLegend
duration={Duration.P3M}
change={{ ratio, amount }}
costStart={1000}
costEnd={5000}
/>
</MockContext>,
);
expect(rendered.getByText(costText)).toBeInTheDocument();
expect(rendered.queryByText(engineerTest)).toBeInTheDocument();
});
},
);
describe.each`
duration | periodStartText | periodEndText
${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'}
${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'}
`(
'Should display the correct relative time',
({ duration, periodStartText, periodEndText }) => {
it(`Should display the correct relative time for ${duration}`, async () => {
const rendered = await renderInTestApp(
<MockContext currency={engineers}>
<ResourceGrowthBarChartLegend
change={{ ratio: -2.5, amount: 100_000 }}
duration={duration}
costStart={1000}
costEnd={5000}
/>
</MockContext>,
);
expect(rendered.getByText(periodStartText)).toBeInTheDocument();
expect(rendered.getByText(periodEndText)).toBeInTheDocument();
});
},
);
});

Some files were not shown because too many files have changed in this diff Show More