diff --git a/.changeset/cost-insights-five-baboons-attack.md b/.changeset/cost-insights-five-baboons-attack.md new file mode 100644 index 0000000000..f2c070acb4 --- /dev/null +++ b/.changeset/cost-insights-five-baboons-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +make change ratio optional diff --git a/.changeset/spotty-pigs-bathe.md b/.changeset/spotty-pigs-bathe.md new file mode 100644 index 0000000000..0585f335dc --- /dev/null +++ b/.changeset/spotty-pigs-bathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Update README for composability diff --git a/.changeset/thick-cobras-switch.md b/.changeset/thick-cobras-switch.md new file mode 100644 index 0000000000..002c724421 --- /dev/null +++ b/.changeset/thick-cobras-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-proxy-backend': patch +--- + +Prefix proxy routes with `/` if not present in configuration diff --git a/.changeset/tough-walls-wash.md b/.changeset/tough-walls-wash.md new file mode 100644 index 0000000000..a45adcfbf6 --- /dev/null +++ b/.changeset/tough-walls-wash.md @@ -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`. diff --git a/.changeset/tricky-yaks-melt.md b/.changeset/tricky-yaks-melt.md new file mode 100644 index 0000000000..3679e6d1da --- /dev/null +++ b/.changeset/tricky-yaks-melt.md @@ -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. diff --git a/.changeset/twenty-peas-deny.md b/.changeset/twenty-peas-deny.md new file mode 100644 index 0000000000..d149a9337c --- /dev/null +++ b/.changeset/twenty-peas-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Allow `filter` parameter to be specified multiple times diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index a12f638a74..694226b8cc 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -41,8 +41,10 @@ Kaewkasi Knex Leasot Lerna +LocalStack Luxon Minikube +Minio Mkdocs Monorepo Namespaces diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index c7876f3874..aa38dd42b1 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -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: diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 50c56af7a6..3e20e37cad 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -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 diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index a17a6cdc10..4b489ff2e4 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -67,7 +67,7 @@ export class CatalogClient implements CatalogApi { // @public (undocumented) export type CatalogEntitiesRequest = { - filter?: Record | undefined; + filter?: Record[] | Record | undefined; fields?: string[] | undefined; }; diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 19da45e4ec..359e3a2c60 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -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( diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 3de25e9808..a246b3abab 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -56,18 +56,27 @@ export class CatalogClient implements CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise> { - 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) { diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts index 0d25bf7483..ef907eafa9 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types.ts @@ -17,7 +17,10 @@ import { Entity, EntityName, Location } from '@backstage/catalog-model'; export type CatalogEntitiesRequest = { - filter?: Record | undefined; + filter?: + | Record[] + | Record + | undefined; fields?: string[] | undefined; }; diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 64550b35d1..941877aec5 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -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" diff --git a/packages/cli/package.json b/packages/cli/package.json index cd61193efe..1110efefe6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0a0a76c2d6..55d86314af 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -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", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 9c6684fdca..d628c860fd 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -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); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index f8c06845c9..7133ae81e3 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -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`} diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index 01a0874636..196f8bec44 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -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 Negligible; } - return {cost}; + if (currency.kind === CurrencyType.USD) { + // Do not display percentage if ratio cannot be calculated + if (isNaN(ratio)) { + return ( + + ~{currency.prefix} + {formatCurrency(converted)} + + ); + } + + return ( + + {formatPercent(ratio)} or ~{currency.prefix} + {formatCurrency(converted)} + + ); + } + + if (amount < 1) { + return ( + + less than {indefiniteArticleOf(['a', 'an'], currency.unit)} + + ); + } + + // Do not display percentage if ratio cannot be calculated + if (isNaN(ratio)) { + return ( + + ~{formatCurrency(converted, currency.unit)} + + ); + } + + return ( + + {formatPercent(ratio)} or ~{formatCurrency(converted, currency.unit)} + + ); }; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx index 8be5e238cc..6dc6f2cc93 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx @@ -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( - , + , ); 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( - , + , ); expect(queryByLabelText('savings')).not.toBeInTheDocument(); expect(queryByLabelText('excess')).not.toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx index e220fdb8e7..a1c200c6b3 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx @@ -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; }; 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 ( - - {formatter ? formatter(amount) : amount} - {growth === GrowthType.Savings && ( - - )} - {growth === GrowthType.Excess && } - - ); - } - - // Display cost as a factor of percent change return ( - {formatter ? formatter(ratio) : ratio} - {ratio < 0 && } - {ratio > 0 && } + {formatter ? formatter(change) : change.ratio} + {growth === GrowthType.Excess && } + {growth === GrowthType.Savings && } ); }; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx new file mode 100644 index 0000000000..112081fed4 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx @@ -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( + + + + {children} + + + , + ), + ); +} + +describe('', () => { + it('displays the legend without exploding', async () => { + const { findByText } = renderInTestApp( + , + ); + + 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( + , + ); + + 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'} +`('', ({ 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( + , + ); + + 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(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx index ebba5173a9..ad8a065a6e 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx @@ -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) => { const theme = useTheme(); - - const lastCompleteBillingDate = useLastCompleteBillingDate(); const { duration } = useFilters(mapFiltersToProps); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const comparedChange = metricData ? getComparedChange( @@ -57,23 +56,25 @@ export const CostOverviewLegend = ({ return ( - - - {formatPercent(dailyCostData.change!.ratio)} - - - {metric && metricData && comparedChange && ( + {dailyCostData.change && ( + + + {formatChange(dailyCostData.change)} + + + )} + {metricData && metric && comparedChange && ( <> - {formatPercent(metricData.change.ratio)} + {formatChange(metricData.change)} diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx index 45576bda65..f9bb44058b 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx @@ -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 @@ -32,7 +32,7 @@ type RowData = { label: string; previous: number; current: number; - ratio: number; + change: ChangeStatistic; }; function createRenderer(col: keyof RowData, classes: Record) { @@ -41,7 +41,7 @@ function createRenderer(col: keyof RowData, classes: Record) { 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) { {costFormatter.format(row[col])} ); - case 'ratio': + case 'change': return ( formatPercent(Math.abs(amount))} + change={row.change} + formatter={formatChange} /> ); default: @@ -75,10 +75,15 @@ function createSorter(field?: keyof Omit) { 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: Change, 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()); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx index ace2766277..07fea77d9d 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -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={ - + !!activeEntity.change.ratio && ( + + ) } actions={ @@ -173,11 +174,13 @@ export const ProductInsightsChart = ({ + !!activeEntity.change.ratio && ( + + ) } content={ id @@ -197,7 +200,9 @@ export const ProductInsightsChart = ({ return ( - + diff --git a/plugins/cost-insights/src/testUtils/mockData.ts b/plugins/cost-insights/src/testUtils/mockData.ts index 5149da278d..27bb07d0e1 100644 --- a/plugins/cost-insights/src/testUtils/mockData.ts +++ b/plugins/cost-insights/src/testUtils/mockData.ts @@ -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: {}, diff --git a/plugins/cost-insights/src/testUtils/testUtils.ts b/plugins/cost-insights/src/testUtils/testUtils.ts index 37913f635e..5077e05b75 100644 --- a/plugins/cost-insights/src/testUtils/testUtils.ts +++ b/plugins/cost-insights/src/testUtils/testUtils.ts @@ -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, }; } diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index a47640411a..70cd9fb9a2 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -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; } diff --git a/plugins/cost-insights/src/utils/assert.ts b/plugins/cost-insights/src/utils/assert.ts index 05ce65197b..5f9f0d01e1 100644 --- a/plugins/cost-insights/src/utils/assert.ts +++ b/plugins/cost-insights/src/utils/assert.ts @@ -20,7 +20,7 @@ export function notEmpty( return !isNull(value) && !isUndefined(value); } -export function isUndefined(value: any): boolean { +export function isUndefined(value: any): value is undefined { return value === undefined; } diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 7208e9bc4f..191ae589d6 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -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); }); }, ); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index 950fbfeb82..479818bdae 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -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( + [savings, excess]: [T, T], + change: ChangeStatistic, +): T { + const isSavings = (change.ratio ?? change.amount) <= 0; + return isSavings ? savings : excess; +} diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 8f2b94f997..75306d7392 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -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) { diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 6c47043602..571de96ac4 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -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) => 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) => 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/); }); }); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 5c306de966..71ed82c23b 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -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}`]: '/', }; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 3c2c308c24..32229ccbc6 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index 21f90ec432..7f97f88ed4 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -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 - - {/* other routes ... */} - } - /> - {/* other routes ... */} -; +const routes = ( + + {/* ... */} + } + /> ``` If you'd like to configure it more, see the `TechRadarPageProps` and `TechRadarComponentProps` types for options: diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 83b79f2f6c..6a76172638 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -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; }; } | { diff --git a/yarn.lock b/yarn.lock index c42a5fcf5f..b03b6d2a32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"