From 8b9c8196f12aa666622c6b3ded2ba247acc49cc8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 28 Oct 2020 14:37:21 +0100 Subject: [PATCH 01/61] catalog,register-component: always register locations using a 'url' type --- .changeset/fresh-maps-complain.md | 6 +++ plugins/catalog/src/api/CatalogClient.ts | 12 +++++- plugins/catalog/src/api/types.ts | 7 +++- .../components/CatalogPage/CatalogPage.tsx | 2 +- .../src/filter/useEntityFilterGroup.test.tsx | 2 +- .../RegisterComponentForm.tsx | 39 +++---------------- .../RegisterComponentPage.test.tsx | 2 +- .../RegisterComponentPage.tsx | 15 +------ .../components/TemplatePage/TemplatePage.tsx | 9 +++-- 9 files changed, 39 insertions(+), 55 deletions(-) create mode 100644 .changeset/fresh-maps-complain.md diff --git a/.changeset/fresh-maps-complain.md b/.changeset/fresh-maps-complain.md new file mode 100644 index 0000000000..ef2021df37 --- /dev/null +++ b/.changeset/fresh-maps-complain.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/plugin-register-component': patch +--- + +Locations registered through the catalog client now default to the 'url' type. The type selection dropdown in the register-component form has been removed. diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 642183f916..40622da60a 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -19,7 +19,12 @@ import { Location, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { CatalogApi, EntityCompoundName } from './types'; +import { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + EntityCompoundName, +} from './types'; import { DiscoveryApi } from '@backstage/core'; export class CatalogClient implements CatalogApi { @@ -87,7 +92,10 @@ export class CatalogClient implements CatalogApi { return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); } - async addLocation(type: string, target: string) { + async addLocation({ + type = 'url', + target, + }: AddLocationRequest): Promise { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, { diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 36c86f2e96..324a8d43fd 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -35,11 +35,16 @@ export interface CatalogApi { compoundName: EntityCompoundName, ): Promise; getEntities(filter?: Record): Promise; - addLocation(type: string, target: string): Promise; + addLocation(location: AddLocationRequest): Promise; getLocationByEntity(entity: Entity): Promise; removeEntityByUid(uid: string): Promise; } +export type AddLocationRequest = { + type?: string; + target: string; +}; + export type AddLocationResponse = { location: Location; entities: Entity[]; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 00be785754..1a9a723f16 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -76,7 +76,7 @@ const CatalogPageContents = () => { const root = configApi.getConfig('catalog.exampleEntityLocations'); for (const type of root.keys()) { for (const target of root.getStringArray(type)) { - promises.push(catalogApi.addLocation(type, target)); + promises.push(catalogApi.addLocation({ target })); } } await Promise.all(promises); diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx index acf4790ebf..3d11ffe7a9 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -30,7 +30,7 @@ describe('useEntityFilterGroup', () => { beforeEach(() => { catalogApi = { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - addLocation: jest.fn((_a, _b) => new Promise(() => {})), + addLocation: jest.fn(_a => new Promise(() => {})), getEntities: jest.fn(), getLocationByEntity: jest.fn(), getLocationById: jest.fn(), diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index bc433e1c36..56dd50ea67 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -19,15 +19,12 @@ import { Button, FormControl, FormHelperText, - InputLabel, LinearProgress, - MenuItem, - Select, TextField, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { ComponentIdValidators } from '../../util/validate'; const useStyles = makeStyles(theme => ({ @@ -50,11 +47,11 @@ export type Props = { }; export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { - const { control, register, handleSubmit, errors, formState } = useForm({ + const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); const classes = useStyles(); - const hasErrors = !!errors.componentLocation; + const hasErrors = !!errors.entityLocation; const dirty = formState?.isDirty; return submitting ? ( @@ -71,10 +68,9 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { id="registerComponentInput" variant="outlined" label="Entity file URL" - data-testid="componentLocationInput" error={hasErrors} placeholder="https://example.com/user/some-service/blob/master/catalog-info.yaml" - name="componentLocation" + name="entityLocation" required margin="normal" helperText="Enter the full path to the catalog-info.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component." @@ -84,36 +80,13 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { })} /> - {errors.componentLocation && ( + {errors.entityLocation && ( - {errors.componentLocation.message} + {errors.entityLocation.message} )} - - Host type - ( - - )} - /> - +
+ + { featureFlags.isActive('enable-example-feature') && } +
); }; ``` From 1f010f7828f47d37a523bb06ba8e2c16f5ef7d3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 28 Oct 2020 22:14:23 +0100 Subject: [PATCH 18/61] core-api: move LocalStorageFeatureFlags to api implementations --- .../LocalStorageFeatureFlags.test.tsx} | 4 ++-- .../LocalStorageFeatureFlags.tsx} | 2 +- .../implementations/FeatureFlagsApi/index.ts | 17 +++++++++++++++++ .../core-api/src/apis/implementations/index.ts | 3 ++- packages/core-api/src/app/App.tsx | 2 +- 5 files changed, 23 insertions(+), 5 deletions(-) rename packages/core-api/src/{app/FeatureFlags.test.tsx => apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx} (98%) rename packages/core-api/src/{app/FeatureFlags.tsx => apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx} (99%) create mode 100644 packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts diff --git a/packages/core-api/src/app/FeatureFlags.test.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx similarity index 98% rename from packages/core-api/src/app/FeatureFlags.test.tsx rename to packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx index 02641d6d18..2902e399c9 100644 --- a/packages/core-api/src/app/FeatureFlags.test.tsx +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { LocalStorageFeatureFlags } from './FeatureFlags'; -import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions'; +import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; +import { FeatureFlagState, FeatureFlagsApi } from '../../definitions'; describe('FeatureFlags', () => { beforeEach(() => { diff --git a/packages/core-api/src/app/FeatureFlags.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx similarity index 99% rename from packages/core-api/src/app/FeatureFlags.tsx rename to packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx index 73215481b7..00b3eff906 100644 --- a/packages/core-api/src/app/FeatureFlags.tsx +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx @@ -19,7 +19,7 @@ import { FeatureFlagsApi, FeatureFlag, FeatureFlagsSaveOptions, -} from '../apis/definitions'; +} from '../../definitions'; export function validateFlagName(name: string): void { if (name.length < 3) { diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..33990584f3 --- /dev/null +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index 30aeb81d44..d0df2760ab 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -23,7 +23,8 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; export * from './ConfigApi'; -export * from './ErrorApi'; export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 91b55a7d2e..9c38c20b44 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -50,11 +50,11 @@ import { useApi, AnyApiFactory, ApiHolder, + LocalStorageFeatureFlags, } from '../apis'; import { useAsync } from 'react-use'; import { AppIdentity } from './AppIdentity'; import { ApiResolver, ApiFactoryRegistry } from '../apis/system'; -import { LocalStorageFeatureFlags } from './FeatureFlags'; type FullAppOptions = { apis: Iterable; From cbab5bbf8aefc2df4a737204d7f2d1a3544eadea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 28 Oct 2020 22:46:21 +0100 Subject: [PATCH 19/61] changesets: added changeset for FeatureFlagsApi refactoring --- .changeset/swift-emus-mate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-emus-mate.md diff --git a/.changeset/swift-emus-mate.md b/.changeset/swift-emus-mate.md new file mode 100644 index 0000000000..1903742115 --- /dev/null +++ b/.changeset/swift-emus-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': minor +--- + +Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save(). From 0a5d8606824accb38f23adbd870725ee5584359b Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 29 Oct 2020 04:40:05 -0400 Subject: [PATCH 20/61] Fix typo in template comment (#3152) --- packages/create-app/templates/default-app/app-config.yaml.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index d643c72e0c..3c7bc40c2b 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -38,7 +38,7 @@ backend: #ca: # if you have a CA file and want to verify it you can uncomment this section # $file: /ca/server.crt {{/if}} - # workingDirectory: /tmp # Use this to configure a working direcotry for the scaffolder, defaults to the OS temp-dir + # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir integrations: github: From e3ceaa97bebc13f26295df6adf8ffbafc6acb9db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 09:40:30 +0100 Subject: [PATCH 21/61] chore(deps): bump commander from 6.1.0 to 6.2.0 (#3154) Bumps [commander](https://github.com/tj/commander.js) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/tj/commander.js/releases) - [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/tj/commander.js/compare/v6.1.0...v6.2.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cdf9fd9c31..3e2e8e4e9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8781,9 +8781,9 @@ commander@^5.0.0, commander@^5.1.0: integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== commander@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc" - integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA== + version "6.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== common-tags@1.8.0, common-tags@^1.8.0: version "1.8.0" From a4c727f1a20aa3676e2b24b9737f569f3f60fae8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 09:41:57 +0100 Subject: [PATCH 22/61] chore(deps): bump eslint from 7.4.0 to 7.12.1 (#3153) Bumps [eslint](https://github.com/eslint/eslint) from 7.4.0 to 7.12.1. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/master/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v7.4.0...v7.12.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 137 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 77 insertions(+), 60 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3e2e8e4e9d..b2b87be5e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1673,6 +1673,22 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@eslint/eslintrc@^0.2.1": + version "0.2.1" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz#f72069c330461a06684d119384435e12a5d76e3c" + integrity sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^12.1.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + lodash "^4.17.19" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + "@evocateur/libnpmaccess@^3.1.2": version "3.1.2" resolved "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz#ecf7f6ce6b004e9f942b098d92200be4a4b1c845" @@ -6337,11 +6353,16 @@ acorn@^6.0.1, acorn@^6.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== -acorn@^7.1.1, acorn@^7.2.0: +acorn@^7.1.1: version "7.3.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== +acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -6436,17 +6457,7 @@ ajv@^5.0.0: fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.5.5, ajv@^6.7.0: - version "6.12.4" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^6.12.5: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.5.5, ajv@^6.7.0: version "6.12.5" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da" integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag== @@ -6475,7 +6486,7 @@ ansi-align@^3.0.0: dependencies: string-width "^3.0.0" -ansi-colors@^3.0.0, ansi-colors@^3.2.1: +ansi-colors@^3.0.0: version "3.2.4" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== @@ -9826,7 +9837,14 @@ debug@3.1.0, debug@=3.1.0: dependencies: ms "2.0.0" -debug@4, debug@4.1.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" + integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== + dependencies: + ms "2.1.2" + +debug@4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -9840,13 +9858,6 @@ debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" -debug@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" - integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== - dependencies: - ms "2.1.2" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -10601,20 +10612,13 @@ enhanced-resolve@^4.0.0, enhanced-resolve@^4.3.0: memory-fs "^0.5.0" tapable "^1.0.0" -enquirer@^2.3.0: +enquirer@^2.3.0, enquirer@^2.3.5: version "2.3.6" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== dependencies: ansi-colors "^4.1.1" -enquirer@^2.3.5: - version "2.3.5" - resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.5.tgz#3ab2b838df0a9d8ab9e7dff235b0e8712ef92381" - integrity sha512-BNT1C08P9XD0vNg3J475yIUG+mVdp9T6towYFHUv897X0KoHBjB1shyrNmhmtHWKP17iSWgo7Gqh7BBuzLZMSA== - dependencies: - ansi-colors "^3.2.1" - entities@^1.1.1, entities@^1.1.2, entities@~1.1.1: version "1.1.2" resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -10965,25 +10969,25 @@ eslint-scope@^4.0.3: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^5.0.0, eslint-scope@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz#d0f971dfe59c69e0cada684b23d49dbf82600ce5" - integrity sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w== +eslint-scope@^5.0.0, eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== dependencies: - esrecurse "^4.1.0" + esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" - integrity sha512-0HCPuJv+7Wv1bACm8y5/ECVfYdfsAm9xmVb7saeFlxjPYALefjhbYoCkBjPdPzGH8wWyTpAez82Fh3VKYEZ8OA== +eslint-utils@^2.0.0, eslint-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: eslint-visitor-keys "^1.1.0" -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" - integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== eslint-visitor-keys@^2.0.0: version "2.0.0" @@ -10991,21 +10995,22 @@ eslint-visitor-keys@^2.0.0: integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@^7.1.0: - version "7.4.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" - integrity sha512-gU+lxhlPHu45H3JkEGgYhWhkR9wLHHEXC9FbWFnTlEkbKyZKWgWRLgf61E8zWmBuI6g5xKBph9ltg3NtZMVF8g== + version "7.12.1" + resolved "https://registry.npmjs.org/eslint/-/eslint-7.12.1.tgz#bd9a81fa67a6cfd51656cdb88812ce49ccec5801" + integrity sha512-HlMTEdr/LicJfN08LB3nM1rRYliDXOmfoO4vj39xN6BLpFzF00hbwBoqHk8UcJ2M/3nlARZWy/mslvGEuZFvsg== dependencies: "@babel/code-frame" "^7.0.0" + "@eslint/eslintrc" "^0.2.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" enquirer "^2.3.5" - eslint-scope "^5.1.0" - eslint-utils "^2.0.0" - eslint-visitor-keys "^1.2.0" - espree "^7.1.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.0" esquery "^1.2.0" esutils "^2.0.2" file-entry-cache "^5.0.1" @@ -11019,7 +11024,7 @@ eslint@^7.1.0: js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" - lodash "^4.17.14" + lodash "^4.17.19" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" @@ -11037,14 +11042,14 @@ esm@^3.2.25: resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== -espree@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/espree/-/espree-7.1.0.tgz#a9c7f18a752056735bf1ba14cb1b70adc3a5ce1c" - integrity sha512-dcorZSyfmm4WTuTnE5Y7MEN1DyoPYy1ZR783QW1FJoenn7RailyWFsq/UL6ZAAA7uXurN9FIpYyUs3OfiIW+Qw== +espree@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" + integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== dependencies: - acorn "^7.2.0" + acorn "^7.4.0" acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.2.0" + eslint-visitor-keys "^1.3.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" @@ -11065,6 +11070,13 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -11075,6 +11087,11 @@ estraverse@^5.1.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" @@ -21874,10 +21891,10 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" - integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== strip-json-comments@~2.0.1: version "2.0.1" From b1b3f094f10470fff5feee99a89a365e729a2284 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 29 Oct 2020 15:51:54 +0100 Subject: [PATCH 23/61] docs: tweak feature-flags docs --- docs/reference/createPlugin-feature-flags.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index 7108941edd..bcea80e26b 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -24,14 +24,14 @@ export default createPlugin({ ## Using with useApi To inspect the state of a feature flag inside your plugin, you can use the -`FeatureFlagsApi`, accessed via the `FeatureFlagsApiRef`. For example: +`FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example: ```tsx import React, { FC } from 'react'; import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; -const ExamplePage: FC<{}> = () => { +const ExamplePage = () => { const featureFlags = useApi(featureFlagsApiRef); return ( From fa56f46158f78c665a7e0f69f7b2818632f560af Mon Sep 17 00:00:00 2001 From: Vividh Chandna Date: Thu, 29 Oct 2020 21:28:18 +0530 Subject: [PATCH 24/61] fix: Update tags validation message & documentation (#3145) * Update dns label validation message to match regex pattern * Update documentation for tags validation & examples * Add changeset --- .changeset/ninety-ads-dance.md | 5 +++++ docs/features/software-catalog/descriptor-format.md | 6 +++--- .../src/entity/policies/FieldFormatEntityPolicy.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/ninety-ads-dance.md diff --git a/.changeset/ninety-ads-dance.md b/.changeset/ninety-ads-dance.md new file mode 100644 index 0000000000..a03298fd7f --- /dev/null +++ b/.changeset/ninety-ads-dance.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Fix documentation and validation message for tags diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 62ac9da8d8..a330ef196a 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -256,8 +256,8 @@ component, like `java` or `go`. This field is optional, and currently has no special semantics. -Each tag must be sequences of `[a-zA-Z0-9]` separated by `-`, at most 63 -characters in total. +Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters +in total. ## Kind: Component @@ -418,7 +418,7 @@ of the `metadata.name` field. ### `metadata.tags` [optional] A list of strings that can be associated with the template, e.g. -`['Recommended', 'React']`. +`['recommended', 'react']`. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 7193c1a89b..acd4469fcb 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -74,7 +74,7 @@ export class FieldFormatEntityPolicy implements EntityPolicy { case 'isValidNamespace': case 'isValidDnsLabel': expectation = - 'a string that is sequences of [a-zA-Z0-9] separated by [-], at most 63 characters in total'; + 'a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total'; break; case 'isValidAnnotationValue': expectation = 'a string'; From 6d4f8c6366a5dd5a7b4c1edf9b4714fde20a5e73 Mon Sep 17 00:00:00 2001 From: Abhishek Jakhar Date: Thu, 29 Oct 2020 16:32:55 +0530 Subject: [PATCH 25/61] chore: add test case for ErrorBoundary component --- .../ErrorBoundary/ErrorBoundary.test.tsx | 71 +++++++++++++++++++ .../layout/ErrorBoundary/ErrorBoundary.tsx | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/layout/ErrorBoundary/ErrorBoundary.test.tsx diff --git a/packages/core/src/layout/ErrorBoundary/ErrorBoundary.test.tsx b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 0000000000..4a3126c73c --- /dev/null +++ b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,71 @@ +/* eslint-disable no-console */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ErrorBoundary } from './ErrorBoundary'; +import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; + +type BombProps = { + shouldThrow?: boolean; + children?: React.ReactNode; +}; + +const Bomb = ({ shouldThrow }: BombProps) => { + if (shouldThrow) { + throw new Error('Bomb'); + } else { + return

Working Component

; + } +}; + +describe('', () => { + it('should render error boundary with and without error', async () => { + const { error } = await withLogCollector(['error'], async () => { + const { + rerender, + queryByRole, + getByRole, + getByText, + } = await renderInTestApp( + + + , + ); + + expect(queryByRole('alert')).not.toBeInTheDocument(); + expect(getByText(/working component/i)).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(getByRole('alert')).toBeInTheDocument(); + expect(getByText(/something went wrong here/i)).toBeInTheDocument(); + }); + + expect(error).toEqual([ + expect.stringMatching(/^Error: Uncaught \[Error: Bomb\]/), + expect.stringMatching( + /^The above error occurred in the component:/, + ), + expect.stringMatching(/^ErrorBoundary/), + ]); + expect(error.length).toEqual(3); + }); +}); diff --git a/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx index cc48e26b1d..17900ad8e3 100644 --- a/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx @@ -65,7 +65,7 @@ type EProps = { const Error = ({ slackChannel }: EProps) => { return ( -
+
Something went wrong here.{' '} {slackChannel && <>Please contact {slackChannel} for help.}
From 59e42b4ca77d7458ebf8648b5fdcb17be17424f4 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 14:28:42 -0400 Subject: [PATCH 26/61] preserve enums in types --- .../cost-insights/src/api/CostInsightsApi.ts | 2 +- .../src/api/ExampleCostInsightsClient.ts | 8 ++-- .../AlertActionCard.test.tsx | 3 +- .../components/CostGrowth/CostGrowth.test.tsx | 13 +++-- .../src/components/CostGrowth/CostGrowth.tsx | 5 +- .../components/CostOverviewCard/selector.tsx | 3 +- .../CurrencySelect/CurrencySelect.tsx | 3 +- .../PeriodSelect/PeriodSelect.test.tsx | 3 +- .../components/PeriodSelect/PeriodSelect.tsx | 2 +- .../ProductInsightsCard.test.tsx | 3 +- .../ProductInsightsCard.tsx | 3 +- .../ProductInsightsCard/selector.ts | 3 +- .../ProjectGrowthAlertCard.tsx | 8 ++-- .../ProjectGrowthInstructionsPage.tsx | 4 +- .../ResourceGrowthBarChartLegend.test.tsx | 2 +- .../ResourceGrowthBarChartLegend.tsx | 3 +- plugins/cost-insights/src/index.ts | 3 +- .../src/types/{Alert.tsx => Alert.ts} | 45 ------------------ plugins/cost-insights/src/types/Currency.ts | 7 +++ .../src/{utils/icon.ts => types/Duration.ts} | 18 ++++--- plugins/cost-insights/src/types/Filters.ts | 2 +- plugins/cost-insights/src/types/Icon.ts | 9 ++++ plugins/cost-insights/src/types/index.ts | 1 + plugins/cost-insights/src/utils/alerts.tsx | 47 +++++++++++++++++++ plugins/cost-insights/src/utils/currency.ts | 10 +--- .../cost-insights/src/utils/duration.test.ts | 3 +- plugins/cost-insights/src/utils/duration.ts | 16 +------ plugins/cost-insights/src/utils/filters.ts | 3 +- .../src/utils/formatters.test.ts | 2 +- plugins/cost-insights/src/utils/formatters.ts | 2 +- plugins/cost-insights/src/utils/history.ts | 3 +- plugins/cost-insights/src/utils/loading.ts | 16 +++++++ plugins/cost-insights/src/utils/mockData.ts | 2 +- .../cost-insights/src/utils/navigation.tsx | 2 +- .../cost-insights/src/utils/public/index.ts | 20 -------- plugins/cost-insights/src/utils/tests.tsx | 3 +- 36 files changed, 136 insertions(+), 146 deletions(-) rename plugins/cost-insights/src/types/{Alert.tsx => Alert.ts} (59%) rename plugins/cost-insights/src/{utils/icon.ts => types/Duration.ts} (59%) create mode 100644 plugins/cost-insights/src/utils/alerts.tsx delete mode 100644 plugins/cost-insights/src/utils/public/index.ts diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 8e165ba72a..be5cd54fdb 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -18,13 +18,13 @@ import { createApiRef } from '@backstage/core'; import { Alert, Cost, + Duration, Group, Project, ProductCost, Maybe, MetricData, } from '../types'; -import { Duration } from '../utils/duration'; export type CostInsightsApi = { /** diff --git a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts b/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts index 159e2a2e7d..70695beb25 100644 --- a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts +++ b/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts @@ -23,20 +23,22 @@ import { ChangeStatistic, Cost, DateAggregation, + Duration, Group, Maybe, MetricData, ProductCost, Project, - ProjectGrowthAlert, ProjectGrowthData, Trendline, - UnlabeledDataflowAlert, UnlabeledDataflowData, } from '../types'; +import { + ProjectGrowthAlert, + UnlabeledDataflowAlert +} from '../utils/alerts'; import { DEFAULT_DATE_FORMAT, - Duration, exclusiveEndDateOf, inclusiveStartDateOf, } from '../utils/duration'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx index 3e2ca4f8c8..d896bf0d47 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx @@ -17,8 +17,9 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { AlertActionCard } from './AlertActionCard'; -import { ProjectGrowthAlert, ProjectGrowthData } from '../../types'; import { MockScrollProvider } from '../../utils/tests'; +import { ProjectGrowthAlert } from '../../utils/alerts'; +import { ProjectGrowthData } from '../../types'; const data: ProjectGrowthData = { aggregation: [500000.8, 970502.8], diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 7c319354aa..046394ca7a 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -17,11 +17,10 @@ import React, { PropsWithChildren } from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { CostGrowth } from './CostGrowth'; -import { Currency } from '../../types'; -import { Duration } from '../../utils/duration'; +import { Currency, CurrencyType, Duration } from '../../types'; import { findAlways } from '../../utils/assert'; import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; -import { defaultCurrencies, CurrencyType } from '../../utils/currency'; +import { defaultCurrencies } from '../../utils/currency'; const engineers = findAlways(defaultCurrencies, c => c.kind === null); const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD); @@ -38,10 +37,10 @@ const MockContext = ({ currency: Currency; engineerCost: number; }>) => ( - - {children} - -); + + {children} + + ); describe.each` engineerCost | ratio | amount | expected diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index bdb4e00394..3f5e0f55eb 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -16,9 +16,8 @@ import React from 'react'; import classnames from 'classnames'; -import { ChangeStatistic } from '../../types'; -import { rateOf, CurrencyType } from '../../utils/currency'; -import { Duration } from '../../utils/duration'; +import { ChangeStatistic, CurrencyType, Duration } from '../../types'; +import { rateOf } from '../../utils/currency'; import { growthOf, GrowthType, EngineerThreshold } from '../../utils/change'; import { useCostGrowthStyles as useStyles } from '../../utils/styles'; import { formatPercent, formatCurrency } from '../../utils/formatters'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx index 3440500215..ef65f38dbb 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx @@ -13,8 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Maybe, PageFilters } from '../../types'; -import { Duration } from '../../utils/duration'; +import { Duration, Maybe, PageFilters } from '../../types'; import { MapFiltersToProps } from '../../hooks/useFilters'; type CostOverviewFilterProps = PageFilters & { diff --git a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx index 2780c728f3..1b5920ffcb 100644 --- a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx +++ b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx @@ -16,8 +16,7 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; -import { Currency } from '../../types'; -import { CurrencyType } from '../../utils/currency'; +import { Currency, CurrencyType } from '../../types'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index 67f40550f2..a3519ff61b 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -20,9 +20,8 @@ import { renderInTestApp } from '@backstage/test-utils'; import UserEvent from '@testing-library/user-event'; import { PeriodSelect, getDefaultOptions } from './PeriodSelect'; import { getDefaultPageFilters } from '../../utils/filters'; -import { Duration } from '../../utils/duration'; import { MockBillingDateProvider } from '../../utils/tests'; -import { Group } from '../../types'; +import { Group, Duration } from '../../types'; const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); const lastCompleteBillingDate = '2020-05-01'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index f1a80a55f5..4908641d6f 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -16,11 +16,11 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { Duration } from '../../types'; import { formatLastTwoLookaheadQuarters, formatLastTwoMonths, } from '../../utils/formatters'; -import { Duration } from '../../utils/duration'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; import { useLastCompleteBillingDate } from '../../hooks'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index f27d7349a3..9d1610e298 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -35,8 +35,7 @@ import { MockScrollProvider, MockLoadingProvider, } from '../../utils/tests'; -import { Duration } from '../../utils/duration'; -import { Product, ProductCost, ProductPeriod } from '../../types'; +import { Duration, Product, ProductCost, ProductPeriod } from '../../types'; const costInsightsApi = ( productCost: ProductCost, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index fec73f0c06..840c99522a 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -28,10 +28,9 @@ import { useLoading, useScroll, } from '../../hooks'; -import { Duration } from '../../utils/duration'; import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; import { mapFiltersToProps, mapLoadingToProps } from './selector'; -import { Maybe, Product, ProductCost } from '../../types'; +import { Duration, Maybe, Product, ProductCost } from '../../types'; import { pluralOf } from '../../utils/grammar'; import { formatPeriod } from '../../utils/formatters'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts index 6007070707..efc8448034 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts +++ b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts @@ -16,8 +16,7 @@ import { MapFiltersToProps } from '../../hooks/useFilters'; import { MapLoadingToProps } from '../../hooks/useLoading'; -import { PageFilters, ProductPeriod } from '../../types'; -import { Duration } from '../../utils/duration'; +import { Duration, PageFilters, ProductPeriod } from '../../types'; import { findAlways } from '../../utils/assert'; type ProductInsightsCardFilterProps = PageFilters & { diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index c32afcd257..18f7fa151d 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -20,8 +20,7 @@ import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart'; import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend'; -import { ProjectGrowthData } from '../../types'; -import { Duration } from '../../utils/duration'; +import { Duration, ProjectGrowthData } from '../../types'; import { pluralOf } from '../../utils/grammar'; type ProjectGrowthAlertProps = { @@ -32,9 +31,8 @@ export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { const [costStart, costEnd] = alert.aggregation; const subheader = ` - ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ - alert.products.length > 1 ? ', sorted by cost' : '' - }`; + ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${alert.products.length > 1 ? ', sorted by cost' : '' + }`; const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format( '[Q]Q YYYY', ); diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index 074d3e2b92..f2083388ae 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -18,14 +18,14 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; -import { Duration } from '../../utils/duration'; import { Alert, + Duration, Entity, Product, - ProjectGrowthAlert, ProjectGrowthData, } from '../../types'; +import { ProjectGrowthAlert } from '../../utils/alerts'; import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend'; import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx index bf5c1021cc..47285a2363 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -17,10 +17,10 @@ import React, { PropsWithChildren } from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend'; -import { Duration } from '../../utils/duration'; import { defaultCurrencies } from '../../utils/currency'; import { findAlways } from '../../utils/assert'; import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; +import { Duration } from '../../types'; const engineers = findAlways(defaultCurrencies, c => c.kind === null); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx index 0c281e6266..2e8e0f07a1 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -19,8 +19,7 @@ import { Box, useTheme } from '@material-ui/core'; import { LegendItem } from '../LegendItem'; import { CostGrowth } from '../CostGrowth'; import { currencyFormatter } from '../../utils/formatters'; -import { Duration } from '../../utils/duration'; -import { ChangeStatistic, CostInsightsTheme } from '../../types'; +import { ChangeStatistic, Duration, CostInsightsTheme } from '../../types'; export type ResourceGrowthBarChartLegendProps = { change: ChangeStatistic; diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 119fc2035c..48b2425946 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -19,4 +19,5 @@ export * from './api'; export * from './components'; export { useCurrency } from './hooks'; export * from './types'; -export * from './utils/public'; +export * from './utils/tests'; +export * from './utils/duration'; diff --git a/plugins/cost-insights/src/types/Alert.tsx b/plugins/cost-insights/src/types/Alert.ts similarity index 59% rename from plugins/cost-insights/src/types/Alert.tsx rename to plugins/cost-insights/src/types/Alert.ts index 4ed55a844c..d6046e3241 100644 --- a/plugins/cost-insights/src/types/Alert.tsx +++ b/plugins/cost-insights/src/types/Alert.ts @@ -14,11 +14,8 @@ * limitations under the License. */ -import React from 'react'; import { ChangeStatistic } from './ChangeStatistic'; import { Maybe } from './Maybe'; -import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; -import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; /** * Generic alert type with required fields for display. The `element` field will be rendered in @@ -57,11 +54,6 @@ export enum DataKey { Name = 'name', } -/** - * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning - * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi - * fetches alert data from the backend, then creates Alert classes with the data. - */ export interface ProjectGrowthData { project: string; periodStart: string; @@ -71,26 +63,6 @@ export interface ProjectGrowthData { products: Array; } -export class ProjectGrowthAlert implements Alert { - data: ProjectGrowthData; - - constructor(data: ProjectGrowthData) { - this.data = data; - } - - get title() { - return `Investigate cost growth in project ${this.data.project}`; - } - - subtitle = - 'Cost growth outpacing business growth is unsustainable long-term.'; - url = '/cost-insights/investigating-growth'; - - get element() { - return ; - } -} - export interface UnlabeledDataflowData { periodStart: string; periodEnd: string; @@ -99,23 +71,6 @@ export interface UnlabeledDataflowData { labeledCost: number; } -export class UnlabeledDataflowAlert implements Alert { - data: UnlabeledDataflowData; - - constructor(data: UnlabeledDataflowData) { - this.data = data; - } - - title = 'Add labels to workflows'; - subtitle = - 'Labels show in billing data, enabling cost insights for each workflow.'; - url = '/cost-insights/labeling-jobs'; - - get element() { - return ; - } -} - export interface UnlabeledDataflowAlertProject { id: string; unlabeledCost: number; diff --git a/plugins/cost-insights/src/types/Currency.ts b/plugins/cost-insights/src/types/Currency.ts index 16ba432be8..8a8af81b0e 100644 --- a/plugins/cost-insights/src/types/Currency.ts +++ b/plugins/cost-insights/src/types/Currency.ts @@ -21,3 +21,10 @@ export interface Currency { prefix?: string; rate?: number; } + +export enum CurrencyType { + USD = 'USD', + CarbonOffsetTons = 'CARBON_OFFSET_TONS', + Beers = 'BEERS', + IceCream = 'PINTS_OF_ICE_CREAM', +} diff --git a/plugins/cost-insights/src/utils/icon.ts b/plugins/cost-insights/src/types/Duration.ts similarity index 59% rename from plugins/cost-insights/src/utils/icon.ts rename to plugins/cost-insights/src/types/Duration.ts index de957743ba..a1eb9aa31e 100644 --- a/plugins/cost-insights/src/utils/icon.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -14,11 +14,15 @@ * limitations under the License. */ -export enum IconType { - Compute = 'compute', - Data = 'data', - Database = 'database', - Storage = 'storage', - Search = 'search', - ML = 'ml', +/** + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean + * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * September 15. + */ +export enum Duration { + P30D = 'P30D', + P90D = 'P90D', + P1M = 'P1M', + P3M = 'P3M', } diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts index da71471f50..120738fcc7 100644 --- a/plugins/cost-insights/src/types/Filters.ts +++ b/plugins/cost-insights/src/types/Filters.ts @@ -15,7 +15,7 @@ */ import { Maybe } from './Maybe'; -import { Duration } from '../utils/duration'; +import { Duration } from './Duration'; export interface PageFilters { group: Maybe; diff --git a/plugins/cost-insights/src/types/Icon.ts b/plugins/cost-insights/src/types/Icon.ts index db5520ca15..70f43a6813 100644 --- a/plugins/cost-insights/src/types/Icon.ts +++ b/plugins/cost-insights/src/types/Icon.ts @@ -18,3 +18,12 @@ export type Icon = { kind: string; component: JSX.Element; }; + +export enum IconType { + Compute = 'compute', + Data = 'data', + Database = 'database', + Storage = 'storage', + Search = 'search', + ML = 'ml', +} diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts index cced505f86..398110ec80 100644 --- a/plugins/cost-insights/src/types/index.ts +++ b/plugins/cost-insights/src/types/index.ts @@ -19,6 +19,7 @@ export * from './ChangeStatistic'; export * from './ChartData'; export * from './Cost'; export * from './DateAggregation'; +export * from './Duration'; export * from './Currency'; export * from './Entity'; export * from './Icon'; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx new file mode 100644 index 0000000000..fcb41f1e3b --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; +import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; +import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; + +/** + * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class UnlabeledDataflowAlert implements Alert { + data: UnlabeledDataflowData; + + constructor(data: UnlabeledDataflowData) { + this.data = data; + } + + title = 'Add labels to workflows'; + subtitle = + 'Labels show in billing data, enabling cost insights for each workflow.'; + url = '/cost-insights/labeling-jobs'; + + get element() { + return ; + } +} + +export class ProjectGrowthAlert implements Alert { + data: ProjectGrowthData; + + constructor(data: ProjectGrowthData) { + this.data = data; + } + + get title() { + return `Investigate cost growth in project ${this.data.project}`; + } + + subtitle = + 'Cost growth outpacing business growth is unsustainable long-term.'; + url = '/cost-insights/investigating-growth'; + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index 7f43acc66b..663a29a112 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -13,17 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Currency } from '../types'; -import { Duration } from '../utils/duration'; +import { Currency, CurrencyType, Duration } from '../types'; import { assertNever } from '../utils/assert'; -export enum CurrencyType { - USD = 'USD', - CarbonOffsetTons = 'CARBON_OFFSET_TONS', - Beers = 'BEERS', - IceCream = 'PINTS_OF_ICE_CREAM', -} - export const rateOf = (cost: number, duration: Duration) => { switch (duration) { case Duration.P1M: diff --git a/plugins/cost-insights/src/utils/duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts index 675a90b54a..a5509eda07 100644 --- a/plugins/cost-insights/src/utils/duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; +import { Duration } from '../types'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; const lastCompleteBillingDate = '2020-06-05'; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 09034ae9df..d90e78dc5f 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -15,20 +15,8 @@ */ import moment from 'moment'; -import { assertNever } from '../utils/assert'; - -/** - * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean - * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if - * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - - * September 15. - */ -export enum Duration { - P30D = 'P30D', - P90D = 'P90D', - P1M = 'P1M', - P3M = 'P3M', -} +import { Duration } from '../types'; +import { assertNever } from './assert'; export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; diff --git a/plugins/cost-insights/src/utils/filters.ts b/plugins/cost-insights/src/utils/filters.ts index 26d2bc6cf3..fbe1bf46cc 100644 --- a/plugins/cost-insights/src/utils/filters.ts +++ b/plugins/cost-insights/src/utils/filters.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { Group, PageFilters } from '../types'; -import { Duration } from './duration'; +import { Duration, Group, PageFilters } from '../types'; export function getDefaultPageFilters(groups: Group[]): PageFilters { return { diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index 7be6e1dffb..69e9086be1 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -19,7 +19,7 @@ import { lengthyCurrencyFormatter, quarterOf, } from './formatters'; -import { Duration } from '../utils/duration'; +import { Duration } from '../types'; Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 48b0b77188..fc563bf836 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -15,8 +15,8 @@ */ import moment from 'moment'; +import { Duration } from '../types'; import { - Duration, inclusiveEndDateOf, inclusiveStartDateOf, } from '../utils/duration'; diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts index 36f4e9a5b0..7d45cfdad2 100644 --- a/plugins/cost-insights/src/utils/history.ts +++ b/plugins/cost-insights/src/utils/history.ts @@ -16,8 +16,7 @@ import qs from 'qs'; import * as yup from 'yup'; -import { Group, PageFilters } from '../types'; -import { Duration } from '../utils/duration'; +import { Duration, Group, PageFilters } from '../types'; import { getDefaultPageFilters } from '../utils/filters'; import { ConfigContextProps } from '../hooks/useConfig'; diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 7a0225154f..4652603926 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { Loading } from '../types'; export enum DefaultLoadingAction { diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 91cd2f8ad2..58ba4a4251 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -17,6 +17,7 @@ import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core'; import { + Duration, Entity, Product, ProductCost, @@ -30,7 +31,6 @@ import { getDefaultState as getDefaultLoadingState, } from '../utils/loading'; import { findAlways } from '../utils/assert'; -import { Duration } from '../utils/duration'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; diff --git a/plugins/cost-insights/src/utils/navigation.tsx b/plugins/cost-insights/src/utils/navigation.tsx index 39016f6092..d2cb1d20bb 100644 --- a/plugins/cost-insights/src/utils/navigation.tsx +++ b/plugins/cost-insights/src/utils/navigation.tsx @@ -23,7 +23,7 @@ import Search from '@material-ui/icons/Search'; import CloudQueue from '@material-ui/icons/CloudQueue'; import School from '@material-ui/icons/School'; import ViewHeadline from '@material-ui/icons/ViewHeadline'; -import { IconType } from '../utils/icon'; +import { IconType } from '../types'; export enum DefaultNavigation { CostOverviewCard = 'cost-overview-card', diff --git a/plugins/cost-insights/src/utils/public/index.ts b/plugins/cost-insights/src/utils/public/index.ts deleted file mode 100644 index ffc2d5c882..0000000000 --- a/plugins/cost-insights/src/utils/public/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from '../tests'; -export { Duration } from '../duration'; -export { CurrencyType } from '../currency'; -export { IconType } from '../icon'; diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 09eab89139..852330acc3 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -32,8 +32,7 @@ import { BillingDateContextProps, } from '../hooks/useLastCompleteBillingDate'; import { ScrollContext, ScrollContextProps } from '../hooks/useScroll'; -import { Duration } from '../utils/duration'; -import { Group } from '../types'; +import { Group, Duration } from '../types'; /* Mock Providers and types are exposed publicly to allow users to test custom implementations From bbf52b659570988c6d9f0b2cdddaba9a16981750 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 14:43:02 -0400 Subject: [PATCH 27/61] fix alert link --- plugins/cost-insights/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 8c2fd11761..3b6e1e4fd5 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -106,7 +106,7 @@ costInsights: ## Alerts -The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Alert.tsx). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations. +The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Alert.ts). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations. The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations. From 948052cbb4b692bb9a6ad444cc8f19431e763a12 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 21 Oct 2020 09:34:43 +0200 Subject: [PATCH 28/61] feat: add ability to dry run adding a new location The location is now added in a transaction and afterwards rolled back. This allows users to dry run this operation to see if there entity has issues. This is probably done my automated tools. --- .changeset/angry-crabs-relate.md | 9 ++ .../features/software-catalog/installation.md | 1 + .../catalog/DatabaseEntitiesCatalog.test.ts | 46 +++++++-- .../src/catalog/DatabaseEntitiesCatalog.ts | 42 +++++--- .../src/catalog/DatabaseLocationsCatalog.ts | 18 +++- plugins/catalog-backend/src/catalog/types.ts | 14 ++- .../src/database/CommonDatabase.test.ts | 5 +- .../src/database/CommonDatabase.ts | 60 +++++++---- plugins/catalog-backend/src/database/index.ts | 1 + plugins/catalog-backend/src/database/types.ts | 33 +++++-- .../ingestion/HigherOrderOperations.test.ts | 70 ++++++++++++- .../src/ingestion/HigherOrderOperations.ts | 72 ++++++++------ .../catalog-backend/src/ingestion/types.ts | 5 +- .../src/service/CatalogBuilder.ts | 1 + .../src/service/router.test.ts | 99 +++++++++++++------ plugins/catalog-backend/src/service/router.ts | 40 ++++---- 16 files changed, 378 insertions(+), 138 deletions(-) create mode 100644 .changeset/angry-crabs-relate.md diff --git a/.changeset/angry-crabs-relate.md b/.changeset/angry-crabs-relate.md new file mode 100644 index 0000000000..66cdb29be5 --- /dev/null +++ b/.changeset/angry-crabs-relate.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Add ability to dry run adding a new location ot the catalog API. + +The location is now added in a transaction and afterwards rolled back. +This allows users to dry run this operation to see if there entity has issues. +This is probably done by automated tools in the CI/CD pipeline. diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 26990033aa..1e037461b4 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -122,6 +122,7 @@ export default async function createPlugin({ entitiesCatalog, locationsCatalog, locationReader, + db, logger, ); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 6407000b6f..7e751bab92 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -16,12 +16,13 @@ import { getVoidLogger } from '@backstage/backend-common'; import type { Entity } from '@backstage/catalog-model'; -import { Database, DatabaseManager } from '../database'; +import { Database, DatabaseManager, Transaction } from '../database'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; import { EntityUpsertRequest } from './types'; describe('DatabaseEntitiesCatalog', () => { let db: jest.Mocked; + let transaction: jest.Mocked; beforeAll(() => { db = { @@ -40,11 +41,14 @@ describe('DatabaseEntitiesCatalog', () => { locationHistory: jest.fn(), addLocationUpdateLogEvent: jest.fn(), }; + transaction = { + rollback: jest.fn(), + }; }); beforeEach(() => { jest.resetAllMocks(); - db.transaction.mockImplementation(async f => f('tx')); + db.transaction.mockImplementation(async f => f(transaction)); }); describe('batchAddOrUpdateEntities', () => { @@ -82,6 +86,36 @@ describe('DatabaseEntitiesCatalog', () => { expect(result).toEqual([{ entityId: 'u' }]); }); + it('adds using existing transaction', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + + const existingTransaction: jest.Mocked = { + rollback: jest.fn(), + }; + + db.entities.mockResolvedValue([]); + db.addEntities.mockResolvedValue([{ entity }]); + + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); + const result = await catalog.addOrUpdateEntity(entity, { + tx: existingTransaction, + }); + + expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledWith( + existingTransaction, + expect.anything(), + ); + expect(result).toBe(entity); + }); + it('updates when given uid', async () => { const entity: Entity = { apiVersion: 'a', @@ -131,10 +165,10 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entityByName).not.toHaveBeenCalled(); expect(db.entityByUid).toHaveBeenCalledTimes(1); - expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u'); + expect(db.entityByUid).toHaveBeenCalledWith(transaction, 'u'); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( - expect.anything(), + transaction, { entity: { apiVersion: 'a', @@ -206,14 +240,14 @@ describe('DatabaseEntitiesCatalog', () => { }, ]); expect(db.entityByName).toHaveBeenCalledTimes(1); - expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + expect(db.entityByName).toHaveBeenCalledWith(transaction, { kind: 'b', namespace: 'd', name: 'c', }); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( - expect.anything(), + transaction, { entity: { apiVersion: 'a', diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 947736dc06..28a972e8e3 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -27,7 +27,12 @@ import { import { chunk, groupBy } from 'lodash'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; -import type { Database, DbEntityResponse, EntityFilters } from '../database'; +import type { + Database, + DbEntityResponse, + EntityFilters, + Transaction, +} from '../database'; import { durationText } from '../util/timing'; import type { EntitiesCatalog, @@ -60,7 +65,14 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private readonly logger: Logger, ) {} - async entities(filters?: EntityFilters[]): Promise { + async entities(options?: { + filters?: EntityFilters[]; + tx?: Transaction; + }): Promise { + const filters = options?.filters; + + // TODO: Support with and without transaction! + const items = await this.database.transaction(tx => this.database.entities(tx, filters), ); @@ -135,8 +147,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { */ async batchAddOrUpdateEntities( requests: EntityUpsertRequest[], - locationId?: string, + options?: { locationId?: string; tx?: Transaction }, ): Promise { + // TODO: How to work with the transaction here? + // Group the entities by unique kind+namespace combinations const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { const name = getEntityName(entity); @@ -163,7 +177,11 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { ); // Retry the batch write a few times to deal with contention - const context = { kind, namespace, locationId }; + const context = { + kind, + namespace, + locationId: options?.locationId, + }; for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { try { const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( @@ -234,13 +252,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const markTimestamp = process.hrtime(); const names = requests.map(({ entity }) => entity.metadata.name); - const oldEntities = await this.entities([ - { - kind: kind, - 'metadata.namespace': namespace, - 'metadata.name': names, - }, - ]); + const oldEntities = await this.entities({ + filters: [ + { + kind: kind, + 'metadata.namespace': namespace, + 'metadata.name': names, + }, + ], + }); const oldEntitiesByName = new Map( oldEntities.map(e => [e.metadata.name, e]), diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index f515829b91..b78b9933b9 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -15,7 +15,7 @@ */ import { Location } from '@backstage/catalog-model'; -import type { Database } from '../database'; +import type { Database, Transaction } from '../database'; import { DatabaseLocationUpdateLogEvent, DatabaseLocationUpdateLogStatus, @@ -25,9 +25,19 @@ import { LocationResponse, LocationsCatalog } from './types'; export class DatabaseLocationsCatalog implements LocationsCatalog { constructor(private readonly database: Database) {} - async addLocation(location: Location): Promise { - const added = await this.database.addLocation(location); - return added; + async addLocation( + location: Location, + options?: { tx?: Transaction }, + ): Promise { + const transaction = options?.tx; + + if (transaction) { + return await this.database.addLocation(transaction, location); + } + + return await this.database.transaction( + async tx => await this.database.addLocation(tx, location), + ); } async removeLocation(id: string): Promise { diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 539a0e531f..1ac06618b5 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -15,7 +15,7 @@ */ import { Entity, EntityRelationSpec, Location } from '@backstage/catalog-model'; -import type { EntityFilters } from '../database'; +import type { EntityFilters, Transaction } from '../database'; // // Entities @@ -31,7 +31,10 @@ export type EntityUpsertResponse = { }; export type EntitiesCatalog = { - entities(filters?: EntityFilters[]): Promise; + entities(options?: { + filters?: EntityFilters[]; + tx?: Transaction; + }): Promise; removeEntityByUid(uid: string): Promise; /** @@ -42,7 +45,7 @@ export type EntitiesCatalog = { */ batchAddOrUpdateEntities( entities: EntityUpsertRequest[], - locationId?: string, + options?: { locationId?: string; tx?: Transaction }, ): Promise; }; @@ -70,7 +73,10 @@ export type LocationResponse = { }; export type LocationsCatalog = { - addLocation(location: Location): Promise; + addLocation( + location: Location, + options?: { tx?: Transaction }, + ): Promise; removeLocation(id: string): Promise; locations(): Promise; location(id: string): Promise; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index ac11f1dd1a..b2a238b111 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -91,7 +91,7 @@ describe('CommonDatabase', () => { timestamp: null, }; - await db.addLocation(input); + await db.transaction(async tx => await db.addLocation(tx, input)); const locations = await db.locations(); expect(locations).toEqual( @@ -238,7 +238,8 @@ describe('CommonDatabase', () => { type: 'a', target: 'b', }; - await db.addLocation(location); + + await db.transaction(async tx => await db.addLocation(tx, location)); await db.addLocationUpdateLogEvent( 'dd12620d-0436-422f-93bd-929aa0788123', diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 72e6bf0a5f..bd6dc29abc 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -46,6 +46,7 @@ import { DbLocationsRow, DbLocationsRowWithStatus, EntityFilters, + Transaction, } from './types'; // The number of items that are sent per batch to the database layer, when @@ -63,9 +64,23 @@ export class CommonDatabase implements Database { private readonly logger: Logger, ) {} - async transaction(fn: (tx: unknown) => Promise): Promise { + async transaction(fn: (tx: Transaction) => Promise): Promise { try { - return await this.database.transaction(fn); + let result: T | undefined = undefined; + + await this.database.transaction( + async tx => { + // We can't return here, as knex swallows the return type in case the transaction is rolled back: + // https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136 + result = await fn(tx); + }, + { + // If we explicitly trigger a rollback, don't fail. + doNotRejectOnRollback: true, + }, + ); + + return result!; } catch (e) { this.logger.debug(`Error during transaction, ${e}`); @@ -81,7 +96,7 @@ export class CommonDatabase implements Database { } async addEntity( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -110,7 +125,7 @@ export class CommonDatabase implements Database { } async addEntities( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest[], ): Promise { const tx = txOpaque as Knex.Transaction; @@ -158,7 +173,7 @@ export class CommonDatabase implements Database { } async updateEntity( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest, matchingEtag?: string, matchingGeneration?: number, @@ -217,7 +232,7 @@ export class CommonDatabase implements Database { } async entities( - txOpaque: unknown, + txOpaque: Transaction, filters?: EntityFilters[], ): Promise { const tx = txOpaque as Knex.Transaction; @@ -295,7 +310,7 @@ export class CommonDatabase implements Database { } async entityByName( - txOpaque: unknown, + txOpaque: Transaction, name: EntityName, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -314,7 +329,7 @@ export class CommonDatabase implements Database { } async entityByUid( - txOpaque: unknown, + txOpaque: Transaction, uid: string, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -330,7 +345,7 @@ export class CommonDatabase implements Database { return this.toEntityResponse(tx, rows[0]); } - async removeEntityByUid(txOpaque: unknown, uid: string): Promise { + async removeEntityByUid(txOpaque: Transaction, uid: string): Promise { const tx = txOpaque as Knex.Transaction; const result = await tx('entities').where({ id: uid }).del(); @@ -341,7 +356,7 @@ export class CommonDatabase implements Database { } async setRelations( - txOpaque: unknown, + txOpaque: Transaction, originatingEntityId: string, relations: EntityRelationSpec[], ): Promise { @@ -368,19 +383,22 @@ export class CommonDatabase implements Database { await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE); } - async addLocation(location: Location): Promise { - return await this.database.transaction(async tx => { - const row: DbLocationsRow = { - id: location.id, - type: location.type, - target: location.target, - }; - await tx('locations').insert(row); - return row; - }); + async addLocation( + txOpaque: Transaction, + location: Location, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const row: DbLocationsRow = { + id: location.id, + type: location.type, + target: location.target, + }; + await tx('locations').insert(row); + return row; } - async removeLocation(txOpaque: unknown, id: string): Promise { + async removeLocation(txOpaque: Transaction, id: string): Promise { const tx = txOpaque as Knex.Transaction; await tx('entities') diff --git a/plugins/catalog-backend/src/database/index.ts b/plugins/catalog-backend/src/database/index.ts index 565a41cfb2..0d84106815 100644 --- a/plugins/catalog-backend/src/database/index.ts +++ b/plugins/catalog-backend/src/database/index.ts @@ -22,4 +22,5 @@ export type { DbEntityResponse, EntityFilter, EntityFilters, + Transaction, } from './types'; diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index b8a2a6231a..62b2d55b88 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -101,6 +101,13 @@ export type EntityFilter = null | string | (null | string)[]; */ export type EntityFilters = Record; +/** + * An abstraction for transactions of the underlying database technology. + */ +export type Transaction = { + rollback(): Promise; +}; + /** * An abstraction on top of the underlying database, wrapping the basic CRUD * needs. @@ -114,7 +121,7 @@ export type Database = { * * @param fn The callback that implements the transaction */ - transaction(fn: (tx: unknown) => Promise): Promise; + transaction(fn: (tx: Transaction) => Promise): Promise; /** * Adds a set of new entities to the catalog. @@ -123,7 +130,7 @@ export type Database = { * @param request The entities being added */ addEntities( - tx: unknown, + tx: Transaction, request: DbEntityRequest[], ): Promise; @@ -147,22 +154,28 @@ export type Database = { * @returns The updated entity */ updateEntity( - tx: unknown, + tx: Transaction, request: DbEntityRequest, matchingEtag?: string, matchingGeneration?: number, ): Promise; - entities(tx: unknown, filters?: EntityFilters[]): Promise; + entities( + tx: Transaction, + filters?: EntityFilters[], + ): Promise; entityByName( - tx: unknown, + tx: Transaction, name: EntityName, ): Promise; - entityByUid(tx: unknown, uid: string): Promise; + entityByUid( + tx: Transaction, + uid: string, + ): Promise; - removeEntityByUid(tx: unknown, uid: string): Promise; + removeEntityByUid(tx: Transaction, uid: string): Promise; /** * Remove current relations for the entity and replace them with the new relations array @@ -171,14 +184,14 @@ export type Database = { * @param relations the relationships to be set */ setRelations( - tx: unknown, + tx: Transaction, entityUid: string, relations: EntityRelationSpec[], ): Promise; - addLocation(location: Location): Promise; + addLocation(tx: Transaction, location: Location): Promise; - removeLocation(tx: unknown, id: string): Promise; + removeLocation(tx: Transaction, id: string): Promise; location(id: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 933433b47e..174428799d 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -18,7 +18,11 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { LocationUpdateStatus } from '../catalog/types'; -import { DatabaseLocationUpdateLogStatus } from '../database/types'; +import { + Database, + DatabaseLocationUpdateLogStatus, + Transaction, +} from '../database/types'; import { HigherOrderOperations } from './HigherOrderOperations'; import { LocationReader } from './types'; @@ -26,6 +30,8 @@ describe('HigherOrderOperations', () => { let entitiesCatalog: jest.Mocked; let locationsCatalog: jest.Mocked; let locationReader: jest.Mocked; + let transaction: jest.Mocked; + let database: jest.Mocked; let higherOrderOperation: HigherOrderOperations; beforeAll(() => { @@ -46,10 +52,29 @@ describe('HigherOrderOperations', () => { locationReader = { read: jest.fn(), }; + transaction = { + rollback: jest.fn(), + }; + database = { + transaction: jest.fn(), + addEntities: jest.fn(), + updateEntity: jest.fn(), + entities: jest.fn(), + entityByName: jest.fn(), + entityByUid: jest.fn(), + removeEntityByUid: jest.fn(), + addLocation: jest.fn(), + removeLocation: jest.fn(), + location: jest.fn(), + locations: jest.fn(), + locationHistory: jest.fn(), + addLocationUpdateLogEvent: jest.fn(), + }; higherOrderOperation = new HigherOrderOperations( entitiesCatalog, locationsCatalog, locationReader, + database, getVoidLogger(), ); }); @@ -64,6 +89,7 @@ describe('HigherOrderOperations', () => { type: 'a', target: 'b', }; + database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); locationsCatalog.locations.mockResolvedValue([]); locationReader.read.mockResolvedValue({ @@ -90,7 +116,9 @@ describe('HigherOrderOperations', () => { id: expect.anything(), ...spec, }), + { tx: transaction }, ); + expect(transaction.rollback).not.toBeCalled(); }); it('reuses the location if a match already existed', async () => { @@ -103,6 +131,7 @@ describe('HigherOrderOperations', () => { ...spec, }; + database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.locations.mockResolvedValue([ { currentStatus: { timestamp: '', status: '', message: '' }, @@ -123,6 +152,7 @@ describe('HigherOrderOperations', () => { expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).not.toBeCalled(); + expect(transaction.rollback).not.toBeCalled(); }); it('rejects the whole operation if any entity could not be read', async () => { @@ -137,6 +167,7 @@ describe('HigherOrderOperations', () => { metadata: { name: 'n' }, }; + database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.locations.mockResolvedValue([]); locationReader.read.mockResolvedValue({ entities: [{ entity, location, relations: [] }], @@ -149,6 +180,43 @@ describe('HigherOrderOperations', () => { expect(locationsCatalog.locations).toBeCalledTimes(1); expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).not.toBeCalled(); + expect(transaction.rollback).not.toBeCalled(); + }); + + it('rollback everything after a dry run', async () => { + const spec = { + type: 'a', + target: 'b', + }; + database.transaction.mockImplementation(x => x(transaction)); + locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); + locationsCatalog.locations.mockResolvedValue([]); + locationReader.read.mockResolvedValue({ entities: [], errors: [] }); + + const result = await higherOrderOperation.addLocation(spec, { + dryRun: true, + }); + + expect(result.location).toEqual( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + ); + expect(result.entities).toEqual([]); + expect(locationsCatalog.locations).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); + expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled(); + expect(locationsCatalog.addLocation).toBeCalledTimes(1); + expect(locationsCatalog.addLocation).toBeCalledWith( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + { tx: transaction }, + ); + expect(transaction.rollback).toBeCalled(); }); }); diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 0e62815be5..2ca79c60b4 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -17,6 +17,7 @@ import { Location, LocationSpec } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; +import { Database } from '../database'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { durationText } from '../util/timing'; import { @@ -33,22 +34,13 @@ import { * database more directly. */ export class HigherOrderOperations implements HigherOrderOperation { - private readonly entitiesCatalog: EntitiesCatalog; - private readonly locationsCatalog: LocationsCatalog; - private readonly locationReader: LocationReader; - private readonly logger: Logger; - constructor( - entitiesCatalog: EntitiesCatalog, - locationsCatalog: LocationsCatalog, - locationReader: LocationReader, - logger: Logger, - ) { - this.entitiesCatalog = entitiesCatalog; - this.locationsCatalog = locationsCatalog; - this.locationReader = locationReader; - this.logger = logger; - } + private readonly entitiesCatalog: EntitiesCatalog, + private readonly locationsCatalog: LocationsCatalog, + private readonly locationReader: LocationReader, + private readonly database: Database, + private readonly logger: Logger, + ) {} /** * Adds a single location to the catalog. @@ -62,7 +54,12 @@ export class HigherOrderOperations implements HigherOrderOperation { * * @param spec The location to add */ - async addLocation(spec: LocationSpec): Promise { + async addLocation( + spec: LocationSpec, + options?: { dryRun?: boolean }, + ): Promise { + const dryRun = options?.dryRun || false; + // Attempt to find a previous location matching the spec const previousLocations = await this.locationsCatalog.locations(); const previousLocation = previousLocations.find( @@ -88,21 +85,36 @@ export class HigherOrderOperations implements HigherOrderOperation { // in the entities list. But we aren't sure what to do about those yet. // Write - if (!previousLocation) { - await this.locationsCatalog.addLocation(location); - } - if (readerOutput.entities.length === 0) { - return { location, entities: [] }; - } + const entities = await this.database.transaction(async tx => { + if (!previousLocation) { + await this.locationsCatalog.addLocation(location, { tx }); + } + if (readerOutput.entities.length === 0) { + return { location, entities: [] }; + } - const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities( - readerOutput.entities, - location.id, - ); + const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities( + readerOutput.entities, + { + locationId: location.id, + tx, + }, + ); - const entities = await this.entitiesCatalog.entities([ - { 'metadata.uid': writtenEntities.map(e => e.entityId) }, - ]); + const outputEntities = await this.entitiesCatalog.entities( + [{ 'metadata.uid': writtenEntities.map(e => e.entityId) }], + { tx }, + ); + + if (dryRun) { + // If this is only a dry run, cancel the database transaction even if it was successful. + await tx.rollback(); + + this.logger.debug(`Perfomed successful dry run of adding a location`); + } + + return outputEntities; + }); return { location, entities }; } @@ -168,7 +180,7 @@ export class HigherOrderOperations implements HigherOrderOperation { try { await this.entitiesCatalog.batchAddOrUpdateEntities( readerOutput.entities, - location.id, + { locationId: location.id }, ); } catch (e) { for (const entity of readerOutput.entities) { diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index c586e2d438..a83b4adecc 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -26,7 +26,10 @@ import type { // export type HigherOrderOperation = { - addLocation(spec: LocationSpec): Promise; + addLocation( + spec: LocationSpec, + options?: { dryRun?: boolean }, + ): Promise; refreshAllLocations(): Promise; }; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index b041dac5ab..f2a4ac7468 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -227,6 +227,7 @@ export class CatalogBuilder { entitiesCatalog, locationsCatalog, locationReader, + db, logger, ); diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index a6b22eaad9..3a43ac96ad 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -23,6 +23,8 @@ import { LocationResponse } from '../catalog/types'; import { HigherOrderOperation } from '../ingestion/types'; import { createRouter } from './router'; +// TODO: ... + describe('createRouter', () => { let entitiesCatalog: jest.Mocked; let locationsCatalog: jest.Mocked; @@ -83,13 +85,15 @@ describe('createRouter', () => { expect(response.status).toEqual(200); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { - a: ['1', null, '3'], - b: ['4'], - }, - { c: [null] }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [ + { + a: ['1', null, '3'], + b: ['4'], + }, + { c: [null] }, + ], + }); }); }); @@ -107,9 +111,9 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-uid/zzz'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { 'metadata.uid': 'zzz' }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [{ 'metadata.uid': 'zzz' }], + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); @@ -120,9 +124,9 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-uid/zzz'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { 'metadata.uid': 'zzz' }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [{ 'metadata.uid': 'zzz' }], + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/uid/); }); @@ -143,13 +147,15 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/k/ns/n'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { - kind: 'k', - 'metadata.namespace': 'ns', - 'metadata.name': 'n', - }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [ + { + kind: 'k', + 'metadata.namespace': 'ns', + 'metadata.name': 'n', + }, + ], + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); @@ -160,13 +166,15 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/b/d/c'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { - kind: 'b', - 'metadata.namespace': 'd', - 'metadata.name': 'c', - }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [ + { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }, + ], + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); }); @@ -209,9 +217,9 @@ describe('createRouter', () => { { entity, relations: [] }, ]); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { 'metadata.uid': 'u' }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filters: [{ 'metadata.uid': 'u' }], + }); expect(response.status).toEqual(200); expect(response.body).toEqual(entity); }); @@ -285,7 +293,36 @@ describe('createRouter', () => { const response = await request(app).post('/locations').send(spec); expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1); - expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec); + expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec, { + dryRun: false, + }); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); + }); + + it('supports dry run', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + higherOrderOperation.addLocation.mockResolvedValue({ + location: { id: 'a', ...spec }, + entities: [], + }); + + const response = await request(app) + .post('/locations?dryRun=true') + .send(spec); + + expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1); + expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec, { + dryRun: true, + }); expect(response.status).toEqual(201); expect(response.body).toEqual( expect.objectContaining({ diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 785e44f68c..e297bf16c6 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -20,6 +20,7 @@ import { locationSpecSchema } from '@backstage/catalog-model'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import yn from 'yn'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { HigherOrderOperation } from '../ingestion/types'; import { @@ -48,7 +49,7 @@ export async function createRouter( .get('/entities', async (req, res) => { const filters = translateQueryToEntityFilters(req.query); const fieldMapper = translateQueryToFieldMapper(req.query); - const entities = await entitiesCatalog.entities(filters); + const entities = await entitiesCatalog.entities({ filters }); res.status(200).send(entities.map(fieldMapper)); }) .post('/entities', async (req, res) => { @@ -56,18 +57,20 @@ export async function createRouter( const [result] = await entitiesCatalog.batchAddOrUpdateEntities([ { entity: body as Entity, relations: [] }, ]); - const [entity] = await entitiesCatalog.entities([ - { 'metadata.uid': result.entityId }, - ]); + const [entity] = await entitiesCatalog.entities({ + filters: [{ 'metadata.uid': result.entityId }], + }); res.status(200).send(entity); }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; - const entities = await entitiesCatalog.entities([ - { - 'metadata.uid': uid, - }, - ]); + const entities = await entitiesCatalog.entities({ + filters: [ + { + 'metadata.uid': uid, + }, + ], + }); if (!entities.length) { res.status(404).send(`No entity with uid ${uid}`); } @@ -80,13 +83,15 @@ export async function createRouter( }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const entities = await entitiesCatalog.entities([ - { - kind: kind, - 'metadata.namespace': namespace, - 'metadata.name': name, - }, - ]); + const entities = await entitiesCatalog.entities({ + filters: [ + { + kind: kind, + 'metadata.namespace': namespace, + 'metadata.name': name, + }, + ], + }); if (!entities.length) { res .status(404) @@ -101,7 +106,8 @@ export async function createRouter( if (higherOrderOperation) { router.post('/locations', async (req, res) => { const input = await validateRequestBody(req, locationSpecSchema); - const output = await higherOrderOperation.addLocation(input); + const dryRun = yn(req.query.dryRun, { default: false }); + const output = await higherOrderOperation.addLocation(input, { dryRun }); res.status(201).send(output); }); } From 2ebcfac8d4487313c9d4bd90225395965b5945b8 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 21 Oct 2020 09:37:25 +0200 Subject: [PATCH 29/61] feat: add a validate button to the register-component page This allows the user to validate his location before adding it. --- .changeset/angry-crabs-relate.md | 2 +- .changeset/nervous-drinks-notice.md | 8 ++ .../catalog/DatabaseEntitiesCatalog.test.ts | 30 +++-- .../src/catalog/DatabaseEntitiesCatalog.ts | 126 +++++++++++------- plugins/catalog-backend/src/catalog/types.ts | 5 +- .../ingestion/HigherOrderOperations.test.ts | 99 ++++++++++++-- .../src/ingestion/HigherOrderOperations.ts | 15 +-- .../src/service/router.test.ts | 2 - plugins/catalog/src/api/CatalogClient.ts | 5 +- plugins/catalog/src/api/types.ts | 1 + .../RegisterComponentForm.test.tsx | 10 +- .../RegisterComponentForm.tsx | 48 +++++-- .../RegisterComponentPage.tsx | 12 +- .../RegisterComponentResultDialog.tsx | 115 +++++++++------- 14 files changed, 327 insertions(+), 151 deletions(-) create mode 100644 .changeset/nervous-drinks-notice.md diff --git a/.changeset/angry-crabs-relate.md b/.changeset/angry-crabs-relate.md index 66cdb29be5..678093c1ac 100644 --- a/.changeset/angry-crabs-relate.md +++ b/.changeset/angry-crabs-relate.md @@ -2,7 +2,7 @@ '@backstage/plugin-catalog-backend': minor --- -Add ability to dry run adding a new location ot the catalog API. +Add ability to dry run adding a new location to the catalog API. The location is now added in a transaction and afterwards rolled back. This allows users to dry run this operation to see if there entity has issues. diff --git a/.changeset/nervous-drinks-notice.md b/.changeset/nervous-drinks-notice.md new file mode 100644 index 0000000000..7c6af21f64 --- /dev/null +++ b/.changeset/nervous-drinks-notice.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/plugin-register-component': minor +--- + +Add a validate button to the register-component page + +This allows the user to validate his location before adding it. diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 7e751bab92..90b11b179d 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -95,25 +95,33 @@ describe('DatabaseEntitiesCatalog', () => { namespace: 'd', }, }; - const existingTransaction: jest.Mocked = { rollback: jest.fn(), }; db.entities.mockResolvedValue([]); - db.addEntities.mockResolvedValue([{ entity }]); + db.addEntities.mockResolvedValue([ + { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, + ]); const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); - const result = await catalog.addOrUpdateEntity(entity, { - tx: existingTransaction, - }); - - expect(db.addEntities).toHaveBeenCalledTimes(1); - expect(db.addEntities).toHaveBeenCalledWith( - existingTransaction, - expect.anything(), + const result = await catalog.batchAddOrUpdateEntities( + [{ entity, relations: [] }], + { tx: existingTransaction }, ); - expect(result).toBe(entity); + + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ + { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], + }, + ]); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); + expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(result).toEqual([{ entityId: 'u' }]); }); it('updates when given uid', async () => { diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 28a972e8e3..53fb50677b 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -70,46 +70,44 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { tx?: Transaction; }): Promise { const filters = options?.filters; - - // TODO: Support with and without transaction! - - const items = await this.database.transaction(tx => - this.database.entities(tx, filters), - ); + const items = options?.tx + ? await this.database.entities(options?.tx, filters) + : await this.database.transaction(tx => + this.database.entities(tx, filters), + ); return items.map(i => i.entity); } private async addOrUpdateEntity( entity: Entity, + tx: Transaction, locationId?: string, ): Promise { - return await this.database.transaction(async tx => { - // Find a matching (by uid, or by compound name, depending on the given - // entity) existing entity, to know whether to update or add - const existing = entity.metadata.uid - ? await this.database.entityByUid(tx, entity.metadata.uid) - : await this.database.entityByName(tx, getEntityName(entity)); + // Find a matching (by uid, or by compound name, depending on the given + // entity) existing entity, to know whether to update or add + const existing = entity.metadata.uid + ? await this.database.entityByUid(tx, entity.metadata.uid) + : await this.database.entityByName(tx, getEntityName(entity)); - // If it's an update, run the algorithm for annotation merging, updating - // etag/generation, etc. - let response: DbEntityResponse; - if (existing) { - const updated = generateUpdatedEntity(existing.entity, entity); - response = await this.database.updateEntity( - tx, - { locationId, entity: updated }, - existing.entity.metadata.etag, - existing.entity.metadata.generation, - ); - } else { - const added = await this.database.addEntities(tx, [ - { locationId, entity }, - ]); - response = added[0]; - } + // If it's an update, run the algorithm for annotation merging, updating + // etag/generation, etc. + let response: DbEntityResponse; + if (existing) { + const updated = generateUpdatedEntity(existing.entity, entity); + response = await this.database.updateEntity( + tx, + { locationId, entity: updated }, + existing.entity.metadata.etag, + existing.entity.metadata.generation, + ); + } else { + const added = await this.database.addEntities(tx, [ + { locationId, entity }, + ]); + response = added[0]; + } - return response.entity; - }); + return response.entity; } async removeEntityByUid(uid: string): Promise { @@ -143,14 +141,41 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { * Writes a number of entities efficiently to storage. * * @param entities Some entities - * @param locationId The location that they all belong to + * @param options.locationId The location that they all belong to + * @param options.tx A database transaction to execute the queries in */ async batchAddOrUpdateEntities( requests: EntityUpsertRequest[], - options?: { locationId?: string; tx?: Transaction }, + options?: { + locationId?: string; + tx?: Transaction; + }, ): Promise { - // TODO: How to work with the transaction here? + const locationId = options?.locationId; + if (options?.tx) { + return this.batchAddOrUpdateEntitiesInTransaction( + requests, + options.tx, + locationId, + ); + } + + return await this.database.transaction( + async tx => + await this.batchAddOrUpdateEntitiesInTransaction( + requests, + tx, + locationId, + ), + ); + } + + private async batchAddOrUpdateEntitiesInTransaction( + requests: EntityUpsertRequest[], + tx: Transaction, + locationId?: string, + ): Promise { // Group the entities by unique kind+namespace combinations const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { const name = getEntityName(entity); @@ -180,29 +205,30 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const context = { kind, namespace, - locationId: options?.locationId, + locationId, }; for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { try { const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( batch, context, + tx, ); if (toAdd.length) { modifiedEntityIds.push( - ...(await this.batchAdd(toAdd, context)), + ...(await this.batchAdd(toAdd, context, tx)), ); } if (toUpdate.length) { modifiedEntityIds.push( - ...(await this.batchUpdate(toUpdate, context)), + ...(await this.batchUpdate(toUpdate, context, tx)), ); } // TODO(Rugvip): We currently always update relations, but we // likely want to figure out a way to avoid that for (const { entity, relations } of toIgnore) { const entityId = entity.metadata.uid!; - await this.setRelations(entityId, relations); + await this.setRelations(entityId, relations, tx); modifiedEntityIds.push({ entityId }); } @@ -231,10 +257,9 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async setRelations( originatingEntityId: string, relations: EntityRelationSpec[], + tx: Transaction, ): Promise { - return await this.database.transaction(tx => - this.database.setRelations(tx, originatingEntityId, relations), - ); + await this.database.setRelations(tx, originatingEntityId, relations); } // Given a batch of entities that were just read from a location, take them @@ -244,6 +269,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async analyzeBatch( requests: EntityUpsertRequest[], { kind, namespace }: BatchContext, + tx: Transaction, ): Promise<{ toAdd: EntityUpsertRequest[]; toUpdate: EntityUpsertRequest[]; @@ -260,6 +286,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { 'metadata.name': names, }, ], + tx, }); const oldEntitiesByName = new Map( @@ -299,15 +326,13 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async batchAdd( requests: EntityUpsertRequest[], { locationId }: BatchContext, + tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); - const res = await this.database.transaction( - async tx => - await this.database.addEntities( - tx, - requests.map(({ entity }) => ({ locationId, entity })), - ), + const res = await this.database.addEntities( + tx, + requests.map(({ entity }) => ({ locationId, entity })), ); const entityIds = res.map(({ entity }) => ({ @@ -315,7 +340,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { })); for (const [index, { entityId }] of entityIds.entries()) { - await this.setRelations(entityId, requests[index].relations); + await this.setRelations(entityId, requests[index].relations, tx); } this.logger.debug( @@ -330,15 +355,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async batchUpdate( requests: EntityUpsertRequest[], { locationId }: BatchContext, + tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); const responseIds: EntityUpsertResponse[] = []; // TODO(freben): Still not batched for (const entity of requests) { - const res = await this.addOrUpdateEntity(entity.entity, locationId); + const res = await this.addOrUpdateEntity(entity.entity, tx, locationId); const entityId = res.metadata.uid!; responseIds.push({ entityId }); - await this.setRelations(entityId, entity.relations); + await this.setRelations(entityId, entity.relations, tx); } this.logger.debug( diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1ac06618b5..6f2977619a 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -45,7 +45,10 @@ export type EntitiesCatalog = { */ batchAddOrUpdateEntities( entities: EntityUpsertRequest[], - options?: { locationId?: string; tx?: Transaction }, + options?: { + tx?: Transaction; + locationId?: string; + }, ): Promise; }; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 174428799d..da722e56bc 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -63,6 +63,7 @@ describe('HigherOrderOperations', () => { entityByName: jest.fn(), entityByUid: jest.fn(), removeEntityByUid: jest.fn(), + setRelations: jest.fn(), addLocation: jest.fn(), removeLocation: jest.fn(), location: jest.fn(), @@ -81,6 +82,7 @@ describe('HigherOrderOperations', () => { beforeEach(() => { jest.resetAllMocks(); + database.transaction.mockImplementation(async f => f(transaction)); }); describe('addLocation', () => { @@ -89,7 +91,6 @@ describe('HigherOrderOperations', () => { type: 'a', target: 'b', }; - database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); locationsCatalog.locations.mockResolvedValue([]); locationReader.read.mockResolvedValue({ @@ -121,6 +122,62 @@ describe('HigherOrderOperations', () => { expect(transaction.rollback).not.toBeCalled(); }); + it('insert the location and its entities', async () => { + const spec = { + type: 'a', + target: 'b', + }; + const location: LocationSpec = { type: '', target: '' }; + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); + locationsCatalog.locations.mockResolvedValue([]); + locationsCatalog.locations.mockResolvedValue([]); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { + entityId: 'id', + }, + ]); + entitiesCatalog.entities.mockResolvedValue([entity]); + locationReader.read.mockResolvedValue({ + entities: [ + { + location, + entity, + relations: [], + }, + ], + errors: [], + }); + + const result = await higherOrderOperation.addLocation(spec); + + expect(result.location).toEqual( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + ); + expect(result.entities).toEqual([entity]); + expect(locationsCatalog.locations).toBeCalledTimes(1); + expect(locationsCatalog.addLocation).toBeCalledTimes(1); + expect(locationsCatalog.addLocation).toBeCalledWith( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + { tx: transaction }, + ); + expect(locationReader.read).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); + expect(entitiesCatalog.entities).toBeCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledTimes(1); + expect(transaction.rollback).not.toBeCalled(); + }); + it('reuses the location if a match already existed', async () => { const spec = { type: 'a', @@ -131,7 +188,6 @@ describe('HigherOrderOperations', () => { ...spec, }; - database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.locations.mockResolvedValue([ { currentStatus: { timestamp: '', status: '', message: '' }, @@ -167,7 +223,6 @@ describe('HigherOrderOperations', () => { metadata: { name: 'n' }, }; - database.transaction.mockImplementation(x => x(transaction)); locationsCatalog.locations.mockResolvedValue([]); locationReader.read.mockResolvedValue({ entities: [{ entity, location, relations: [] }], @@ -188,10 +243,31 @@ describe('HigherOrderOperations', () => { type: 'a', target: 'b', }; - database.transaction.mockImplementation(x => x(transaction)); + const location: LocationSpec = { type: '', target: '' }; + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); locationsCatalog.locations.mockResolvedValue([]); - locationReader.read.mockResolvedValue({ entities: [], errors: [] }); + locationsCatalog.locations.mockResolvedValue([]); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { + entityId: 'id', + }, + ]); + entitiesCatalog.entities.mockResolvedValue([entity]); + locationReader.read.mockResolvedValue({ + entities: [ + { + location, + entity, + relations: [], + }, + ], + errors: [], + }); const result = await higherOrderOperation.addLocation(spec, { dryRun: true, @@ -203,11 +279,8 @@ describe('HigherOrderOperations', () => { ...spec, }), ); - expect(result.entities).toEqual([]); + expect(result.entities).toEqual([entity]); expect(locationsCatalog.locations).toBeCalledTimes(1); - expect(locationReader.read).toBeCalledTimes(1); - expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); - expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled(); expect(locationsCatalog.addLocation).toBeCalledTimes(1); expect(locationsCatalog.addLocation).toBeCalledWith( expect.objectContaining({ @@ -216,6 +289,10 @@ describe('HigherOrderOperations', () => { }), { tx: transaction }, ); + expect(locationReader.read).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); + expect(entitiesCatalog.entities).toBeCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledTimes(1); expect(transaction.rollback).toBeCalled(); }); }); @@ -281,7 +358,9 @@ describe('HigherOrderOperations', () => { relations: [], }), ], - '123', + { + locationId: '123', + }, ); }); diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 2ca79c60b4..ac21fc6155 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -90,21 +90,18 @@ export class HigherOrderOperations implements HigherOrderOperation { await this.locationsCatalog.addLocation(location, { tx }); } if (readerOutput.entities.length === 0) { - return { location, entities: [] }; + return []; } const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities( readerOutput.entities, - { - locationId: location.id, - tx, - }, + { locationId: location.id, tx }, ); - const outputEntities = await this.entitiesCatalog.entities( - [{ 'metadata.uid': writtenEntities.map(e => e.entityId) }], - { tx }, - ); + const outputEntities = await this.entitiesCatalog.entities({ + filters: [{ 'metadata.uid': writtenEntities.map(e => e.entityId) }], + tx, + }); if (dryRun) { // If this is only a dry run, cancel the database transaction even if it was successful. diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 3a43ac96ad..95dc8e82d1 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -23,8 +23,6 @@ import { LocationResponse } from '../catalog/types'; import { HigherOrderOperation } from '../ingestion/types'; import { createRouter } from './router'; -// TODO: ... - describe('createRouter', () => { let entitiesCatalog: jest.Mocked; let locationsCatalog: jest.Mocked; diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 40622da60a..85fd44cf01 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -95,9 +95,12 @@ export class CatalogClient implements CatalogApi { async addLocation({ type = 'url', target, + dryRun, }: AddLocationRequest): Promise { const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, + `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ + dryRun ? '?dryRun=true' : '' + }`, { headers: { 'Content-Type': 'application/json', diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 324a8d43fd..06030e6ac5 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -43,6 +43,7 @@ export interface CatalogApi { export type AddLocationRequest = { type?: string; target: string; + dryRun?: boolean; }; export type AddLocationResponse = { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 875b1f8660..fae5d649b9 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -20,17 +20,18 @@ import React from 'react'; import { RegisterComponentForm } from './RegisterComponentForm'; describe('RegisterComponentForm', () => { - it('should initially render a disabled button', async () => { + it('should initially render disabled buttons', async () => { render(); expect( await screen.findByText(/Enter the full path to the catalog-info.yaml/), ).toBeInTheDocument(); - expect(screen.getByText('Submit').closest('button')).toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).toBeDisabled(); + expect(screen.getByText('Register').closest('button')).toBeDisabled(); }); - it('should enable a submit button when the target url is set', async () => { + it('should enable the submit buttons when the target url is set', async () => { render(); await act(async () => { @@ -40,7 +41,8 @@ describe('RegisterComponentForm', () => { ); }); - expect(screen.getByText('Submit').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Register').closest('button')).not.toBeDisabled(); }); it('should show spinner while submitting', async () => { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index 56dd50ea67..de183869f7 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -33,8 +33,11 @@ const useStyles = makeStyles(theme => ({ display: 'flex', flexFlow: 'column nowrap', }, - submit: { - marginTop: theme.spacing(1), + buttonSpacing: { + marginLeft: theme.spacing(1), + }, + buttons: { + marginTop: theme.spacing(2), }, select: { minWidth: 120, @@ -54,12 +57,21 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { const hasErrors = !!errors.entityLocation; const dirty = formState?.isDirty; + const onSubmitValidate = handleSubmit(data => { + data.mode = 'validate'; + onSubmit(data); + }); + + const onSubmitRegister = handleSubmit(data => { + data.mode = 'register'; + onSubmit(data); + }); + return submitting ? ( ) : (
@@ -87,15 +99,27 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { )} - +
+ + +
); }; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index c7f94e2060..d0b56598b2 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -75,27 +75,30 @@ export const RegisterComponentPage = ({ location: Location; } | null; error: null | Error; + dryRun: boolean; }>({ data: null, error: null, + dryRun: false, }); const handleSubmit = async (formData: Record) => { setFormState(FormStates.Submitting); - const { entityLocation: target } = formData; + const { entityLocation: target, mode } = formData; + const dryRun = mode === 'validate'; try { - const data = await catalogApi.addLocation({ target }); + const data = await catalogApi.addLocation({ target, dryRun }); if (!isMounted()) return; - setResult({ error: null, data }); + setResult({ error: null, data, dryRun }); setFormState(FormStates.Success); } catch (e) { errorApi.post(e); if (!isMounted()) return; - setResult({ error: e, data: null }); + setResult({ error: e, data: null, dryRun }); setFormState(FormStates.Idle); } }; @@ -124,6 +127,7 @@ export const RegisterComponentPage = ({ {formState === FormStates.Success && ( setFormState(FormStates.Idle)} classes={{ paper: classes.dialogPaper }} catalogRouteRef={catalogRouteRef} diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index 9631865ffb..2b57050661 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -29,7 +29,7 @@ import { List, ListItem, } from '@material-ui/core'; -import React from 'react'; +import React, { useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; @@ -37,6 +37,7 @@ type Props = { onClose: () => void; classes?: Record; entities: Entity[]; + dryRun?: boolean; catalogRouteRef: RouteRef; }; @@ -64,49 +65,71 @@ export const RegisterComponentResultDialog = ({ onClose, classes, entities, + dryRun, catalogRouteRef, -}: Props) => ( - - Registration Result - - - The following entities have been successfully created: - - - {entities.map((entity: any, index: number) => { - const entityPath = getEntityCatalogPath({ entity, catalogRouteRef }); - return ( - - - - {entityPath} - - ), - }} - /> - - {index < entities.length - 1 && } - - ); - })} - - - - - - -); +}: Props) => { + const [open, setOpen] = useState(true); + const handleClose = () => { + setOpen(false); + }; + + return ( + + + {dryRun ? 'Validation Result' : 'Registration Result'} + + + + {dryRun + ? 'The following entities would be created:' + : 'The following entities have been successfully created:'} + + + {entities.map((entity: any, index: number) => { + const entityPath = getEntityCatalogPath({ + entity, + catalogRouteRef, + }); + return ( + + + + {entityPath} + + ), + }} + /> + + {index < entities.length - 1 && } + + ); + })} + + + + {dryRun && ( + + )} + {!dryRun && ( + + )} + + + ); +}; From a6da3d1c06b33dcdf8b3588868646caaf9bbaeda Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 29 Oct 2020 11:10:11 +0100 Subject: [PATCH 30/61] fix: upgrade knex to 0.21.6 This reduces a overly noisy warning during tests: https://github.com/knex/knex/pull/3936 --- packages/backend-common/package.json | 2 +- packages/backend/package.json | 2 +- .../packages/backend/package.json.hbs | 2 +- plugins/auth-backend/package.json | 2 +- plugins/catalog-backend/package.json | 2 +- plugins/techdocs-backend/package.json | 2 +- yarn.lock | 22 +++++++++---------- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index dea2f16bae..076e644afd 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -43,7 +43,7 @@ "express-promise-router": "^3.0.3", "git-url-parse": "^11.4.0", "helmet": "^4.0.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "lodash": "^4.17.15", "logform": "^2.1.1", "minimist": "^1.2.5", diff --git a/packages/backend/package.json b/packages/backend/package.json index 9f8e66bc61..0d066d1139 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -38,7 +38,7 @@ "example-app": "^0.1.1-alpha.26", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "knex": "^0.21.1", + "knex": "^0.21.6", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", "sqlite3": "^5.0.0", diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index dde04868b9..bf887dc014 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -31,7 +31,7 @@ "dockerode": "^3.2.0", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "knex": "^0.21.1", + "knex": "^0.21.6", {{#if dbTypePG}} "pg": "^8.3.0", {{/if}} diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 6489c8547d..caa20e361b 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -35,7 +35,7 @@ "helmet": "^4.0.0", "jose": "^1.27.1", "jwt-decode": "2.2.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", "passport": "^0.4.1", diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index cd8e363901..d15779bf4e 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -32,7 +32,7 @@ "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", "git-url-parse": "^11.4.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 5bdc7ba282..50eb0253cc 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -32,7 +32,7 @@ "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "nodegit": "^0.27.0", "winston": "^3.2.1" }, diff --git a/yarn.lock b/yarn.lock index b2b87be5e0..70b224a600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13469,7 +13469,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -15194,23 +15194,21 @@ kleur@^3.0.3: resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -knex@^0.21.1: - version "0.21.5" - resolved "https://registry.npmjs.org/knex/-/knex-0.21.5.tgz#c4be1958488f348aed3510aa4b7115639ee1bd01" - integrity sha512-cQj7F2D/fu03eTr6ZzYCYKdB9w7fPYlvTiU/f2OeXay52Pq5PwD+NAkcf40WDnppt/4/4KukROwlMOaE7WArcA== +knex@^0.21.6: + version "0.21.8" + resolved "https://registry.npmjs.org/knex/-/knex-0.21.8.tgz#e5c07af61ee6aa006d3468e10e3a69351deb0c26" + integrity sha512-ziUu4vAlIGA8j2l0S4xcD1d3XdpJA4HYGhwHEhgAgefGCmB1OLSjUGCs/ebkJal42fSvAkyZaB0tcOtTXKgS5g== dependencies: colorette "1.2.1" commander "^5.1.0" debug "4.1.1" esm "^3.2.25" getopts "2.2.5" - inherits "~2.0.4" interpret "^2.2.0" liftoff "3.1.0" lodash "^4.17.20" - mkdirp "^1.0.4" pg-connection-string "2.3.0" - tarn "^3.0.0" + tarn "^3.0.1" tildify "2.0.0" uuid "^7.0.3" v8flags "^3.2.0" @@ -22264,10 +22262,10 @@ tar@^6.0.1, tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" -tarn@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.0.tgz#a4082405216c0cce182b8b4cb2639c52c1e870d4" - integrity sha512-PKUnlDFODZueoA8owLehl8vLcgtA8u4dRuVbZc92tspDYZixjJL6TqYOmryf/PfP/EBX+2rgNcrj96NO+RPkdQ== +tarn@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec" + integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw== tdigest@^0.1.1: version "0.1.1" From 4d66ae31c266b9b1de6b1523306a57c14a58f046 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 29 Oct 2020 14:08:16 +0100 Subject: [PATCH 31/61] feat: upgrade ts-jest to 26.3.0 This adds support for typescript 4.0 and resolves a warning during testing. --- packages/cli/package.json | 2 +- yarn.lock | 110 ++++++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 668ec5e000..00bf463018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -96,7 +96,7 @@ "sucrase": "^3.16.0", "tar": "^6.0.1", "terser-webpack-plugin": "^1.4.3", - "ts-jest": "^26.0.0", + "ts-jest": "^26.3.0", "ts-loader": "^7.0.4", "typescript": "^4.0.3", "url-loader": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index 70b224a600..52f797f241 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2315,6 +2315,11 @@ slash "^3.0.0" strip-ansi "^6.0.0" +"@jest/create-cache-key-function@^26.5.0": + version "26.5.0" + resolved "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-26.5.0.tgz#1d07947adc51ea17766d9f0ccf5a8d6ea94c47dc" + integrity sha512-DJ+pEBUIqarrbv1W/C39f9YH0rJ4wsXZ/VC6JafJPlHW2HOucKceeaqTOQj9MEDQZjySxMLkOq5mfXZXNZcmWw== + "@jest/environment@^26.5.2": version "26.5.2" resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.5.2.tgz#eba3cfc698f6e03739628f699c28e8a07f5e65fe" @@ -2461,6 +2466,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^26.6.1": + version "26.6.1" + resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" + integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + "@jsdevtools/ono@^7.1.3": version "7.1.3" resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" @@ -5242,6 +5258,14 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" +"@types/jest@26.x": + version "26.0.15" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" + integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== + dependencies: + jest-diff "^26.0.0" + pretty-format "^26.0.0" + "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -14350,6 +14374,16 @@ jest-diff@^25.2.1: jest-get-type "^25.2.6" pretty-format "^25.5.0" +jest-diff@^26.0.0: + version "26.6.1" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" + integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== + dependencies: + chalk "^4.0.0" + diff-sequences "^26.5.0" + jest-get-type "^26.3.0" + pretty-format "^26.6.1" + jest-diff@^26.5.2: version "26.5.2" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.5.2.tgz#8e26cb32dc598e8b8a1b9deff55316f8313c8053" @@ -14627,6 +14661,18 @@ jest-snapshot@^26.5.3: pretty-format "^26.5.2" semver "^7.3.2" +jest-util@^26.1.0: + version "26.6.1" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.1.tgz#4cc0d09ec57f28d12d053887eec5dc976a352e9b" + integrity sha512-xCLZUqVoqhquyPLuDXmH7ogceGctbW8SMyQVjD9o+1+NPWI7t0vO08udcFLVPLgKWcvc+zotaUv/RuaR6l8HIA== + dependencies: + "@jest/types" "^26.6.1" + "@types/node" "*" + chalk "^4.0.0" + graceful-fs "^4.2.4" + is-ci "^2.0.0" + micromatch "^4.0.2" + jest-util@^26.5.2: version "26.5.2" resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.5.2.tgz#8403f75677902cc52a1b2140f568e91f8ed4f4d7" @@ -16198,14 +16244,6 @@ microevent.ts@~0.1.1: resolved "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@4.x, micromatch@^4.0.0, micromatch@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== - dependencies: - braces "^3.0.1" - picomatch "^2.0.5" - micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -16225,6 +16263,14 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -18811,6 +18857,16 @@ pretty-format@^25.2.1, pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^26.0.0, pretty-format@^26.6.1: + version "26.6.1" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" + integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== + dependencies: + "@jest/types" "^26.6.1" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + pretty-format@^26.4.2: version "26.4.2" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237" @@ -19496,6 +19552,11 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-i resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" + integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== + react-lazylog@^4.5.2, react-lazylog@^4.5.3: version "4.5.3" resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" @@ -22739,21 +22800,23 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" -ts-jest@^26.0.0: - version "26.1.1" - resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz#b98569b8a4d4025d966b3d40c81986dd1c510f8d" - integrity sha512-Lk/357quLg5jJFyBQLnSbhycnB3FPe+e9i7ahxokyXxAYoB0q1pPmqxxRPYr4smJic1Rjcf7MXDBhZWgxlli0A== +ts-jest@^26.3.0: + version "26.4.3" + resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.4.3.tgz#d153a616033e7ec8544b97ddbe2638cbe38d53db" + integrity sha512-pFDkOKFGY+nL9v5pkhm+BIFpoAuno96ff7GMnIYr/3L6slFOS365SI0fGEVYx2RKGji5M2elxhWjDMPVcOCdSw== dependencies: + "@jest/create-cache-key-function" "^26.5.0" + "@types/jest" "26.x" bs-logger "0.x" buffer-from "1.x" fast-json-stable-stringify "2.x" + jest-util "^26.1.0" json5 "2.x" lodash.memoize "4.x" make-error "1.x" - micromatch "4.x" mkdirp "1.x" semver "7.x" - yargs-parser "18.x" + yargs-parser "20.x" ts-loader@^7.0.4: version "7.0.4" @@ -24214,13 +24277,10 @@ yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yargs-parser@18.x, yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +yargs-parser@20.x: + version "20.2.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.3.tgz#92419ba867b858c868acf8bae9bf74af0dd0ce26" + integrity sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww== yargs-parser@^10.0.0: version "10.1.0" @@ -24245,6 +24305,14 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^20.0.0: version "20.2.1" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.1.tgz#28f3773c546cdd8a69ddae68116b48a5da328e77" From 3f5ef4ac1617c152daf3312d2280f34bf8f738ac Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 15:52:18 -0400 Subject: [PATCH 32/61] formatting --- .../src/api/ExampleCostInsightsClient.ts | 5 +---- .../components/CostGrowth/CostGrowth.test.tsx | 8 ++++---- .../ProjectGrowthAlertCard.tsx | 5 +++-- plugins/cost-insights/src/utils/alerts.tsx | 16 ++++++++++++++++ plugins/cost-insights/src/utils/formatters.ts | 5 +---- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts b/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts index 70695beb25..8cc66e626f 100644 --- a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts +++ b/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts @@ -33,10 +33,7 @@ import { Trendline, UnlabeledDataflowData, } from '../types'; -import { - ProjectGrowthAlert, - UnlabeledDataflowAlert -} from '../utils/alerts'; +import { ProjectGrowthAlert, UnlabeledDataflowAlert } from '../utils/alerts'; import { DEFAULT_DATE_FORMAT, exclusiveEndDateOf, diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 046394ca7a..9c76d6d998 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -37,10 +37,10 @@ const MockContext = ({ currency: Currency; engineerCost: number; }>) => ( - - {children} - - ); + + {children} + +); describe.each` engineerCost | ratio | amount | expected diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index 18f7fa151d..4311bcc1bf 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -31,8 +31,9 @@ export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { const [costStart, costEnd] = alert.aggregation; const subheader = ` - ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${alert.products.length > 1 ? ', sorted by cost' : '' - }`; + ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + alert.products.length > 1 ? ', sorted by cost' : '' + }`; const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format( '[Q]Q YYYY', ); diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index fcb41f1e3b..fbb148f38a 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react'; import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index fc563bf836..bf8e26ef5f 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -16,10 +16,7 @@ import moment from 'moment'; import { Duration } from '../types'; -import { - inclusiveEndDateOf, - inclusiveStartDateOf, -} from '../utils/duration'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; import { pluralOf } from '../utils/grammar'; export type Period = { From a40536a24ba9deba68c784a63cf094790fd68ff7 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:31:05 -0400 Subject: [PATCH 33/61] move example client out of src to break import cycles --- plugins/cost-insights/dev/index.tsx | 4 ++-- plugins/cost-insights/src/api/index.ts | 1 - .../src/{api/ExampleCostInsightsClient.ts => client.ts} | 8 ++++---- plugins/cost-insights/src/index.ts | 1 + 4 files changed, 7 insertions(+), 7 deletions(-) rename plugins/cost-insights/src/{api/ExampleCostInsightsClient.ts => client.ts} (97%) diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx index 836aea0edb..fe208d5bb8 100644 --- a/plugins/cost-insights/dev/index.tsx +++ b/plugins/cost-insights/dev/index.tsx @@ -15,8 +15,8 @@ */ import { createDevApp } from '@backstage/dev-utils'; import { createPlugin, createApiFactory } from '@backstage/core'; -import { ExampleCostInsightsClient } from '../src/api'; -import { costInsightsApiRef } from '../src'; +import { costInsightsApiRef } from '../src/api'; +import { ExampleCostInsightsClient } from '../src/client'; import { pluginConfig } from '../src/plugin'; const devPlugin = createPlugin({ diff --git a/plugins/cost-insights/src/api/index.ts b/plugins/cost-insights/src/api/index.ts index a367df9487..d231570e9b 100644 --- a/plugins/cost-insights/src/api/index.ts +++ b/plugins/cost-insights/src/api/index.ts @@ -15,4 +15,3 @@ */ export * from './CostInsightsApi'; -export * from './ExampleCostInsightsClient'; diff --git a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts b/plugins/cost-insights/src/client.ts similarity index 97% rename from plugins/cost-insights/src/api/ExampleCostInsightsClient.ts rename to plugins/cost-insights/src/client.ts index 8cc66e626f..b73e824031 100644 --- a/plugins/cost-insights/src/api/ExampleCostInsightsClient.ts +++ b/plugins/cost-insights/src/client.ts @@ -17,7 +17,7 @@ import dayjs from 'dayjs'; import regression, { DataPoint } from 'regression'; -import { CostInsightsApi } from './CostInsightsApi'; +import { CostInsightsApi } from './api'; import { Alert, ChangeStatistic, @@ -32,13 +32,13 @@ import { ProjectGrowthData, Trendline, UnlabeledDataflowData, -} from '../types'; -import { ProjectGrowthAlert, UnlabeledDataflowAlert } from '../utils/alerts'; +} from './types'; +import { ProjectGrowthAlert, UnlabeledDataflowAlert } from './utils/alerts'; import { DEFAULT_DATE_FORMAT, exclusiveEndDateOf, inclusiveStartDateOf, -} from '../utils/duration'; +} from './utils/duration'; type IntervalFields = { duration: Duration; diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 48b2425946..795d4e14a6 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -15,6 +15,7 @@ */ export { plugin } from './plugin'; +export * from './ExampleCostInsightsClient'; export * from './api'; export * from './components'; export { useCurrency } from './hooks'; From f454f0eecca9dd35f60b4f47f9ccc1b30a5020f1 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:34:01 -0400 Subject: [PATCH 34/61] make thresholds and growth enums public --- .../src/components/CostGrowth/CostGrowth.tsx | 4 ++-- plugins/cost-insights/src/types/ChangeStatistic.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index 3f5e0f55eb..da2f3e3c1b 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -16,9 +16,9 @@ import React from 'react'; import classnames from 'classnames'; -import { ChangeStatistic, CurrencyType, Duration } from '../../types'; +import { ChangeStatistic, CurrencyType, Duration, EngineerThreshold, GrowthType } from '../../types'; import { rateOf } from '../../utils/currency'; -import { growthOf, GrowthType, EngineerThreshold } from '../../utils/change'; +import { growthOf } from '../../utils/change'; import { useCostGrowthStyles as useStyles } from '../../utils/styles'; import { formatPercent, formatCurrency } from '../../utils/formatters'; import { indefiniteArticleOf } from '../../utils/grammar'; diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index e60d47a0e6..a47640411a 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -20,3 +20,16 @@ export interface ChangeStatistic { // The actual USD change between time periods (can be negative if costs decreased) amount: number; } + +export const EngineerThreshold = 0.5; + +export enum ChangeThreshold { + upper = 0.05, + lower = -0.05, +} + +export enum GrowthType { + Negligible, + Savings, + Excess, +} From 24b8f78d5fdd8d71096f6c0427942fee052fec1b Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:36:52 -0400 Subject: [PATCH 35/61] make client public --- plugins/cost-insights/src/client.ts | 8 ++++---- plugins/cost-insights/src/index.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index b73e824031..979a7a44ef 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -17,7 +17,7 @@ import dayjs from 'dayjs'; import regression, { DataPoint } from 'regression'; -import { CostInsightsApi } from './api'; +import { CostInsightsApi } from '../src/api'; import { Alert, ChangeStatistic, @@ -32,13 +32,13 @@ import { ProjectGrowthData, Trendline, UnlabeledDataflowData, -} from './types'; -import { ProjectGrowthAlert, UnlabeledDataflowAlert } from './utils/alerts'; +} from '../src/types'; +import { ProjectGrowthAlert, UnlabeledDataflowAlert } from '../src/utils/alerts'; import { DEFAULT_DATE_FORMAT, exclusiveEndDateOf, inclusiveStartDateOf, -} from './utils/duration'; +} from '../src/utils/duration'; type IntervalFields = { duration: Duration; diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 795d4e14a6..1e6172d568 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -15,7 +15,7 @@ */ export { plugin } from './plugin'; -export * from './ExampleCostInsightsClient'; +export * from './client'; export * from './api'; export * from './components'; export { useCurrency } from './hooks'; From c894503435f36712d10da018d582f1ec1ae0c2d3 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:38:24 -0400 Subject: [PATCH 36/61] revert order --- .../ResourceGrowthBarChartLegend.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx index 2e8e0f07a1..6b5ce17c95 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -19,7 +19,7 @@ import { Box, useTheme } from '@material-ui/core'; import { LegendItem } from '../LegendItem'; import { CostGrowth } from '../CostGrowth'; import { currencyFormatter } from '../../utils/formatters'; -import { ChangeStatistic, Duration, CostInsightsTheme } from '../../types'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; export type ResourceGrowthBarChartLegendProps = { change: ChangeStatistic; From 7926f07735514f2fe6d76f5dad8f09597742ea61 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:39:02 -0400 Subject: [PATCH 37/61] make default date format public --- plugins/cost-insights/src/types/Duration.ts | 2 ++ plugins/cost-insights/src/utils/duration.ts | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index a1eb9aa31e..acf707dd1e 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -26,3 +26,5 @@ export enum Duration { P1M = 'P1M', P3M = 'P3M', } + +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index d90e78dc5f..6a880b382c 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -15,11 +15,9 @@ */ import moment from 'moment'; -import { Duration } from '../types'; +import { Duration, DEFAULT_DATE_FORMAT } from '../types'; import { assertNever } from './assert'; -export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; - /** * Derive the start date of a given period, assuming two repeating intervals. * From ce0e0f12c5d9cf991e980d91ec6da7ab50424b2c Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 17:41:38 -0400 Subject: [PATCH 38/61] formatting --- plugins/cost-insights/src/client.ts | 5 ++++- .../src/components/CostGrowth/CostGrowth.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 979a7a44ef..914fdb7b64 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -33,7 +33,10 @@ import { Trendline, UnlabeledDataflowData, } from '../src/types'; -import { ProjectGrowthAlert, UnlabeledDataflowAlert } from '../src/utils/alerts'; +import { + ProjectGrowthAlert, + UnlabeledDataflowAlert, +} from '../src/utils/alerts'; import { DEFAULT_DATE_FORMAT, exclusiveEndDateOf, diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index da2f3e3c1b..89e44ee185 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -16,7 +16,13 @@ import React from 'react'; import classnames from 'classnames'; -import { ChangeStatistic, CurrencyType, Duration, EngineerThreshold, GrowthType } from '../../types'; +import { + ChangeStatistic, + CurrencyType, + Duration, + EngineerThreshold, + GrowthType, +} from '../../types'; import { rateOf } from '../../utils/currency'; import { growthOf } from '../../utils/change'; import { useCostGrowthStyles as useStyles } from '../../utils/styles'; From e9c222605bb1ae1fccfd0990a645ad576124796f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 29 Oct 2020 22:55:05 +0100 Subject: [PATCH 39/61] cli: fix create-plugin tmp dir creation --- .../create-plugin/createPlugin.test.ts | 27 +---------------- .../commands/create-plugin/createPlugin.ts | 29 ++++++------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index a08bb0da87..660caec189 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -17,9 +17,7 @@ import fs from 'fs-extra'; import path from 'path'; import mockFs from 'mock-fs'; -import os from 'os'; -import del from 'del'; -import { createTemporaryPluginFolder, movePlugin } from './createPlugin'; +import { movePlugin } from './createPlugin'; const id = 'testPluginMock'; @@ -28,29 +26,6 @@ describe('createPlugin', () => { mockFs.restore(); }); - describe('createPluginFolder', () => { - it('should create a temporary plugin directory in the correct place', async () => { - const tempDir = path.join(os.tmpdir(), id); - try { - await createTemporaryPluginFolder(tempDir); - await expect(fs.pathExists(tempDir)).resolves.toBe(true); - expect(tempDir).toMatch(id); - } finally { - await del(tempDir, { force: true }); - } - }); - - it('should not create a temporary plugin directory if it already exists', async () => { - mockFs({ - [id]: {}, - }); - - await expect(createTemporaryPluginFolder(id)).rejects.toThrow( - /Failed to create temporary plugin directory/, - ); - }); - }); - describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { mockFs({ diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index f54a62f613..c9f8b3a81d 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -19,7 +19,7 @@ import { promisify } from 'util'; import chalk from 'chalk'; import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, join as joinPath } from 'path'; import os from 'os'; import { Command } from 'commander'; import { @@ -46,18 +46,6 @@ async function checkExists(destination: string) { }); } -export async function createTemporaryPluginFolder(tempDir: string) { - await Task.forItem('creating', 'temporary directory', async () => { - try { - await fs.mkdir(tempDir); - } catch (error) { - throw new Error( - `Failed to create temporary plugin directory: ${error.message}`, - ); - } - }); -} - const sortObjectByKeys = (obj: { [name in string]: string }) => { return Object.keys(obj) .sort() @@ -238,7 +226,6 @@ export default async (cmd: Command) => { ? 'templates/default-backend-plugin' : 'templates/default-plugin', ); - const tempDir = resolvePath(os.tmpdir(), pluginId); const pluginDir = isMonoRepo ? paths.resolveTargetRoot('plugins', pluginId) : paths.resolveTargetRoot(pluginId); @@ -250,13 +237,15 @@ export default async (cmd: Command) => { Task.log(); Task.log('Creating the plugin...'); + Task.section('Checking if the plugin ID is available'); + await checkExists(pluginDir); + + Task.section('Creating a temporary plugin directory'); + const tempDir = await fs.mkdtemp( + joinPath(os.tmpdir(), `backstage-plugin-${pluginId}`), + ); + try { - Task.section('Checking if the plugin ID is available'); - await checkExists(pluginDir); - - Task.section('Creating a temporary plugin directory'); - await createTemporaryPluginFolder(tempDir); - Task.section('Preparing files'); await templatingTask( From 21ba4d38a0f6359e8a08649b6c74bd098c457d23 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 29 Oct 2020 18:00:56 -0400 Subject: [PATCH 40/61] (docs) Clarifications to documentation & UI chapter rename (#3159) * Minor wording tweaks * Rename section --- docs/overview/adopting.md | 12 ++++++------ docs/overview/architecture-overview.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/overview/adopting.md b/docs/overview/adopting.md index 422adfb288..0d404d5cb4 100644 --- a/docs/overview/adopting.md +++ b/docs/overview/adopting.md @@ -42,7 +42,7 @@ infra/platform teams and end-users: ![pop](../assets/pop.png) While anyone at your company can contribute to the platform, the vast majority -of work will be done by teams that also has internal engineers as their +of work will be done by teams that also have internal engineers as their customers. The central team should treat these _contributing teams_ as customers of the platform as well. @@ -51,9 +51,9 @@ customers. This is done primarily by building [plugins](../plugins/index.md). Contributing teams should themselves treat their plugins as, or part of, the products they maintain. -> Case study: Inside Spotify we have a team that owns our CI platform. They -> don't only maintain the pipelines and build servers, but also expose their -> product in Backstage through a plugin. Since they also +> Case study: Inside Spotify we have a team that owns our CI platform. They not +> only maintain the pipelines and build servers, but also expose their product +> in Backstage through a plugin. Since they also > [maintain their own API](../plugins/call-existing-api.md), they can improve > their product by iterating on API and UI in lockstep. Because the plugin > follows our [platform design guidelines](../dls/design.md) their customers get @@ -123,14 +123,14 @@ successful impact on your software development process: engineer is someone that is able to contribute to different domains of engineering. Teams with T-shaped people have fewer bottlenecks and can therefore deliver more consistently. Backstage makes it easier to be T-shaped - since tools and infrastructure is consistent between domains, and information + since tools and infrastructure are consistent between domains, and information is available centrally. - **eNPS** Surveys asking about how productive people feel, how easy it is to find information and overall satisfaction with internal tools. - **Fragmentation** _(Experimental)_ Backstage - [Software Templates](../features/software-templates/index.md) helps drive + [Software Templates](../features/software-templates/index.md) help drive standardization in your software ecosystem. By measuring the variance in technology between different software components it is possible to get a sense of the overall fragmentation in your ecosystem. Examples could include: diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index ee2d3c6097..6f3c6b1a71 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -39,7 +39,7 @@ this. ![The architecture of a basic Backstage application](../assets/architecture-overview/backstage-typical-architecture.png) -## The UI +## User Interface The UI is a thin, client-side wrapper around a set of plugins. It provides some core UI components and libraries for shared activities such as config From 94d351d605ae26cf06aa9c4e3646bf9d85eca48c Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 29 Oct 2020 18:46:17 -0400 Subject: [PATCH 41/61] fix imports --- plugins/cost-insights/src/client.ts | 2 +- .../CostOverviewCard/CostOverviewTooltip.tsx | 2 +- plugins/cost-insights/src/utils/change.ts | 22 +++++++------------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 914fdb7b64..f7671bcce0 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -23,6 +23,7 @@ import { ChangeStatistic, Cost, DateAggregation, + DEFAULT_DATE_FORMAT, Duration, Group, Maybe, @@ -38,7 +39,6 @@ import { UnlabeledDataflowAlert, } from '../src/utils/alerts'; import { - DEFAULT_DATE_FORMAT, exclusiveEndDateOf, inclusiveStartDateOf, } from '../src/utils/duration'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx index 51f2f12bdb..e0a9b9a9ea 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx @@ -17,7 +17,7 @@ import React from 'react'; import moment from 'moment'; import { TooltipPayload, TooltipProps } from 'recharts'; import { Tooltip, TooltipItemProps } from '../../components/Tooltip'; -import { DEFAULT_DATE_FORMAT } from '../../utils/duration'; +import { DEFAULT_DATE_FORMAT } from '../../types'; export type CostOverviewTooltipProps = TooltipProps & { dataKeys: Array; diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index f5c6570b36..1fe3662537 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -14,22 +14,16 @@ * limitations under the License. */ -import { Cost, ChangeStatistic, MetricData } from '../types'; +import { + Cost, + ChangeStatistic, + ChangeThreshold, + EngineerThreshold, + GrowthType, + MetricData, +} from '../types'; import { aggregationSort } from '../utils/sort'; -export const EngineerThreshold = 0.5; - -export enum ChangeThreshold { - upper = 0.05, - lower = -0.05, -} - -export enum GrowthType { - Negligible, - Savings, - Excess, -} - // Used by for displaying status colors export function growthOf(amount: number, ratio: number) { if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { From 1ae2a687f08045e230dc604281e42b42649856f9 Mon Sep 17 00:00:00 2001 From: Mayursinh Sarvaiya Date: Fri, 30 Oct 2020 12:06:55 +0530 Subject: [PATCH 42/61] chore: better position for job stage loader (#3170) --- .../src/components/JobStage/JobStage.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 5128a761ec..8b3fc04de0 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -73,6 +73,18 @@ const useStyles = makeStyles(theme => ({ boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, }, }, + jobStatusTitle: { + display: 'flex', + width: '100%', + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + [theme.breakpoints.down('xs')]: { + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-start', + }, + }, })); type Props = { @@ -118,7 +130,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { className: classes.button, }} > - + {name} {timeElapsed && `(${timeElapsed})`}{' '} {startedAt && !endedAt && } From 16aefb099ed2892eba3919e798c65c7102e6da00 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 29 Oct 2020 20:58:03 +0100 Subject: [PATCH 43/61] Revert "feat: upgrade ts-jest to 26.3.0" This reverts commit 4d66ae31c266b9b1de6b1523306a57c14a58f046. --- packages/cli/package.json | 2 +- yarn.lock | 110 ++++++++------------------------------ 2 files changed, 22 insertions(+), 90 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 00bf463018..668ec5e000 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -96,7 +96,7 @@ "sucrase": "^3.16.0", "tar": "^6.0.1", "terser-webpack-plugin": "^1.4.3", - "ts-jest": "^26.3.0", + "ts-jest": "^26.0.0", "ts-loader": "^7.0.4", "typescript": "^4.0.3", "url-loader": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index 52f797f241..70b224a600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2315,11 +2315,6 @@ slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/create-cache-key-function@^26.5.0": - version "26.5.0" - resolved "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-26.5.0.tgz#1d07947adc51ea17766d9f0ccf5a8d6ea94c47dc" - integrity sha512-DJ+pEBUIqarrbv1W/C39f9YH0rJ4wsXZ/VC6JafJPlHW2HOucKceeaqTOQj9MEDQZjySxMLkOq5mfXZXNZcmWw== - "@jest/environment@^26.5.2": version "26.5.2" resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.5.2.tgz#eba3cfc698f6e03739628f699c28e8a07f5e65fe" @@ -2466,17 +2461,6 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@jest/types@^26.6.1": - version "26.6.1" - resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" - integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^15.0.0" - chalk "^4.0.0" - "@jsdevtools/ono@^7.1.3": version "7.1.3" resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" @@ -5258,14 +5242,6 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" -"@types/jest@26.x": - version "26.0.15" - resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" - integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== - dependencies: - jest-diff "^26.0.0" - pretty-format "^26.0.0" - "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -14374,16 +14350,6 @@ jest-diff@^25.2.1: jest-get-type "^25.2.6" pretty-format "^25.5.0" -jest-diff@^26.0.0: - version "26.6.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" - integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== - dependencies: - chalk "^4.0.0" - diff-sequences "^26.5.0" - jest-get-type "^26.3.0" - pretty-format "^26.6.1" - jest-diff@^26.5.2: version "26.5.2" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.5.2.tgz#8e26cb32dc598e8b8a1b9deff55316f8313c8053" @@ -14661,18 +14627,6 @@ jest-snapshot@^26.5.3: pretty-format "^26.5.2" semver "^7.3.2" -jest-util@^26.1.0: - version "26.6.1" - resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.1.tgz#4cc0d09ec57f28d12d053887eec5dc976a352e9b" - integrity sha512-xCLZUqVoqhquyPLuDXmH7ogceGctbW8SMyQVjD9o+1+NPWI7t0vO08udcFLVPLgKWcvc+zotaUv/RuaR6l8HIA== - dependencies: - "@jest/types" "^26.6.1" - "@types/node" "*" - chalk "^4.0.0" - graceful-fs "^4.2.4" - is-ci "^2.0.0" - micromatch "^4.0.2" - jest-util@^26.5.2: version "26.5.2" resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.5.2.tgz#8403f75677902cc52a1b2140f568e91f8ed4f4d7" @@ -16244,6 +16198,14 @@ microevent.ts@~0.1.1: resolved "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== +micromatch@4.x, micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -16263,14 +16225,6 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -micromatch@^4.0.0, micromatch@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== - dependencies: - braces "^3.0.1" - picomatch "^2.0.5" - miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -18857,16 +18811,6 @@ pretty-format@^25.2.1, pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" -pretty-format@^26.0.0, pretty-format@^26.6.1: - version "26.6.1" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" - integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== - dependencies: - "@jest/types" "^26.6.1" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^17.0.1" - pretty-format@^26.4.2: version "26.4.2" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237" @@ -19552,11 +19496,6 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-i resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: - version "17.0.1" - resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" - integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== - react-lazylog@^4.5.2, react-lazylog@^4.5.3: version "4.5.3" resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" @@ -22800,23 +22739,21 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" -ts-jest@^26.3.0: - version "26.4.3" - resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.4.3.tgz#d153a616033e7ec8544b97ddbe2638cbe38d53db" - integrity sha512-pFDkOKFGY+nL9v5pkhm+BIFpoAuno96ff7GMnIYr/3L6slFOS365SI0fGEVYx2RKGji5M2elxhWjDMPVcOCdSw== +ts-jest@^26.0.0: + version "26.1.1" + resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz#b98569b8a4d4025d966b3d40c81986dd1c510f8d" + integrity sha512-Lk/357quLg5jJFyBQLnSbhycnB3FPe+e9i7ahxokyXxAYoB0q1pPmqxxRPYr4smJic1Rjcf7MXDBhZWgxlli0A== dependencies: - "@jest/create-cache-key-function" "^26.5.0" - "@types/jest" "26.x" bs-logger "0.x" buffer-from "1.x" fast-json-stable-stringify "2.x" - jest-util "^26.1.0" json5 "2.x" lodash.memoize "4.x" make-error "1.x" + micromatch "4.x" mkdirp "1.x" semver "7.x" - yargs-parser "20.x" + yargs-parser "18.x" ts-loader@^7.0.4: version "7.0.4" @@ -24277,10 +24214,13 @@ yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yargs-parser@20.x: - version "20.2.3" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.3.tgz#92419ba867b858c868acf8bae9bf74af0dd0ce26" - integrity sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww== +yargs-parser@18.x, yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" yargs-parser@^10.0.0: version "10.1.0" @@ -24305,14 +24245,6 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - yargs-parser@^20.0.0: version "20.2.1" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.1.tgz#28f3773c546cdd8a69ddae68116b48a5da328e77" From 16d90dca247d8fcaf2ebd04c01f84dc317e2fc7c Mon Sep 17 00:00:00 2001 From: Mayursinh Sarvaiya Date: Fri, 30 Oct 2020 13:40:59 +0530 Subject: [PATCH 44/61] fix: failing windows tests (#3171) --- .../src/scaffolder/stages/prepare/azure.test.ts | 4 ++-- .../src/scaffolder/stages/prepare/github.test.ts | 4 ++-- .../src/scaffolder/stages/prepare/gitlab.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 76dd842368..db3a110384 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -152,8 +152,8 @@ describe('AzurePreparer', () => { workingDirectory: '/workDir', }); - expect(response).toBe( - '/workDir/graphql-starter-static/template/test/1/2/3', + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index af3932d71a..86a870330e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -121,8 +121,8 @@ describe('GitHubPreparer', () => { workingDirectory: '/workDir', }); - expect(response).toBe( - '/workDir/graphql-starter-static/template/test/1/2/3', + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 6be3f790f5..a86a51e0dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -156,8 +156,8 @@ describe('GitLabPreparer', () => { workingDirectory: '/workDir', }); - expect(response).toBe( - '/workDir/graphql-starter-static/template/test/1/2/3', + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, ); }); }); From c0d5242a0dc9315f635447f675ae4dbebe1db238 Mon Sep 17 00:00:00 2001 From: Giuliano Varriale Date: Fri, 30 Oct 2020 09:50:18 +0100 Subject: [PATCH 45/61] fix(StructuredMetadataTable): proper render boolean values (#3162) --- .changeset/popular-ghosts-remain.md | 5 +++++ .../StructuredMetadataTable.stories.tsx | 2 ++ .../StructuredMetadataTable.test.tsx | 22 ++++++++++++++++++- .../StructuredMetadataTable.tsx | 6 ++++- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .changeset/popular-ghosts-remain.md diff --git a/.changeset/popular-ghosts-remain.md b/.changeset/popular-ghosts-remain.md new file mode 100644 index 0000000000..4a170c7618 --- /dev/null +++ b/.changeset/popular-ghosts-remain.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Proper render boolean values on StructuredMetadataTable component diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx index 97591ebab3..fcaa92457f 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx @@ -24,6 +24,8 @@ const metadata = { description: 'This is a long description of what this is doing (and some additional info too). \n It has new lines and extra text to make it especially annoying to render. But it just ignores them.', something: 'Yes', + 'true value': true, + 'false value': false, owner: 'squad', 'longer key name': ['v1', 'v2', 'v3'], rules: { diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx index 68d4262c3e..0844710876 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { render } from '@testing-library/react'; +import { render, within } from '@testing-library/react'; import { startCase } from 'lodash'; import React from 'react'; import { StructuredMetadataTable } from './StructuredMetadataTable'; @@ -70,6 +70,26 @@ describe('', () => { }); }); + it('Supports boolean values', () => { + const metadata = { foo: true, bar: false }; + const expectedValues = [ + ['Foo', '✅'], + ['Bar', '❌'], + ]; + + const { getAllByRole } = render( + , + ); + + getAllByRole('row').forEach((row, index) => { + const [firstCell, secondCell] = within(row).getAllByRole('cell'); + const [expectedKey, expectedValue] = expectedValues[index]; + + expect(firstCell).toHaveTextContent(expectedKey); + expect(secondCell).toHaveTextContent(expectedValue); + }); + }); + it('Supports react elements', () => { const metadata = { react:
field
}; const { getByText } = render( diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index 52ad9a320a..4868af54bd 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -78,7 +78,7 @@ function renderMap( } function toValue( - value: ReactElement | object | Array, + value: ReactElement | object | Array | boolean, options?: any, nested?: boolean, ) { @@ -94,6 +94,10 @@ function toValue( return renderList(value, nested); } + if (typeof value === 'boolean') { + return {value ? '✅' : '❌'}; + } + return {value}; } From da0a79c3b8c64b97bc08fa3db797486e81684fa8 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Fri, 30 Oct 2020 09:52:39 +0100 Subject: [PATCH 46/61] feat: add sonarqube plugin (#3160) --- .../well-known-annotations.md | 16 ++ microsite/data/plugins/sonarqube.yaml | 9 + microsite/static/img/sonarqube-icon.svg | 41 ++++ plugins/sonarqube/.eslintrc.js | 3 + plugins/sonarqube/README.md | 95 ++++++++ plugins/sonarqube/dev/index.tsx | 20 ++ plugins/sonarqube/docs/sonar-card.png | Bin 0 -> 75595 bytes plugins/sonarqube/package.json | 52 ++++ plugins/sonarqube/src/api/index.test.ts | 161 ++++++++++++ plugins/sonarqube/src/api/index.ts | 110 +++++++++ plugins/sonarqube/src/api/types.ts | 55 +++++ .../components/SonarQubeCard/Percentage.tsx | 45 ++++ .../src/components/SonarQubeCard/Rating.tsx | 112 +++++++++ .../components/SonarQubeCard/RatingCard.tsx | 78 ++++++ .../SonarQubeCard/SonarQubeCard.tsx | 230 ++++++++++++++++++ .../src/components/SonarQubeCard/Value.tsx | 34 +++ .../src/components/SonarQubeCard/index.ts | 17 ++ plugins/sonarqube/src/components/index.ts | 17 ++ .../sonarqube/src/components/useProjectKey.ts | 23 ++ plugins/sonarqube/src/index.ts | 18 ++ plugins/sonarqube/src/plugin.test.ts | 23 ++ plugins/sonarqube/src/plugin.ts | 38 +++ plugins/sonarqube/src/setupTests.ts | 17 ++ 23 files changed, 1214 insertions(+) create mode 100644 microsite/data/plugins/sonarqube.yaml create mode 100644 microsite/static/img/sonarqube-icon.svg create mode 100644 plugins/sonarqube/.eslintrc.js create mode 100644 plugins/sonarqube/README.md create mode 100644 plugins/sonarqube/dev/index.tsx create mode 100644 plugins/sonarqube/docs/sonar-card.png create mode 100644 plugins/sonarqube/package.json create mode 100644 plugins/sonarqube/src/api/index.test.ts create mode 100644 plugins/sonarqube/src/api/index.ts create mode 100644 plugins/sonarqube/src/api/types.ts create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/Value.tsx create mode 100644 plugins/sonarqube/src/components/SonarQubeCard/index.ts create mode 100644 plugins/sonarqube/src/components/index.ts create mode 100644 plugins/sonarqube/src/components/useProjectKey.ts create mode 100644 plugins/sonarqube/src/index.ts create mode 100644 plugins/sonarqube/src/plugin.test.ts create mode 100644 plugins/sonarqube/src/plugin.ts create mode 100644 plugins/sonarqube/src/setupTests.ts diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 40a4ca3ea1..47b1d5885d 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -173,6 +173,22 @@ The value of these annotations are the corresponding attributes that were found when ingestion the entity from LDAP. Not all of them may be present, depending on what attributes that the server presented at ingestion time. +### sonarqube.org/project-key + +```yaml +# Example: +metadata: + annotations: + sonarqube.org/project-key: pump-station +``` + +The value of this annotation is the project key of a +[SonarQube](https://sonarqube.org) or [SonarCloud](https://sonarcloud.io) +project within your organization. + +Specifying this annotation may enable SonarQube related features in Backstage +for that entity. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in diff --git a/microsite/data/plugins/sonarqube.yaml b/microsite/data/plugins/sonarqube.yaml new file mode 100644 index 0000000000..a2982a4a58 --- /dev/null +++ b/microsite/data/plugins/sonarqube.yaml @@ -0,0 +1,9 @@ +--- +title: SonarQube +author: SDA SE +authorUrl: https://sda.se/ +category: Quality +description: Components to display code quality metrics from SonarCloud and SonarQube. +documentation: https://github.com/spotify/backstage/blob/master/plugins/sonarqube/README.md +iconUrl: img/sonarqube-icon.svg +npmPackageName: '@backstage/plugin-sonarqube' diff --git a/microsite/static/img/sonarqube-icon.svg b/microsite/static/img/sonarqube-icon.svg new file mode 100644 index 0000000000..d3de342b38 --- /dev/null +++ b/microsite/static/img/sonarqube-icon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/sonarqube/.eslintrc.js b/plugins/sonarqube/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/sonarqube/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md new file mode 100644 index 0000000000..3d3c1f725b --- /dev/null +++ b/plugins/sonarqube/README.md @@ -0,0 +1,95 @@ +# SonarQube Plugin + +The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarcloud.io) or [SonarQube](https://sonarqube.com). + +![Sonar Card](./docs/sonar-card.png) + +## Getting Started + +1. Install the SonarQube Plugin: + +```bash +yarn add @backstage/plugin-sonarqube +``` + +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; +``` + +3. Add the `SonarQubeCard` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { SonarQubeCard } from '@backstage/plugin-sonarqube'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +4. Add the proxy config: + +**SonarCloud** + +```yaml +// app-config.yaml + +proxy: + '/sonarqube': + target: https://sonarcloud.io/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Basic base64(":")' <-- note the trailing ':' + # Example: Basic bXktYXBpLWtleTo= + $env: SONARQUBE_AUTH_HEADER +``` + +**SonarQube** + +```yaml +// app-config.yaml + +proxy: + '/sonarqube': + target: https://your.sonarqube.instance.com/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Basic base64(":")' <-- note the trailing ':' + # Example: Basic bXktYXBpLWtleTo= + $env: SONARQUBE_AUTH_HEADER + +sonarQube: + baseUrl: https://your.sonarqube.instance.com +``` + +5. Get and provide `SONARQUBE_AUTH_HEADER` as env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) + +6. Add the `sonarqube.org/project-key` annotation to your component-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + sonarqube.org/project-key: YOUR_PROJECT_KEY +spec: + type: library + owner: Spotify + lifecycle: experimental +``` diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/sonarqube/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/sonarqube/docs/sonar-card.png b/plugins/sonarqube/docs/sonar-card.png new file mode 100644 index 0000000000000000000000000000000000000000..cbab02edd63b1598b05981bdc0d42e506b97569a GIT binary patch literal 75595 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�&QZgZR|qRWnyVNSL~NZ7*3m}bwjs4*t?tWPY1?i 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 { + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const setupHandlers = () => { + server.use( + rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe('component=our-service'); + return res( + ctx.json({ + component: { + analysisDate: '2020-01-01T00:00:00Z', + }, + } as ComponentWrapper), + ); + }), + ); + + server.use( + rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', + ); + return res( + ctx.json({ + measures: [ + { + metric: 'alert_status', + value: 'OK', + component: 'our-service', + }, + { + metric: 'alert_status', + value: 'ERROR', + component: 'other-service', + }, + { + metric: 'bugs', + value: '2', + component: 'our-service', + }, + { + metric: 'reliability_rating', + value: '3.0', + component: 'our-service', + }, + { + metric: 'vulnerabilities', + value: '4', + component: 'our-service', + }, + { + metric: 'security_rating', + value: '1.0', + component: 'our-service', + }, + { + metric: 'code_smells', + value: '100', + component: 'our-service', + }, + { + metric: 'sqale_rating', + value: '2.0', + component: 'our-service', + }, + { + metric: 'coverage', + value: '55.5', + component: 'our-service', + }, + { + metric: 'duplicated_lines_density', + value: '1.0', + component: 'our-service', + }, + ], + } as MeasuresWrapper), + ); + }), + ); + }; + + it('should report finding summary', async () => { + setupHandlers(); + + const client = new SonarQubeApi({ discoveryApi }); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + reliability_rating: '3.0', + vulnerabilities: '4', + security_rating: '1.0', + code_smells: '100', + sqale_rating: '2.0', + coverage: '55.5', + duplicated_lines_density: '1.0', + }, + projectUrl: 'https://sonarcloud.io/dashboard?id=our-service', + } as FindingSummary); + }); + + it('should report finding summary (custom baseUrl)', async () => { + setupHandlers(); + + const client = new SonarQubeApi({ + discoveryApi, + baseUrl: 'http://a.instance.local', + }); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + reliability_rating: '3.0', + vulnerabilities: '4', + security_rating: '1.0', + code_smells: '100', + sqale_rating: '2.0', + coverage: '55.5', + duplicated_lines_density: '1.0', + }, + projectUrl: 'http://a.instance.local/dashboard?id=our-service', + } as FindingSummary); + }); +}); diff --git a/plugins/sonarqube/src/api/index.ts b/plugins/sonarqube/src/api/index.ts new file mode 100644 index 0000000000..01f6f74fe3 --- /dev/null +++ b/plugins/sonarqube/src/api/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef, DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { ComponentWrapper, MeasuresWrapper, MetricKey } from './types'; + +/** + * Define a type to make sure that all metrics are used + */ +type Metrics = { + [key in MetricKey]: string | undefined; +}; + +export interface FindingSummary { + lastAnalysis: string; + metrics: Metrics; + projectUrl: string; +} + +export const sonarQubeApiRef = createApiRef({ + id: 'plugin.sonarqube.service', + description: 'Used by the SonarQube plugin to make requests', +}); + +export class SonarQubeApi { + discoveryApi: DiscoveryApi; + baseUrl: string; + + constructor({ + discoveryApi, + baseUrl = 'https://sonarcloud.io/', + }: { + discoveryApi: DiscoveryApi; + baseUrl?: string; + }) { + this.discoveryApi = discoveryApi; + this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + } + + private async callApi(path: string): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`; + const response = await fetch(`${apiUrl}/${path}`); + if (response.status === 200) { + return (await response.json()) as T; + } + return undefined; + } + + async getFindingSummary( + componentKey?: string, + ): Promise { + if (!componentKey) { + return undefined; + } + + const component = await this.callApi( + `components/show?component=${componentKey}`, + ); + if (!component) { + return undefined; + } + + const metrics: Metrics = { + alert_status: undefined, + bugs: undefined, + reliability_rating: undefined, + vulnerabilities: undefined, + security_rating: undefined, + code_smells: undefined, + sqale_rating: undefined, + coverage: undefined, + duplicated_lines_density: undefined, + }; + + const measures = await this.callApi( + `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys( + metrics, + ).join(',')}`, + ); + if (!measures) { + return undefined; + } + + measures.measures + .filter(m => m.component === componentKey) + .forEach(m => { + metrics[m.metric] = m.value; + }); + + return { + lastAnalysis: component.component.analysisDate, + metrics, + projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`, + }; + } +} diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts new file mode 100644 index 0000000000..17539070cb --- /dev/null +++ b/plugins/sonarqube/src/api/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ComponentWrapper { + component: Component; +} + +export interface Component { + analysisDate: string; +} + +export interface MeasuresWrapper { + measures: Measure[]; +} + +export type MetricKey = + // alert status + | 'alert_status' + + // bugs and rating (-> reliability) + | 'bugs' + | 'reliability_rating' + + // vulnerabilities and rating (-> security) + | 'vulnerabilities' + | 'security_rating' + + // code smells and rating (-> maintainability) + | 'code_smells' + | 'sqale_rating' + + // coverage + | 'coverage' + + // duplicated lines + | 'duplicated_lines_density'; + +export interface Measure { + metric: MetricKey; + value: string; + component: string; +} diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx new file mode 100644 index 0000000000..9d1fc415ff --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core/styles'; +import { useTheme } from '@material-ui/styles'; +import { Circle } from 'rc-progress'; +import React from 'react'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + height: theme.spacing(3), + width: theme.spacing(3), + }, +})); + +export const Percentage = ({ value }: { value?: string }) => { + const classes = useStyles(); + const theme = useTheme(); + + return ( + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx new file mode 100644 index 0000000000..df90e2b821 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx @@ -0,0 +1,112 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageTheme } from '@backstage/theme'; +import { Avatar } from '@material-ui/core'; +import { lighten, makeStyles } from '@material-ui/core/styles'; +import { CSSProperties } from '@material-ui/styles'; +import React, { useMemo } from 'react'; + +const useStyles = makeStyles((theme: BackstageTheme) => { + const commonCardRating: CSSProperties = { + height: theme.spacing(3), + width: theme.spacing(3), + color: theme.palette.common.white, + }; + + return { + ratingDefault: { + ...commonCardRating, + background: theme.palette.status.aborted, + }, + ratingA: { + ...commonCardRating, + background: theme.palette.status.ok, + }, + ratingB: { + ...commonCardRating, + background: lighten(theme.palette.status.ok, 0.5), + }, + ratingC: { + ...commonCardRating, + background: theme.palette.status.pending, + }, + ratingD: { + ...commonCardRating, + background: theme.palette.status.warning, + }, + ratingE: { + ...commonCardRating, + background: theme.palette.error.main, + }, + }; +}); + +export const Rating = ({ + rating, + hideValue, +}: { + rating?: string; + hideValue?: boolean; +}) => { + const classes = useStyles(); + + const ratingProp = useMemo(() => { + switch (rating) { + case '1.0': + return { + name: 'A', + className: classes.ratingA, + }; + + case '2.0': + return { + name: 'B', + className: classes.ratingB, + }; + + case '3.0': + return { + name: 'C', + className: classes.ratingC, + }; + + case '4.0': + return { + name: 'D', + className: classes.ratingD, + }; + + case '5.0': + return { + name: 'E', + className: classes.ratingE, + }; + + default: + return { + name: '', + className: classes.ratingDefault, + }; + } + }, [classes, rating]); + + return ( + + {!hideValue && ratingProp.name} + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx new file mode 100644 index 0000000000..cd678b7366 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { ReactNode } from 'react'; + +const useStyles = makeStyles(theme => { + return { + root: { + margin: theme.spacing(1, 0), + }, + upper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + cardTitle: { + textAlign: 'center', + }, + wrapIcon: { + display: 'inline-flex', + verticalAlign: 'baseline', + }, + left: { + display: 'flex', + }, + right: { + display: 'flex', + marginLeft: theme.spacing(0.5), + }, + }; +}); + +export const RatingCard = ({ + leftSlot, + rightSlot, + title, + titleIcon, +}: { + leftSlot: ReactNode; + rightSlot: ReactNode; + title: string; + titleIcon?: ReactNode; +}) => { + const classes = useStyles(); + + return ( + + + + {leftSlot} + + + {rightSlot} + + + + + {titleIcon} {title} + + + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx new file mode 100644 index 0000000000..8e6cc817a1 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -0,0 +1,230 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { + EmptyState, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import { Chip, Grid } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import BugReport from '@material-ui/icons/BugReport'; +import LockOpen from '@material-ui/icons/LockOpen'; +import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied'; +import React, { useMemo } from 'react'; +import { useAsync } from 'react-use'; +import { sonarQubeApiRef } from '../../api'; +import { + SONARQUBE_PROJECT_KEY_ANNOTATION, + useProjectKey, +} from '../useProjectKey'; +import { Percentage } from './Percentage'; +import { Rating } from './Rating'; +import { RatingCard } from './RatingCard'; +import { Value } from './Value'; + +const useStyles = makeStyles(theme => ({ + badgeLabel: { + color: theme.palette.common.white, + }, + badgeError: { + margin: 0, + backgroundColor: theme.palette.error.main, + }, + badgeSuccess: { + margin: 0, + backgroundColor: theme.palette.success.main, + }, + header: { + padding: theme.spacing(2, 2, 2, 2.5), + }, + action: { + margin: 0, + }, + lastAnalyzed: { + color: theme.palette.text.secondary, + }, + disabled: { + backgroundColor: theme.palette.background.default, + }, +})); + +interface DuplicationRating { + greaterThan: number; + rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0'; +} + +const defaultDuplicationRatings: DuplicationRating[] = [ + { greaterThan: 0, rating: '1.0' }, + { greaterThan: 3, rating: '2.0' }, + { greaterThan: 5, rating: '3.0' }, + { greaterThan: 10, rating: '4.0' }, + { greaterThan: 20, rating: '5.0' }, +]; + +export const SonarQubeCard = ({ + entity, + variant = 'gridItem', + duplicationRatings = defaultDuplicationRatings, +}: { + entity: Entity; + variant?: string; + duplicationRatings?: DuplicationRating[]; +}) => { + const sonarQubeApi = useApi(sonarQubeApiRef); + + const projectTitle = useProjectKey(entity); + + const { value, loading } = useAsync( + async () => sonarQubeApi.getFindingSummary(projectTitle), + [sonarQubeApi, projectTitle], + ); + + const deepLink = + !loading && value + ? { + title: 'View more', + link: value.projectUrl, + } + : undefined; + + const gatePassed = value && value.metrics.alert_status === 'OK'; + + const classes = useStyles(); + + const qualityBadge = !loading && value && ( + + ); + + const duplicationRating = useMemo(() => { + if (loading || !value || !value.metrics.duplicated_lines_density) { + return ''; + } + + let rating = ''; + + for (const r of duplicationRatings) { + if (+value.metrics.duplicated_lines_density >= r.greaterThan) { + rating = r.rating; + } + } + + return rating; + }, [loading, value, duplicationRatings]); + + return ( + + {loading && } + + {!loading && !projectTitle && ( + + )} + + {!loading && projectTitle && !value && ( + + )} + + {!loading && value && ( + <> + + + } + title="Bugs" + leftSlot={} + rightSlot={} + /> + } + title="Vulnerabilities" + leftSlot={} + rightSlot={} + /> + } + title="Code Smells" + leftSlot={} + rightSlot={} + /> +
+ } + rightSlot={} + /> + } + rightSlot={ + + } + /> + + + Last analyzed on{' '} + {new Date(value.lastAnalysis).toLocaleString('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + + )} + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx new file mode 100644 index 0000000000..e7a5492e68 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; + +const useStyles = makeStyles((theme: BackstageTheme) => { + return { + value: { + fontSize: '1.5rem', + fontWeight: theme.typography.fontWeightMedium, + }, + }; +}); + +export const Value = ({ value }: { value?: string }) => { + const classes = useStyles(); + + return {value}; +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/index.ts b/plugins/sonarqube/src/components/SonarQubeCard/index.ts new file mode 100644 index 0000000000..e349f1e1f9 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { SonarQubeCard } from './SonarQubeCard'; diff --git a/plugins/sonarqube/src/components/index.ts b/plugins/sonarqube/src/components/index.ts new file mode 100644 index 0000000000..8f0786bb8e --- /dev/null +++ b/plugins/sonarqube/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './SonarQubeCard'; diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts new file mode 100644 index 0000000000..dcff79d972 --- /dev/null +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; + +export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key'; + +export const useProjectKey = (entity: Entity) => { + return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? ''; +}; diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..f09aeb1038 --- /dev/null +++ b/plugins/sonarqube/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './components'; +export { plugin } from './plugin'; diff --git a/plugins/sonarqube/src/plugin.test.ts b/plugins/sonarqube/src/plugin.test.ts new file mode 100644 index 0000000000..32730b64c3 --- /dev/null +++ b/plugins/sonarqube/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { plugin } from './plugin'; + +describe('sonarqube', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts new file mode 100644 index 0000000000..154d4ee3b8 --- /dev/null +++ b/plugins/sonarqube/src/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, +} from '@backstage/core'; +import { SonarQubeApi, sonarQubeApiRef } from './api'; + +export const plugin = createPlugin({ + id: 'sonarqube', + apis: [ + createApiFactory({ + api: sonarQubeApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new SonarQubeApi({ + discoveryApi, + baseUrl: configApi.getOptionalString('sonarQube.baseUrl'), + }), + }), + ], +}); diff --git a/plugins/sonarqube/src/setupTests.ts b/plugins/sonarqube/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/sonarqube/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; From 801e5e55cc2668fd7979a8b885ab404804a9da6a Mon Sep 17 00:00:00 2001 From: Abhishek Jakhar Date: Fri, 30 Oct 2020 14:46:53 +0530 Subject: [PATCH 47/61] chore: remove redundant functions from microsite footer (#3174) --- microsite/core/Footer.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 1bebf2f399..d41b15b74c 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -17,19 +17,6 @@ const React = require('react'); class Footer extends React.Component { - docUrl(doc, language) { - const baseUrl = this.props.config.baseUrl; - const docsUrl = this.props.config.docsUrl; - const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; - const langPart = `${language ? `${language}/` : ''}`; - return `${baseUrl}${docsPart}${langPart}${doc}`; - } - - pageUrl(doc, language) { - const baseUrl = this.props.config.baseUrl; - return baseUrl + (language ? `${language}/` : '') + doc; - } - render() { return (