Merge remote-tracking branch 'backstage/master' into erikengervall/plugin-release-manager-as-a-service
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': minor
|
||||
---
|
||||
|
||||
make change ratio optional
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-tech-radar': patch
|
||||
---
|
||||
|
||||
Update README for composability
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-proxy-backend': patch
|
||||
---
|
||||
|
||||
Prefix proxy routes with `/` if not present in configuration
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/cli': patch
|
||||
'@backstage/config-loader': patch
|
||||
'@backstage/config': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Bump `json-schema` dependency from `0.2.5` to `0.3.0`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/techdocs-common': patch
|
||||
---
|
||||
|
||||
Adding optional config to enable S3-like API for tech-docs using s3ForcePathStyle option.
|
||||
This allows providers like LocalStack, Minio and Wasabi (+possibly others) to be used to host tech docs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-client': patch
|
||||
---
|
||||
|
||||
Allow `filter` parameter to be specified multiple times
|
||||
@@ -41,8 +41,10 @@ Kaewkasi
|
||||
Knex
|
||||
Leasot
|
||||
Lerna
|
||||
LocalStack
|
||||
Luxon
|
||||
Minikube
|
||||
Minio
|
||||
Mkdocs
|
||||
Monorepo
|
||||
Namespaces
|
||||
|
||||
@@ -78,6 +78,11 @@ techdocs:
|
||||
# https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
|
||||
endpoint: ${AWS_ENDPOINT}
|
||||
|
||||
# (Optional) Whether to use path style URLs when communicating with S3.
|
||||
# Defaults to false.
|
||||
# This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs.
|
||||
s3ForcePathStyle: false
|
||||
|
||||
# Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise.
|
||||
|
||||
azureBlobStorage:
|
||||
|
||||
@@ -36,7 +36,7 @@ Example:
|
||||
```yaml
|
||||
# in app-config.yaml
|
||||
proxy:
|
||||
'/simple-example': http://simple.example.com:8080
|
||||
simple-example: http://simple.example.com:8080
|
||||
'/larger-example/v1':
|
||||
target: http://larger.example.com:8080/svc.v1
|
||||
headers:
|
||||
@@ -46,10 +46,11 @@ proxy:
|
||||
```
|
||||
|
||||
Each key under the proxy configuration entry is a route to match, below the
|
||||
prefix that the proxy plugin is mounted on. It must start with a slash. For
|
||||
example, if the backend mounts the proxy plugin as `/proxy`, the above
|
||||
configuration will lead to the proxy acting on backend requests to
|
||||
`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`.
|
||||
prefix that the proxy plugin is mounted on. If it does not start with a slash,
|
||||
one will be prefixed automatically. For example, if the backend mounts the proxy
|
||||
plugin as `/proxy`, the above configuration will lead to the proxy acting on
|
||||
backend requests to `/api/proxy/simple-example/...` and
|
||||
`/api/proxy/larger-example/v1/...`.
|
||||
|
||||
The value inside each route is either a simple URL string, or an object on the
|
||||
format accepted by
|
||||
|
||||
@@ -67,7 +67,7 @@ export class CatalogClient implements CatalogApi {
|
||||
|
||||
// @public (undocumented)
|
||||
export type CatalogEntitiesRequest = {
|
||||
filter?: Record<string, string | string[]> | undefined;
|
||||
filter?: Record<string, string | string[]>[] | Record<string, string | string[]> | undefined;
|
||||
fields?: string[] | undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +76,38 @@ describe('CatalogClient', () => {
|
||||
expect(response).toEqual(defaultResponse);
|
||||
});
|
||||
|
||||
it('builds entity search filters properly', async () => {
|
||||
it('builds multiple entity search filters properly', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
|
||||
expect(req.url.search).toBe(
|
||||
'?filter=a=1,b=2,b=3,%C3%B6=%3D&filter=a=2',
|
||||
);
|
||||
return res(ctx.json([]));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntities(
|
||||
{
|
||||
filter: [
|
||||
{
|
||||
a: '1',
|
||||
b: ['2', '3'],
|
||||
ö: '=',
|
||||
},
|
||||
{
|
||||
a: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds single entity search filter properly', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
server.use(
|
||||
|
||||
@@ -56,18 +56,27 @@ export class CatalogClient implements CatalogApi {
|
||||
request?: CatalogEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogListResponse<Entity>> {
|
||||
const { filter = {}, fields = [] } = request ?? {};
|
||||
const { filter = [], fields = [] } = request ?? {};
|
||||
const filterItems = [filter].flat();
|
||||
const params: string[] = [];
|
||||
|
||||
const filterParts: string[] = [];
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
for (const v of [value].flat()) {
|
||||
filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
|
||||
// filter param can occur multiple times, for example
|
||||
// /api/catalog/entities?filter=metadata.name=wayback-search,kind=component&filter=metadata.name=www-artist,kind=component'
|
||||
// the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters
|
||||
// the "inner array" defined within a `filter` param corresponds to "allOf" filters
|
||||
for (const filterItem of filterItems) {
|
||||
const filterParts: string[] = [];
|
||||
for (const [key, value] of Object.entries(filterItem)) {
|
||||
for (const v of [value].flat()) {
|
||||
filterParts.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(v)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filterParts.length) {
|
||||
params.push(`filter=${filterParts.join(',')}`);
|
||||
if (filterParts.length) {
|
||||
params.push(`filter=${filterParts.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length) {
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
import { Entity, EntityName, Location } from '@backstage/catalog-model';
|
||||
|
||||
export type CatalogEntitiesRequest = {
|
||||
filter?: Record<string, string | string[]> | undefined;
|
||||
filter?:
|
||||
| Record<string, string | string[]>[]
|
||||
| Record<string, string | string[]>
|
||||
| undefined;
|
||||
fields?: string[] | undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@types/json-schema": "^7.0.5",
|
||||
"@types/yup": "^0.29.8",
|
||||
"ajv": "^7.0.3",
|
||||
"json-schema": "^0.2.5",
|
||||
"json-schema": "^0.3.0",
|
||||
"lodash": "^4.17.15",
|
||||
"uuid": "^8.0.0",
|
||||
"yup": "^0.29.3"
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"inquirer": "^7.0.4",
|
||||
"jest": "^26.0.1",
|
||||
"jest-css-modules": "^2.1.0",
|
||||
"json-schema": "^0.2.5",
|
||||
"json-schema": "^0.3.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"ora": "^5.3.0",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"ajv": "^7.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"json-schema": "^0.2.5",
|
||||
"json-schema": "^0.3.0",
|
||||
"json-schema-merge-allof": "^0.7.0",
|
||||
"typescript-json-schema": "^0.49.0",
|
||||
"yaml": "^1.9.2",
|
||||
|
||||
@@ -80,10 +80,17 @@ export class AwsS3Publish implements PublisherBase {
|
||||
'techdocs.publisher.awsS3.endpoint',
|
||||
);
|
||||
|
||||
// AWS forcePathStyle is an optional config. If missing, it defaults to false. Needs to be enabled for cases
|
||||
// where endpoint url points to locally hosted S3 compatible storage like Localstack
|
||||
const s3ForcePathStyle = config.getOptionalBoolean(
|
||||
'techdocs.publisher.awsS3.s3ForcePathStyle',
|
||||
);
|
||||
|
||||
const storageClient = new aws.S3({
|
||||
credentials,
|
||||
...(region && { region }),
|
||||
...(endpoint && { endpoint }),
|
||||
...(s3ForcePathStyle && { s3ForcePathStyle }),
|
||||
});
|
||||
|
||||
return new AwsS3Publish(storageClient, bucketName, logger);
|
||||
|
||||
@@ -46,6 +46,7 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${`~1 ${engineers.unit}`}
|
||||
${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}`}
|
||||
@@ -65,6 +66,9 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${-1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${'~$10,000'}
|
||||
${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'}
|
||||
@@ -84,6 +88,8 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${`~2,857 ${carbon.unit}s`}
|
||||
${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`}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useCostGrowthStyles as useStyles } from '../../utils/styles';
|
||||
import { formatPercent, formatCurrency } from '../../utils/formatters';
|
||||
import { indefiniteArticleOf } from '../../utils/grammar';
|
||||
import { useConfig, useCurrency } from '../../hooks';
|
||||
import { notEmpty } from '../../utils/assert';
|
||||
|
||||
export type CostGrowthProps = {
|
||||
change: ChangeStatistic;
|
||||
@@ -42,31 +43,65 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => {
|
||||
|
||||
// Only display costs in absolute values
|
||||
const amount = Math.abs(change.amount);
|
||||
const ratio = Math.abs(change.ratio);
|
||||
const ratio = Math.abs(change.ratio ?? NaN);
|
||||
|
||||
const rate = rateOf(engineerCost, duration);
|
||||
const engineers = amount / rate;
|
||||
const converted = amount / (currency.rate ?? rate);
|
||||
|
||||
// If a ratio cannot be calculated, don't format.
|
||||
const growth = notEmpty(change.ratio)
|
||||
? growthOf({ ratio: change.ratio, amount: engineers })
|
||||
: null;
|
||||
// Determine if growth is significant enough to highlight
|
||||
const growth = growthOf(change.ratio, engineers);
|
||||
const classes = classnames({
|
||||
[styles.excess]: growth === GrowthType.Excess,
|
||||
[styles.savings]: growth === GrowthType.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}>Negligible</span>;
|
||||
}
|
||||
|
||||
return <span className={classes}>{cost}</span>;
|
||||
if (currency.kind === CurrencyType.USD) {
|
||||
// Do not display percentage if ratio cannot be calculated
|
||||
if (isNaN(ratio)) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
~{currency.prefix}
|
||||
{formatCurrency(converted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{formatPercent(ratio)} or ~{currency.prefix}
|
||||
{formatCurrency(converted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (amount < 1) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
less than {indefiniteArticleOf(['a', 'an'], currency.unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Do not display percentage if ratio cannot be calculated
|
||||
if (isNaN(ratio)) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
~{formatCurrency(converted, currency.unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{formatPercent(ratio)} or ~{formatCurrency(converted, currency.unit)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,8 +21,6 @@ import { ChangeThreshold, EngineerThreshold } from '../../types';
|
||||
|
||||
describe.each`
|
||||
ratio | amount | ariaLabel
|
||||
${-0.1} | ${undefined} | ${'savings'}
|
||||
${0.01} | ${undefined} | ${'excess'}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'}
|
||||
@@ -32,7 +30,7 @@ describe.each`
|
||||
`('growthOf', ({ ratio, amount, ariaLabel }) => {
|
||||
it(`should display the correct indicator for ${ariaLabel}`, async () => {
|
||||
const { getByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
<CostGrowthIndicator change={{ ratio, amount }} />,
|
||||
);
|
||||
expect(getByLabelText(ariaLabel)).toBeInTheDocument();
|
||||
});
|
||||
@@ -40,7 +38,8 @@ describe.each`
|
||||
|
||||
describe.each`
|
||||
ratio | amount
|
||||
${0} | ${undefined}
|
||||
${undefined} | ${0}
|
||||
${0} | ${0}
|
||||
${ChangeThreshold.lower} | ${0}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1}
|
||||
@@ -49,7 +48,7 @@ describe.each`
|
||||
`('growthOf', ({ ratio, amount }) => {
|
||||
it('should display the correct indicator for negligible growth', async () => {
|
||||
const { queryByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
<CostGrowthIndicator change={{ ratio, amount }} />,
|
||||
);
|
||||
expect(queryByLabelText('savings')).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('excess')).not.toBeInTheDocument();
|
||||
|
||||
@@ -20,49 +20,33 @@ import { Typography, TypographyProps } from '@material-ui/core';
|
||||
import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp';
|
||||
import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown';
|
||||
import { growthOf } from '../../utils/change';
|
||||
import { GrowthType } from '../../types';
|
||||
import { ChangeStatistic, GrowthType, Maybe } from '../../types';
|
||||
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type CostGrowthIndicatorProps = TypographyProps & {
|
||||
ratio: number;
|
||||
amount?: number;
|
||||
formatter?: (amount: number) => string;
|
||||
change: ChangeStatistic;
|
||||
formatter?: (change: ChangeStatistic) => Maybe<string>;
|
||||
};
|
||||
|
||||
export const CostGrowthIndicator = ({
|
||||
ratio,
|
||||
amount,
|
||||
change,
|
||||
formatter,
|
||||
className,
|
||||
...props
|
||||
}: CostGrowthIndicatorProps) => {
|
||||
const classes = useStyles();
|
||||
const growth = growthOf(ratio, amount);
|
||||
const growth = growthOf(change);
|
||||
|
||||
const classNames = classnames(classes.indicator, className, {
|
||||
[classes.savings]: growth === GrowthType.Savings,
|
||||
[classes.excess]: growth === GrowthType.Excess,
|
||||
[classes.savings]: growth === GrowthType.Savings,
|
||||
});
|
||||
|
||||
// Display cost as a factor of engineer cost growth and percentage growth
|
||||
if (typeof amount === 'number') {
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(amount) : amount}
|
||||
{growth === GrowthType.Savings && (
|
||||
<ArrowDropDown aria-label="savings" />
|
||||
)}
|
||||
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
// Display cost as a factor of percent change
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(ratio) : ratio}
|
||||
{ratio < 0 && <ArrowDropDown aria-label="savings" />}
|
||||
{ratio > 0 && <ArrowDropUp aria-label="excess" />}
|
||||
{formatter ? formatter(change) : change.ratio}
|
||||
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
|
||||
{growth === GrowthType.Savings && <ArrowDropDown aria-label="savings" />}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 2021 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 { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { CostOverviewLegend } from './CostOverviewLegend';
|
||||
import {
|
||||
MockBillingDateProvider,
|
||||
MockConfigProvider,
|
||||
MockFilterProvider,
|
||||
MockCurrencyProvider,
|
||||
} from '../../testUtils';
|
||||
|
||||
function renderInTestApp(children: JSX.Element) {
|
||||
return render(
|
||||
wrapInTestApp(
|
||||
<MockConfigProvider>
|
||||
<MockCurrencyProvider>
|
||||
<MockBillingDateProvider>
|
||||
<MockFilterProvider>{children}</MockFilterProvider>
|
||||
</MockBillingDateProvider>
|
||||
</MockCurrencyProvider>
|
||||
</MockConfigProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('<CostOverviewLegend />', () => {
|
||||
it('displays the legend without exploding', async () => {
|
||||
const { findByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={{
|
||||
id: 'msc',
|
||||
format: 'number',
|
||||
aggregation: [],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
aggregation: [],
|
||||
change: {
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(await findByText('MSC Trend')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display metric legend if metric data is not provided', async () => {
|
||||
const { findByText, queryByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={null}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
aggregation: [],
|
||||
change: {
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(queryByText('MSC Trend')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
ratio | amount | title | expected
|
||||
${undefined} | ${1_000} | ${'∞'} | ${'Your Excess'}
|
||||
${undefined} | ${-1_000} | ${'-∞'} | ${'Your Savings'}
|
||||
`('<CostOverviewLegend />', ({ ratio, amount, title, expected }) => {
|
||||
it('displays the correct legend if ratio cannot be calculated and costs are within time period', async () => {
|
||||
const { findByText, findAllByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={{
|
||||
id: 'msc',
|
||||
format: 'number',
|
||||
change: {
|
||||
ratio: ratio,
|
||||
amount: amount,
|
||||
},
|
||||
aggregation: [
|
||||
{
|
||||
date: '2020-01-01',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
date: '2020-07-01', // within default P90D period
|
||||
amount: amount,
|
||||
},
|
||||
],
|
||||
}}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
change: {
|
||||
ratio,
|
||||
amount,
|
||||
},
|
||||
aggregation: [
|
||||
{
|
||||
date: '2020-01-01',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
date: '2020-07-01', // within default P90D period
|
||||
amount: amount,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(await findByText('MSC Trend')).toBeInTheDocument();
|
||||
expect(await findAllByText(title).then(res => res.length)).toBe(2);
|
||||
expect(await findByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
Metric,
|
||||
} from '../../types';
|
||||
import { useLastCompleteBillingDate, useFilters } from '../../hooks';
|
||||
import { getComparedChange } from '../../utils/change';
|
||||
import { getComparedChange, choose } from '../../utils/change';
|
||||
import { mapFiltersToProps } from './selector';
|
||||
import { formatPercent } from '../../utils/formatters';
|
||||
import { formatChange } from '../../utils/formatters';
|
||||
import { CostGrowth } from '../CostGrowth';
|
||||
|
||||
type CostOverviewLegendProps = {
|
||||
@@ -42,9 +42,8 @@ export const CostOverviewLegend = ({
|
||||
metricData,
|
||||
}: PropsWithChildren<CostOverviewLegendProps>) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const { duration } = useFilters(mapFiltersToProps);
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
|
||||
const comparedChange = metricData
|
||||
? getComparedChange(
|
||||
@@ -57,23 +56,25 @@ export const CostOverviewLegend = ({
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="row">
|
||||
<Box mr={2}>
|
||||
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
|
||||
{formatPercent(dailyCostData.change!.ratio)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
{metric && metricData && comparedChange && (
|
||||
{dailyCostData.change && (
|
||||
<Box mr={2}>
|
||||
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
|
||||
{formatChange(dailyCostData.change)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
)}
|
||||
{metricData && metric && comparedChange && (
|
||||
<>
|
||||
<Box mr={2}>
|
||||
<LegendItem
|
||||
title={`${metric.name} Trend`}
|
||||
markerColor={theme.palette.magenta}
|
||||
>
|
||||
{formatPercent(metricData.change.ratio)}
|
||||
{formatChange(metricData.change)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<LegendItem
|
||||
title={comparedChange.ratio <= 0 ? 'Your Savings' : 'Your Excess'}
|
||||
title={choose(['Your Savings', 'Your Excess'], comparedChange)}
|
||||
>
|
||||
<CostGrowth change={comparedChange} duration={duration} />
|
||||
</LegendItem>
|
||||
|
||||
@@ -18,10 +18,10 @@ import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { costFormatter, formatPercent } from '../../utils/formatters';
|
||||
import { costFormatter, formatChange } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { BarChartOptions, Entity } from '../../types';
|
||||
import { BarChartOptions, ChangeStatistic, Entity } from '../../types';
|
||||
|
||||
export type ProductEntityTableOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
@@ -32,7 +32,7 @@ type RowData = {
|
||||
label: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
change: ChangeStatistic;
|
||||
};
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
@@ -41,7 +41,7 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
[classes.colLast]: col === 'change',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
@@ -52,12 +52,12 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
{costFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'ratio':
|
||||
case 'change':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
ratio={row.ratio}
|
||||
formatter={amount => formatPercent(Math.abs(amount))}
|
||||
change={row.change}
|
||||
formatter={formatChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -75,10 +75,15 @@ function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'label') return a.label.localeCompare(b.label);
|
||||
if (field === 'change') {
|
||||
if (formatChange(a[field]) === '∞' || formatChange(b[field]) === '-∞')
|
||||
return 1;
|
||||
if (formatChange(a[field]) === '-∞' || formatChange(b[field]) === '∞')
|
||||
return -1;
|
||||
return a[field].ratio! - b[field].ratio!;
|
||||
}
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
: b.previous + b.current - (a.previous + a.current);
|
||||
return b.previous + b.current - (a.previous + a.current);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,11 +139,11 @@ export const ProductEntityTable = ({
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'ratio',
|
||||
field: 'change',
|
||||
title: <Typography className={lastColClasses}>Change</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('ratio', classes),
|
||||
customSort: createSorter('ratio'),
|
||||
render: createRenderer('change', classes),
|
||||
customSort: createSorter('change'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -148,14 +153,14 @@ export const ProductEntityTable = ({
|
||||
label: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
change: e.change,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
label: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
change: entity.change,
|
||||
})
|
||||
.sort(createSorter());
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
findAnyKey,
|
||||
assertAlways,
|
||||
} from '../../utils/assert';
|
||||
import { formatPeriod, formatPercent } from '../../utils/formatters';
|
||||
import { formatPeriod, formatChange } from '../../utils/formatters';
|
||||
import {
|
||||
titleOf,
|
||||
tooltipItemOf,
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
useBarChartLayoutStyles as useLayoutStyles,
|
||||
} from '../../utils/styles';
|
||||
import { Duration, Entity, Maybe } from '../../types';
|
||||
import { choose } from '../../utils/change';
|
||||
|
||||
export type ProductInsightsChartProps = {
|
||||
billingDate: string;
|
||||
@@ -86,7 +87,6 @@ export const ProductInsightsChart = ({
|
||||
return breakdowns.length > 0;
|
||||
}, [entities, activeLabel]);
|
||||
|
||||
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
|
||||
const costStart = entity.aggregation[0];
|
||||
const costEnd = entity.aggregation[1];
|
||||
const resources = entities.map(resourceOf);
|
||||
@@ -136,7 +136,6 @@ export const ProductInsightsChart = ({
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
|
||||
const activeEntity = findAlways(entities, e => e.id === id);
|
||||
const ratio = activeEntity.change.ratio;
|
||||
const breakdowns = Object.keys(activeEntity.entities);
|
||||
|
||||
if (breakdowns.length) {
|
||||
@@ -148,11 +147,13 @@ export const ProductInsightsChart = ({
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
!!activeEntity.change.ratio && (
|
||||
<CostGrowthIndicator
|
||||
formatter={formatChange}
|
||||
change={activeEntity.change}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Box className={classes.actions}>
|
||||
@@ -173,11 +174,13 @@ export const ProductInsightsChart = ({
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
!!activeEntity.change.ratio && (
|
||||
<CostGrowthIndicator
|
||||
formatter={formatChange}
|
||||
change={activeEntity.change}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
content={
|
||||
id
|
||||
@@ -197,7 +200,9 @@ export const ProductInsightsChart = ({
|
||||
return (
|
||||
<Box className={layoutClasses.wrapper}>
|
||||
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
|
||||
<LegendItem title={legendTitle}>
|
||||
<LegendItem
|
||||
title={choose(['Cost Savings', 'Cost Excess'], entity.change)}
|
||||
>
|
||||
<CostGrowth change={entity.change} duration={duration} />
|
||||
</LegendItem>
|
||||
</BarChartLegend>
|
||||
|
||||
@@ -294,7 +294,6 @@ export const MockBigQueryInsights: Entity = {
|
||||
id: 'dataset-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -415,7 +414,6 @@ export const MockCloudDataflowInsights: Entity = {
|
||||
id: 'pipeline-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -503,7 +501,6 @@ export const MockCloudStorageInsights: Entity = {
|
||||
id: 'Mock SKU C',
|
||||
aggregation: [2_000, 0],
|
||||
change: {
|
||||
ratio: -1,
|
||||
amount: -2000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -515,7 +512,6 @@ export const MockCloudStorageInsights: Entity = {
|
||||
id: 'bucket-c',
|
||||
aggregation: [0, 0],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: {},
|
||||
@@ -655,7 +651,6 @@ export const MockComputeEngineInsights: Entity = {
|
||||
id: 'service-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
|
||||
@@ -93,10 +93,16 @@ export function changeOf(aggregation: DateAggregation[]): ChangeStatistic {
|
||||
const lastAmount = aggregation.length
|
||||
? aggregation[aggregation.length - 1].amount
|
||||
: 0;
|
||||
const ratio =
|
||||
firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0;
|
||||
|
||||
// if either the first or last amounts are zero, the rate of increase/decrease is infinite
|
||||
if (!firstAmount || !lastAmount) {
|
||||
return {
|
||||
amount: lastAmount - firstAmount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ratio: ratio,
|
||||
ratio: (lastAmount - firstAmount) / firstAmount,
|
||||
amount: lastAmount - firstAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
export interface ChangeStatistic {
|
||||
// The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum
|
||||
ratio: number;
|
||||
// If a ratio cannot be calculated - such as when a new or old sum is zero,
|
||||
// the ratio can be omitted and where applicable, ∞ or -∞ will display based on amount.
|
||||
ratio?: number;
|
||||
// The actual USD change between time periods (can be negative if costs decreased)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function notEmpty<TValue>(
|
||||
return !isNull(value) && !isUndefined(value);
|
||||
}
|
||||
|
||||
export function isUndefined(value: any): boolean {
|
||||
export function isUndefined(value: any): value is undefined {
|
||||
return value === undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,23 @@ const GrowthMap = {
|
||||
|
||||
describe.each`
|
||||
ratio | amount | expected
|
||||
${0.0} | ${undefined} | ${GrowthType.Negligible}
|
||||
${undefined} | ${0} | ${GrowthType.Negligible}
|
||||
${0.0} | ${0} | ${GrowthType.Negligible}
|
||||
${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower - 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper - 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.upper + 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess}
|
||||
`(
|
||||
@@ -63,7 +63,7 @@ describe.each`
|
||||
expected: GrowthType;
|
||||
}) => {
|
||||
it(`should display ${GrowthMap[expected]}`, () => {
|
||||
expect(growthOf(ratio, amount)).toBe(expected);
|
||||
expect(growthOf({ ratio, amount })).toBe(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,21 +27,26 @@ import {
|
||||
import dayjs, { OpUnitType } from 'dayjs';
|
||||
import durationPlugin from 'dayjs/plugin/duration';
|
||||
import { inclusiveStartDateOf } from './duration';
|
||||
import { notEmpty } from './assert';
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
// Used for displaying status colors
|
||||
export function growthOf(ratio: number, amount?: number) {
|
||||
if (typeof amount === 'number') {
|
||||
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
|
||||
export function growthOf(change: ChangeStatistic): GrowthType {
|
||||
const exceedsEngineerThreshold = Math.abs(change.amount) >= EngineerThreshold;
|
||||
|
||||
if (notEmpty(change.ratio)) {
|
||||
if (exceedsEngineerThreshold && change.ratio >= ChangeThreshold.upper) {
|
||||
return GrowthType.Excess;
|
||||
}
|
||||
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
|
||||
|
||||
if (exceedsEngineerThreshold && change.ratio <= ChangeThreshold.lower) {
|
||||
return GrowthType.Savings;
|
||||
}
|
||||
} else {
|
||||
if (ratio >= ChangeThreshold.upper) return GrowthType.Excess;
|
||||
if (ratio <= ChangeThreshold.lower) return GrowthType.Savings;
|
||||
if (exceedsEngineerThreshold && change.amount > 0) return GrowthType.Excess;
|
||||
if (exceedsEngineerThreshold && change.amount < 0)
|
||||
return GrowthType.Savings;
|
||||
}
|
||||
|
||||
return GrowthType.Negligible;
|
||||
@@ -54,15 +59,24 @@ export function getComparedChange(
|
||||
duration: Duration,
|
||||
lastCompleteBillingDate: string, // YYYY-MM-DD,
|
||||
): ChangeStatistic {
|
||||
const ratio = dailyCost.change!.ratio - metricData.change.ratio;
|
||||
const dailyCostRatio = dailyCost.change?.ratio;
|
||||
const metricDataRatio = metricData.change?.ratio;
|
||||
const previousPeriodTotal = getPreviousPeriodTotalCost(
|
||||
dailyCost.aggregation,
|
||||
duration,
|
||||
lastCompleteBillingDate,
|
||||
);
|
||||
|
||||
// if either ratio cannot be calculated, no compared ratio can be calculated
|
||||
if (!notEmpty(dailyCostRatio) || !notEmpty(metricDataRatio)) {
|
||||
return {
|
||||
amount: previousPeriodTotal,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ratio: ratio,
|
||||
amount: previousPeriodTotal * ratio,
|
||||
ratio: dailyCostRatio - metricDataRatio,
|
||||
amount: previousPeriodTotal * (dailyCostRatio - metricDataRatio),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +92,6 @@ export function getPreviousPeriodTotalCost(
|
||||
? [dayjsDuration.days(), 'day']
|
||||
: [dayjsDuration.months(), 'month'];
|
||||
const nextPeriodStart = dayjs(startDate).add(amount, type);
|
||||
|
||||
// Add up costs that incurred before the start of the next period.
|
||||
return aggregation.reduce((acc, costByDate) => {
|
||||
return dayjs(costByDate.date).isBefore(nextPeriodStart)
|
||||
@@ -86,3 +99,11 @@ export function getPreviousPeriodTotalCost(
|
||||
: acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function choose<T>(
|
||||
[savings, excess]: [T, T],
|
||||
change: ChangeStatistic,
|
||||
): T {
|
||||
const isSavings = (change.ratio ?? change.amount) <= 0;
|
||||
return isSavings ? savings : excess;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import moment from 'moment';
|
||||
import pluralize from 'pluralize';
|
||||
import { Duration } from '../types';
|
||||
import { ChangeStatistic, Duration } from '../types';
|
||||
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
|
||||
import { notEmpty } from './assert';
|
||||
|
||||
export type Period = {
|
||||
periodStart: string;
|
||||
@@ -78,6 +79,13 @@ export function formatCurrency(amount: number, currency?: string): string {
|
||||
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
|
||||
}
|
||||
|
||||
export function formatChange(change: ChangeStatistic): string {
|
||||
if (notEmpty(change.ratio)) {
|
||||
return formatPercent(Math.abs(change.ratio));
|
||||
}
|
||||
return change.amount >= 0 ? '∞' : '-∞';
|
||||
}
|
||||
|
||||
export function formatPercent(n: number): string {
|
||||
// Number.toFixed shows scientific notation for extreme numbers
|
||||
if (isNaN(n) || Math.abs(n) < 0.01) {
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('buildMiddleware', () => {
|
||||
mockCreateProxyMiddleware.mockClear();
|
||||
});
|
||||
|
||||
it('accepts strings', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', 'http://mocked');
|
||||
it('accepts strings prefixed by /', async () => {
|
||||
buildMiddleware('/proxy', logger, '/test', 'http://mocked');
|
||||
|
||||
expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -74,13 +74,53 @@ describe('buildMiddleware', () => {
|
||||
expect(filter('', { method: 'PATCH', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'DELETE', headers: {} })).toBe(true);
|
||||
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' });
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' });
|
||||
expect(fullConfig.changeOrigin).toBe(true);
|
||||
expect(fullConfig.logProvider!(logger)).toBe(logger);
|
||||
});
|
||||
|
||||
it('accepts routes not prefixed with / when path is not suffixed with /', async () => {
|
||||
buildMiddleware('/proxy', logger, 'test', 'http://mocked');
|
||||
|
||||
expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [
|
||||
(pathname: string, req: Partial<http.IncomingMessage>) => boolean,
|
||||
ProxyMiddlewareConfig,
|
||||
];
|
||||
expect(filter('', { method: 'GET', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'POST', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'PUT', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'PATCH', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'DELETE', headers: {} })).toBe(true);
|
||||
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' });
|
||||
expect(fullConfig.changeOrigin).toBe(true);
|
||||
expect(fullConfig.logProvider!(logger)).toBe(logger);
|
||||
});
|
||||
|
||||
it('accepts routes prefixed with / when path is suffixed with /', async () => {
|
||||
buildMiddleware('/proxy/', logger, '/test', 'http://mocked');
|
||||
|
||||
expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [
|
||||
(pathname: string, req: Partial<http.IncomingMessage>) => boolean,
|
||||
ProxyMiddlewareConfig,
|
||||
];
|
||||
expect(filter('', { method: 'GET', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'POST', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'PUT', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'PATCH', headers: {} })).toBe(true);
|
||||
expect(filter('', { method: 'DELETE', headers: {} })).toBe(true);
|
||||
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' });
|
||||
expect(fullConfig.changeOrigin).toBe(true);
|
||||
expect(fullConfig.logProvider!(logger)).toBe(logger);
|
||||
});
|
||||
|
||||
it('limits allowedMethods', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
allowedMethods: ['GET', 'DELETE'],
|
||||
});
|
||||
@@ -97,13 +137,13 @@ describe('buildMiddleware', () => {
|
||||
expect(filter('', { method: 'PATCH', headers: {} })).toBe(false);
|
||||
expect(filter('', { method: 'DELETE', headers: {} })).toBe(true);
|
||||
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' });
|
||||
expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' });
|
||||
expect(fullConfig.changeOrigin).toBe(true);
|
||||
expect(fullConfig.logProvider!(logger)).toBe(logger);
|
||||
});
|
||||
|
||||
it('permits default headers', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
});
|
||||
|
||||
@@ -143,7 +183,7 @@ describe('buildMiddleware', () => {
|
||||
});
|
||||
|
||||
it('permits default and configured headers', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
headers: {
|
||||
Authorization: 'my-token',
|
||||
@@ -176,7 +216,7 @@ describe('buildMiddleware', () => {
|
||||
});
|
||||
|
||||
it('permits configured headers', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
allowedHeaders: ['authorization', 'cookie'],
|
||||
});
|
||||
@@ -208,7 +248,7 @@ describe('buildMiddleware', () => {
|
||||
});
|
||||
|
||||
it('responds default headers', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
});
|
||||
|
||||
@@ -251,7 +291,7 @@ describe('buildMiddleware', () => {
|
||||
});
|
||||
|
||||
it('responds configured headers', async () => {
|
||||
buildMiddleware('/api/', logger, 'test', {
|
||||
buildMiddleware('/proxy', logger, '/test', {
|
||||
target: 'http://mocked',
|
||||
allowedHeaders: ['set-cookie'],
|
||||
});
|
||||
@@ -282,10 +322,10 @@ describe('buildMiddleware', () => {
|
||||
|
||||
it('rejects malformed target URLs', async () => {
|
||||
expect(() =>
|
||||
buildMiddleware('/api/', logger, 'test', 'backstage.io'),
|
||||
buildMiddleware('/proxy', logger, '/test', 'backstage.io'),
|
||||
).toThrowError(/Proxy target is not a valid URL/);
|
||||
expect(() =>
|
||||
buildMiddleware('/api/', logger, 'test', { target: 'backstage.io' }),
|
||||
buildMiddleware('/proxy', logger, '/test', { target: 'backstage.io' }),
|
||||
).toThrowError(/Proxy target is not a valid URL/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,10 +77,24 @@ export function buildMiddleware(
|
||||
`Proxy target is not a valid URL: ${fullConfig.target ?? ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Default is to do a path rewrite that strips out the proxy's path prefix
|
||||
// and the rest of the route.
|
||||
if (fullConfig.pathRewrite === undefined) {
|
||||
const routeWithSlash = route.endsWith('/') ? route : `${route}/`;
|
||||
let routeWithSlash = route.endsWith('/') ? route : `${route}/`;
|
||||
|
||||
if (!pathPrefix.endsWith('/') && !routeWithSlash.startsWith('/')) {
|
||||
// Need to insert a / between pathPrefix and routeWithSlash
|
||||
routeWithSlash = `/${routeWithSlash}`;
|
||||
} else if (pathPrefix.endsWith('/') && routeWithSlash.startsWith('/')) {
|
||||
// Never expect this to happen at this point in time as
|
||||
// pathPrefix is set using `getExternalBaseUrl` which "Returns the
|
||||
// external HTTP base backend URL for a given plugin,
|
||||
// **without a trailing slash.**". But in case this changes in future, we
|
||||
// need to drop a / on either pathPrefix or routeWithSlash
|
||||
routeWithSlash = routeWithSlash.substring(1);
|
||||
}
|
||||
|
||||
fullConfig.pathRewrite = {
|
||||
[`^${pathPrefix}${routeWithSlash}`]: '/',
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@rjsf/core": "^2.4.0",
|
||||
"@rjsf/material-ui": "^2.4.0",
|
||||
"classnames": "^2.2.6",
|
||||
"json-schema": "^0.2.5",
|
||||
"json-schema": "^0.3.0",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"humanize-duration": "^3.25.1",
|
||||
"immer": "^9.0.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ It serves and scales well for teams and companies of all sizes that want to have
|
||||
|
||||
The Tech Radar can be used in two ways:
|
||||
|
||||
- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation, and you can set a variety of configuration directly in your `apis.ts`.
|
||||
- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation.
|
||||
- **Advanced** - This gives you the React UI component directly. It enables you to insert the Radar on your own layout or page for a more customized feel.
|
||||
|
||||
### Install
|
||||
@@ -26,6 +26,7 @@ The Tech Radar can be used in two ways:
|
||||
For either simple or advanced installations, you'll need to add the dependency using Yarn:
|
||||
|
||||
```sh
|
||||
cd packages/app
|
||||
yarn add @backstage/plugin-tech-radar
|
||||
```
|
||||
|
||||
@@ -34,17 +35,16 @@ yarn add @backstage/plugin-tech-radar
|
||||
Modify your app routes to include the Router component exported from the tech radar, for example:
|
||||
|
||||
```tsx
|
||||
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
|
||||
// in packages/app/src/App.tsx
|
||||
import { TechRadarPage } from '@backstage/plugin-tech-radar';
|
||||
|
||||
// Inside App component
|
||||
<Routes>
|
||||
{/* other routes ... */}
|
||||
<Route
|
||||
path="/tech-radar"
|
||||
element={<TechRadarRouter width={1500} height={800} />}
|
||||
/>
|
||||
{/* other routes ... */}
|
||||
</Routes>;
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
{/* ... */}
|
||||
<Route
|
||||
path="/tech-radar"
|
||||
element={<TechRadarPage width={1500} height={800} />}
|
||||
/>
|
||||
```
|
||||
|
||||
If you'd like to configure it more, see the `TechRadarPageProps` and `TechRadarComponentProps` types for options:
|
||||
|
||||
Vendored
+7
@@ -90,6 +90,13 @@ export interface Config {
|
||||
* @visibility secret
|
||||
*/
|
||||
endpoint?: string;
|
||||
/**
|
||||
* (Optional) Whether to use path style URLs when communicating with S3.
|
||||
* Defaults to false.
|
||||
* This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs.
|
||||
* @visibility backend
|
||||
*/
|
||||
s3ForcePathStyle?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -6295,9 +6295,9 @@
|
||||
integrity sha512-09sXZZVsB3Ib41U0fC+O1O+4UOZT1bl/e+/QubPxpqDWHNEchvx/DEb1KJMOwq6K3MTNzZFoNSzVdR++o1DVnw==
|
||||
|
||||
"@types/luxon@^1.25.0":
|
||||
version "1.26.0"
|
||||
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36"
|
||||
integrity sha512-zYmLYGczqBaOFaFjR1giG1QCbGMlXWOJcYgH9Mnk0MGcZHq1aer3ZwGXX8vd9NOfai6mAI/mVU6jD9hK0Wys7Q==
|
||||
version "1.26.5"
|
||||
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.5.tgz#843fb705e16e4d2a90847a351b799ea9d879859e"
|
||||
integrity sha512-XeQxxRMyJi1znfzHw4CGDLyup/raj84SnjjkI2fDootZPGlB0yqtvlvEIAmzHDa5wiEI5JJevZOWxpcofsaV+A==
|
||||
|
||||
"@types/markdown-to-jsx@^6.11.0":
|
||||
version "6.11.2"
|
||||
@@ -6453,9 +6453,9 @@
|
||||
"@types/passport-oauth2" "*"
|
||||
|
||||
"@types/passport-google-oauth20@^2.0.3":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.3.tgz#f554ff6d39f395acff3f1d762e54462194dac8da"
|
||||
integrity sha512-6EUEGzEg4acwowvgR/yVZIj8S2Kkwc6JmlY2/wnM1wJHNz20o7s1TIGrxnah8ymLgJasYDpy95P3TMMqlmetPw==
|
||||
version "2.0.7"
|
||||
resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.7.tgz#0d97b7a886a0c0d2158682145cd949b29f8efe86"
|
||||
integrity sha512-0HPVSqDmOWk5fRLb+bqGal+6iWsERiEco/Mli77yy5NEy22IfkoRoqZTSZ8UtXDWY9DCZlpS1Jqq56iWx2torw==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
"@types/passport" "*"
|
||||
@@ -16965,10 +16965,10 @@ json-schema@0.2.3:
|
||||
resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
|
||||
integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
|
||||
|
||||
json-schema@^0.2.5:
|
||||
version "0.2.5"
|
||||
resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz#97997f50972dd0500214e208c407efa4b5d7063b"
|
||||
integrity sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ==
|
||||
json-schema@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b"
|
||||
integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
@@ -24297,9 +24297,9 @@ stacktrace-js@^2.0.0, stacktrace-js@^2.0.2:
|
||||
stacktrace-gps "^3.0.4"
|
||||
|
||||
start-server-and-test@^1.10.11:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.12.0.tgz#e836553c928a13026f79c740757d378b92bee8d6"
|
||||
integrity sha512-y3M/PLUPkPBsgKoengMIMQeceT8uOnOc4bkdor/RSCK9Ih/j8z4WthSCrAboXLjgtJJWOporAiEQsnYox+THXg==
|
||||
version "1.12.1"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.12.1.tgz#bf84eb5c5a4c8a98b93ed36519035b3f76179f0e"
|
||||
integrity sha512-qGQ2HQiF2yDIfyaHsXkHfoE5UOl4zJUbJ/gx2xOkfX7iPMXW9qHmoFyaMfIDJVLNkxCK7RxSrvWEI9hNVKQluw==
|
||||
dependencies:
|
||||
bluebird "3.7.2"
|
||||
check-more-types "2.24.0"
|
||||
@@ -24307,7 +24307,7 @@ start-server-and-test@^1.10.11:
|
||||
execa "3.4.0"
|
||||
lazy-ass "1.6.0"
|
||||
ps-tree "1.2.0"
|
||||
wait-on "5.2.1"
|
||||
wait-on "5.3.0"
|
||||
|
||||
start-server-webpack-plugin@^2.2.5:
|
||||
version "2.2.5"
|
||||
@@ -26420,14 +26420,14 @@ w3c-xmlserializer@^2.0.0:
|
||||
dependencies:
|
||||
xml-name-validator "^3.0.0"
|
||||
|
||||
wait-on@5.2.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/wait-on/-/wait-on-5.2.1.tgz#05b66fcb4d7f5da01537f03e7cf96e8836422996"
|
||||
integrity sha512-H2F986kNWMU9hKlI9l/ppO6tN8ZSJd35yBljMLa1/vjzWP++Qh6aXyt77/u7ySJFZQqBtQxnvm/xgG48AObXcw==
|
||||
wait-on@5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/wait-on/-/wait-on-5.3.0.tgz#584e17d4b3fe7b46ac2b9f8e5e102c005c2776c7"
|
||||
integrity sha512-DwrHrnTK+/0QFaB9a8Ol5Lna3k7WvUR4jzSKmz0YaPBpuN2sACyiPVKVfj6ejnjcajAcvn3wlbTyMIn9AZouOg==
|
||||
dependencies:
|
||||
axios "^0.21.1"
|
||||
joi "^17.3.0"
|
||||
lodash "^4.17.20"
|
||||
lodash "^4.17.21"
|
||||
minimist "^1.2.5"
|
||||
rxjs "^6.6.3"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user