Merge pull request #2637 from spotify/plugin-cost-insights
Cost Insights Plugin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
+61
@@ -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';
|
||||
+79
@@ -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();
|
||||
});
|
||||
});
|
||||
+97
@@ -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)),
|
||||
});
|
||||
+28
@@ -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';
|
||||
+40
@@ -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();
|
||||
});
|
||||
});
|
||||
+48
@@ -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';
|
||||
+96
@@ -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';
|
||||
+167
@@ -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 }),
|
||||
});
|
||||
+85
@@ -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();
|
||||
});
|
||||
});
|
||||
+60
@@ -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';
|
||||
+212
@@ -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';
|
||||
+41
@@ -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();
|
||||
});
|
||||
});
|
||||
+97
@@ -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';
|
||||
+93
@@ -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
Reference in New Issue
Block a user