diff --git a/.changeset/2863.md b/.changeset/2863.md deleted file mode 100644 index 729e3aab7b..0000000000 --- a/.changeset/2863.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@backstage/core-api': patch ---- - -Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the -default scope is configurable when overwriting the Core Api in the app. - -``` -GithubAuth.create({ - discoveryApi, - oauthRequestApi, - defaultScopes: ['read:user', 'repo'], -}), -``` - -Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type. - -``` -export type OAuthApiCreateOptions = AuthApiCreateOptions & { - oauthRequestApi: OAuthRequestApi; - defaultScopes?: string[]; -}; - -export type AuthApiCreateOptions = { - discoveryApi: DiscoveryApi; - environment?: string; - provider?: AuthProvider & { id: string }; -}; -``` diff --git a/.changeset/3157.md b/.changeset/3157.md deleted file mode 100644 index f15d05c46a..0000000000 --- a/.changeset/3157.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@backstage/core': patch ---- - -Adds the MarkdownContent component to render and display Markdown content with the default -[GFM](https://github.github.com/gfm/) (Github flavored Markdown) dialect. - -``` - -``` - -To render the Markdown content with plain [CommonMark](https://commonmark.org/), set the dialect to `common-mark` - -``` -` component to `@backstage/core`. diff --git a/.changeset/groups_profile.png b/.changeset/groups_profile.png new file mode 100644 index 0000000000..417faaa4af Binary files /dev/null and b/.changeset/groups_profile.png differ diff --git a/.changeset/grumpy-elephants-press.md b/.changeset/grumpy-elephants-press.md new file mode 100644 index 0000000000..d85d36e609 --- /dev/null +++ b/.changeset/grumpy-elephants-press.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/integration': patch +--- + +Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl diff --git a/.changeset/heavy-ducks-raise.md b/.changeset/heavy-ducks-raise.md new file mode 100644 index 0000000000..71a16da512 --- /dev/null +++ b/.changeset/heavy-ducks-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Ignore relations when comparing entities. This stops the refresh loop from rewriting entities over and over. diff --git a/.changeset/lucky-lemons-return.md b/.changeset/lucky-lemons-return.md deleted file mode 100644 index 49125d31d4..0000000000 --- a/.changeset/lucky-lemons-return.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Fix missing api-docs plugin registration in app template diff --git a/.changeset/moody-pugs-smoke.md b/.changeset/moody-pugs-smoke.md deleted file mode 100644 index 3092ec4bfe..0000000000 --- a/.changeset/moody-pugs-smoke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-user-settings': patch ---- - -Export `AuthProviders`, `DefaultProviderSettings` and `ProviderSettingsItem`. diff --git a/.changeset/nice-trains-punch.md b/.changeset/nice-trains-punch.md new file mode 100644 index 0000000000..71225ac97c --- /dev/null +++ b/.changeset/nice-trains-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-pagerduty': patch +--- + +Supporting Timezones diff --git a/.changeset/odd-mugs-build.md b/.changeset/odd-mugs-build.md new file mode 100644 index 0000000000..bf95d23410 --- /dev/null +++ b/.changeset/odd-mugs-build.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Support `profile` of groups including `displayName` and `picture` in +`GithubOrgReaderProcessor`. Fixes the import of `description` for groups. diff --git a/.changeset/odd-parents-push.md b/.changeset/odd-parents-push.md deleted file mode 100644 index 5d997b7a45..0000000000 --- a/.changeset/odd-parents-push.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Persist table state of the API Explorer to the browser history. This allows to navigate between pages and come back to the previous filter state. diff --git a/.changeset/purple-cycles-switch.md b/.changeset/purple-cycles-switch.md new file mode 100644 index 0000000000..c7594e4254 --- /dev/null +++ b/.changeset/purple-cycles-switch.md @@ -0,0 +1,26 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Write relations directly as part of batch add / update of entities. + +Slight change of the `CommonDatabase` contract: + +## `addEntity` removed + +This method was unused by the core, and rendered unnecessary when `addEntities` +exists. + +If you were a user of `addEntity`, please call `addEntities` instead, with an +array of one element. + +## `DbEntityRequest` has a new field `relations` + +This is the structure that is passed to `addEntities` and `updateEntity`. It +used to be the case that you needed to call `setRelations` separately, but now +this instead happens directly when you call `addEntities` or `updateEntity`. + +If you were using `addEntities` or `updateEntity` directly, please adapt your +code to add the `relations` array to each request. If you were calling +`setRelations` separately next to these methods, you no longer need to do so, +after adding the relations to the `DbEntityRequest`s. diff --git a/.changeset/rare-peas-accept.md b/.changeset/rare-peas-accept.md new file mode 100644 index 0000000000..74597dda11 --- /dev/null +++ b/.changeset/rare-peas-accept.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Batch the writing of statuses after refreshes. This reduced the runtime on sqlite from 16s to 0.2s, and on pg from 60s to 1s on my machine, for the huge LDAP set. diff --git a/.changeset/rotten-pandas-draw.md b/.changeset/rotten-pandas-draw.md new file mode 100644 index 0000000000..85b3feeaec --- /dev/null +++ b/.changeset/rotten-pandas-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Add index to foreign key columns. Postgres (and others) do not do this on the "source" side of a foreign key relation, which was what led to the slowness on large datasets. The full LDAP dataset ingestion now takes two minutes, which is not optimal yet but still a huge improvement over before when it basically never finished :) diff --git a/.changeset/seven-monkeys-run.md b/.changeset/seven-monkeys-run.md deleted file mode 100644 index 7e66daa86f..0000000000 --- a/.changeset/seven-monkeys-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Make the selected state of Select and CheckboxTree controllable from outside. diff --git a/.changeset/shaggy-bobcats-warn.md b/.changeset/shaggy-bobcats-warn.md deleted file mode 100644 index 048e1a0ff6..0000000000 --- a/.changeset/shaggy-bobcats-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': minor ---- - -New DependencyGraph component added to core package. diff --git a/.changeset/shiny-grapes-tan.md b/.changeset/shiny-grapes-tan.md new file mode 100644 index 0000000000..b7361bb970 --- /dev/null +++ b/.changeset/shiny-grapes-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Validate that integration config contains a valid host diff --git a/.changeset/silent-dodos-repeat.md b/.changeset/silent-dodos-repeat.md deleted file mode 100644 index 04978406f3..0000000000 --- a/.changeset/silent-dodos-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Fix divider prop not respected on InfoCard diff --git a/.changeset/six-mugs-camp.md b/.changeset/six-mugs-camp.md new file mode 100644 index 0000000000..373edd340a --- /dev/null +++ b/.changeset/six-mugs-camp.md @@ -0,0 +1,20 @@ +--- +'@backstage/create-app': patch +--- + +In the techdocs-backend plugin (`packages/backend/src/plugins/techdocs.ts`), create a publisher using + +``` + const publisher = Publisher.fromConfig(config, logger, discovery); +``` + +instead of + +``` + const publisher = new LocalPublish(logger, discovery); +``` + +An instance of `publisher` can either be a local filesystem publisher or a Google Cloud Storage publisher. + +Read more about the configs here https://backstage.io/docs/features/techdocs/configuration +(You will also have to update `techdocs.storage.type` to `local` or `googleGcs`. And `techdocs.builder` to either `local` or `external`.) diff --git a/.changeset/smooth-hairs-breathe.md b/.changeset/smooth-hairs-breathe.md deleted file mode 100644 index 2849a1d4b7..0000000000 --- a/.changeset/smooth-hairs-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-tech-radar': patch ---- - -Make the footer color of the tech-radar work in both light and dark theme. diff --git a/.changeset/spicy-bananas-help.md b/.changeset/spicy-bananas-help.md deleted file mode 100644 index 6d847f4bc3..0000000000 --- a/.changeset/spicy-bananas-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Upgrade @kyma-project/asyncapi-react to 0.14.2 diff --git a/.changeset/strong-moose-cough.md b/.changeset/strong-moose-cough.md deleted file mode 100644 index 41a0da7cca..0000000000 --- a/.changeset/strong-moose-cough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/theme': patch ---- - -Improve styling of outlined chips in dark mode. diff --git a/.changeset/swift-sheep-ring.md b/.changeset/swift-sheep-ring.md new file mode 100644 index 0000000000..f4cf92c29d --- /dev/null +++ b/.changeset/swift-sheep-ring.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Re-enable symlink resolution during bundling, and switch to using a resolve plugin for external linked packages. diff --git a/.changeset/tender-coats-unite.md b/.changeset/tender-coats-unite.md new file mode 100644 index 0000000000..018660bcfa --- /dev/null +++ b/.changeset/tender-coats-unite.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-org': patch +--- + +Display the new `profile` fields (`displayName`, `email`, and `picture`) for +groups on the `GroupProfileCard`. + +![Groups Profile Section](./groups_profile.png) + +This also resolves some cases where `profile` fields are missing for users or +groups and for example falls back to displaying the entity name. Adds additional test data to the ACME Corp dataset. diff --git a/.changeset/thick-lobsters-swim.md b/.changeset/thick-lobsters-swim.md deleted file mode 100644 index 3a377b7535..0000000000 --- a/.changeset/thick-lobsters-swim.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Improved the edit link to open the component yaml in edit mode in corresponding SCM. Broke out logic for createEditLink to be reused. diff --git a/.changeset/weak-otters-love.md b/.changeset/weak-otters-love.md deleted file mode 100644 index 2c273e067b..0000000000 --- a/.changeset/weak-otters-love.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@backstage/plugin-scaffolder': minor -'@backstage/plugin-scaffolder-backend': minor ---- - -`createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. - -```js -import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; -import { SingleHostDiscovery } from '@backstage/backend-common'; - -const discovery = SingleHostDiscovery.fromConfig(config); -const entityClient = new CatalogEntityClient({ discovery }); -``` - -- Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. diff --git a/.changeset/weak-rice-beam.md b/.changeset/weak-rice-beam.md deleted file mode 100644 index c507e350a9..0000000000 --- a/.changeset/weak-rice-beam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Capture plugin name under the /api/ prefix for http metrics diff --git a/.changeset/weak-sloths-prove.md b/.changeset/weak-sloths-prove.md new file mode 100644 index 0000000000..bfecb385c8 --- /dev/null +++ b/.changeset/weak-sloths-prove.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added configuration schema for the commonly used properties diff --git a/.changeset/wet-garlics-shave.md b/.changeset/wet-garlics-shave.md new file mode 100644 index 0000000000..86b42541eb --- /dev/null +++ b/.changeset/wet-garlics-shave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sentry': patch +--- + +Added configuration schema for the commonly used properties diff --git a/.changeset/witty-scissors-divide.md b/.changeset/witty-scissors-divide.md new file mode 100644 index 0000000000..f288088ef2 --- /dev/null +++ b/.changeset/witty-scissors-divide.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Support `profile` of groups including `displayName` and `email` in +`MicrosoftGraphOrgReaderProcessor`. Importing `picture` doesn't work yet, as +the Microsoft Graph API does not expose them correctly. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d818c0d6b5..495f1ff78d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /docs/features/techdocs @backstage/techdocs-core /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios +/plugins/search @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 3c5fa34adc..3b7e2a9e2d 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,4 +1,5 @@ abc +andrewthauer Apdex api Api @@ -9,21 +10,25 @@ async Avro backrub Balachandran +benjdlambert Bigtable +Billett Blackbox bool boolean +builtins Chai changeset changesets Changesets -changset chanwit Chanwit +ci cisphobia cissexist classname cli +cloudbuild cncf codeblocks Codecov @@ -38,6 +43,7 @@ const cookiecutter css dariddler +dataflow deadnaming destructured dev @@ -50,6 +56,7 @@ Dockerfile Dockerize dockerode Docusaurus +dzolotusky eg Ek env @@ -59,13 +66,17 @@ facto failover Figma Firekube +Fiverr +freben Fredrik github -Github +GitHub gitlab -Gitlab +GitLab +Grafana graphql graphviz +Gustavsson Hackathons haproxy heroku @@ -74,8 +85,10 @@ horizontalpodautoscalers Hostname http https +Iain img incentivised +inlined inlinehilite interop javascript @@ -92,8 +105,8 @@ learnings lerna Lerna magiclink -Maintainership mailto +maintainership Malus md microsite @@ -110,6 +123,7 @@ msw namespace namespaces Namespaces +namespacing neuro newrelic nginx @@ -120,6 +134,7 @@ npm nvm oauth Oauth +oidc Okta Oldsberg onboarding @@ -158,28 +173,36 @@ Rollup Rosaceae rst rsync +rugvip ruleset sam scaffolded scaffolder Scaffolder semlas +semver Serverless Sinon smartsymobls +Snyk sparklines Spotifiers spotify Spotify +sqlite squidfunk src +stefanalund subkey +subtree superfences Superfences +superset talkdesk Talkdesk tasklist techdocs +Telenor templated templater Templater @@ -190,10 +213,13 @@ theres toc tolerations Tolerations +toolchain toolsets tooltip +tooltips touchpoints ui +untracked upvote url utils @@ -202,14 +228,11 @@ Voi Wealthsimple Weaveworks Webpack +www +WWW xyz yaml Zalando Zhou -Billett -cloudbuild -Grafana -Iain -Snyk -www -WWW +Zolotusky +zoomable diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index a557b4921b..4bd03b4d96 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -19,4 +19,4 @@ jobs: # Calls out to `changeset version`, but also runs prettier version: yarn release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75c6a13cd2..c35e2a88ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: - name: prettier run: yarn prettier:check + - name: validate config + run: yarn backstage-cli config:check + - name: lint run: yarn lerna -- run lint --since origin/master diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index 570d857c48..1844700cea 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -32,7 +32,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v1.0.1 + uses: microsoft/setup-msbuild@v1.0.2 - name: yarn install run: yarn install --frozen-lockfile diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f5029bf836..ca63e627e9 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -47,6 +47,9 @@ jobs: run: yarn install --frozen-lockfile # End of yarn setup + - name: validate config + run: yarn backstage-cli config:check + - name: lint run: yarn lerna -- run lint diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3f59b815b3..0dc40d2c40 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -62,7 +62,7 @@ jobs: - name: prepare nightly release run: yarn changeset version --snapshot nightly - # Publishes the nightly release to NPM, by using tag we make sure the release is + # Publishes the nightly release to npm, by using tag we make sure the release is # not flagged as the latest release, which means that people will not get this # version of the package unless requested explicitly - name: publish nightly release diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index a0691f2a79..679abe6536 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -1,6 +1,7 @@ name: Automatically add new TechDocs Issues and PRs to the GitHub project board -# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/backstage/backstage/projects/5 -# New issues with TechDocs in their title or docs-like-code label will be added to the board. +# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1 +# New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board. +# Caveat: New PRs created from forks will not be added since GitHub Actions don't share credentials with forks. on: issues: @@ -9,7 +10,7 @@ on: types: [opened, reopened, labeled, edited] env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} jobs: assign_issue_or_pr_to_project: @@ -23,7 +24,7 @@ jobs: contains(github.event.issue.title, 'techdocs') || contains(github.event.issue.title, 'Techdocs') with: - project: 'https://github.com/backstage/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new issue to Incoming based on its label. @@ -31,7 +32,7 @@ jobs: if: | contains(github.event.issue.labels.*.name, 'docs-like-code') with: - project: 'https://github.com/backstage/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new PR to Incoming based on its title. @@ -41,7 +42,7 @@ jobs: contains(github.event.pull_request.title, 'techdocs') || contains(github.event.pull_request.title, 'Techdocs') with: - project: 'https://github.com/backstage/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new PR to Incoming based on its label. @@ -49,5 +50,5 @@ jobs: if: | contains(github.event.pull_request.labels.*.name, 'docs-like-code') with: - project: 'https://github.com/backstage/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' diff --git a/.gitignore b/.gitignore index 5c27601791..3334bf956d 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ typings/ .nuxt dist dist-types +dist-workspace # Gatsby files .cache/ diff --git a/.yarnrc b/.yarnrc index 6b56b971d7..4cdaac2408 100644 --- a/.yarnrc +++ b/.yarnrc @@ -6,4 +6,4 @@ registry "https://registry.npmjs.org/" disable-self-update-check true lastUpdateCheck 1580389148099 yarn-path ".yarn/releases/yarn-1.22.1.js" -network-timeout 600000 +network-timeout 300000 diff --git a/ADOPTERS.md b/ADOPTERS.md index 0ba3e3196b..304512ee7e 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,15 +1,18 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| Organization | Contact | Description of Use | +| -------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac33ca7f8..9df533bb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,9 @@ # Backstage Changelog -This is a best-effort changelog where we manually collect breaking changes. It is not an exhaustive list of all changes or even features added. +This changelog is no longer being updated and will be removed in the future, as each package now has its own changelog instead. It was a best-effort changelog where we manually collected breaking changes during the `v0.1.1-alpha.` releases. If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! -## Next Release - -> Collect changes for the next release below - ## v0.1.1-alpha.26 ### @backstage/cli diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 19bbb3bcb9..0ab8d8390d 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,52 +1,45 @@ -# Backstage Governance +# Process for becoming a maintainer -This document defines project governance for the project. +## a) Your organization is not yet a maintainer -## Maintainers +- Express interest to the sponsors that your organization is interested in becoming a maintainer. Becoming a maintainer generally means that you are going to be spending substantial time on Backstage for the foreseeable future. You should have domain expertise and be extremely proficient in TypeScript. +- We will expect you to start contributing increasingly complicated PRs, under the guidance of the existing maintainers. +- We may ask you to do some PRs from our backlog. +- As you gain experience with the code base and our standards, we will ask you to do code reviews for incoming PRs. +- After a period of approximately 2-3 months of working together and making sure we see eye to eye, the existing sponsors and maintainers will confer and decide whether to grant maintainer status or not. We make no guarantees on the length of time this will take, but 2-3 months is the approximate goal. -Backstage Maintainers have write access to the Backstage GitHub repository https://github.com/backstage/backstage. The current maintainers can be found in [MAINTAINERS](MAINTAINERS.md). - -This privilege is granted with some expectation of responsibility: maintainers are people who care about the Backstage project and want to help it grow and improve. A maintainer is not just someone who can make changes, but someone who has demonstrated his or her ability to collaborate with the team, get the most knowledgeable people to review code, contribute high-quality code, and follow through to fix issues (in code or tests). - -A maintainer is a contributor to the Backstage project's success and a citizen helping the project succeed. - -## Becoming a Maintainer +## b) Your organization is currently a maintainer To become a maintainer you need to demonstrate the following: -- commitment to the project - - participate in discussions, contributions, code reviews for 3 months or more, - - perform code reviews for 10 non-trivial pull requests, - - contribute 10 non-trivial pull requests and have them merged into master, -- ability to write good code, -- ability to collaborate with the team, -- understanding of how the team works (policies, processes for testing and code review, etc), -- understanding of the project's code base and coding style. +- First decide whether your organization really needs more people with maintainer access. Valid reasons are "blast radius", a large organization that is working on multiple unrelated projects, etc. +- Contact a sponsor for your organization and express interest. +- Start doing PRs and code reviews under the guidance of your maintainer. +- After a period of 1-2 months the existing sponsors will discuss granting maintainer access. +- Maintainer access can be upgraded to sponsor access after another conference of the existing sponsors. -## Changes in Maintainership +# Maintainer responsibilities -A new maintainer must be proposed by an existing maintainer by opening an issue (with title `Maintainer Nomination`) to the Backstage GitHub repository (https://github.com/backstage/backstage) containing the following information: +- Monitor email aliases. +- Monitor Discord (delayed response is perfectly acceptable). +- Triage GitHub issues and perform pull request reviews for other maintainers and the community. +- Triage build issues - file issues for known flaky builds or bugs, and either fix or find someone to fix any master build breakages. +- During GitHub issue triage, apply all applicable ([labels](https://github.com/backstage/backstage/labels)) to each new issue. Labels are extremely useful for future issue follow up. Which labels to apply is somewhat subjective so just use your best judgment. A few of the most important labels that are not self explanatory are: + - good first issue: Mark any issue that can reasonably be accomplished by a new contributor with this label. + - help wanted: Unless it is immediately obvious that someone is going to work on an issue (and if so assign it), mark it help wanted. +- Make sure that ongoing PRs are moving forward at the right pace or closing them. +- Participate when called upon in the security release process. Note that although this should be a rare occurrence, if a serious vulnerability is found, the process may take up to several full days of work to implement. This reality should be taken into account when discussing time commitment obligations with employers. +- In general, continue to be willing to spend at least 25% of one's time working on Backstage (~1.25 business days per week). +- We currently maintain an "on-call" rotation within the maintainers. Each on-call is 1 week. Although all maintainers are welcome to perform all of the above tasks, it is the on-call maintainer's responsibility to triage incoming issues/questions and marshal ongoing work forward. To reiterate, it is not the responsibility of the on-call maintainer to answer all questions and do all reviews, but it is their responsibility to make sure that everything is being actively covered by someone. -- nominee's first and last name, -- nominee's email address and GitHub user name, -- an explanation of why the nominee should be a maintainer, -- a list of links to non-trivial pull requests (top 10) authored by the nominee. +# When does a maintainer lose maintainer status -Maintainers can be removed by a 2/3 majority vote. +If a maintainer is no longer interested or cannot perform the maintainer duties listed above, they should volunteer to be moved to emeritus status. In extreme cases this can also occur by a vote of the sponsors and maintainers per the voting process below. -## Approving PRs +# Conflict resolution and voting -PRs may be merged after receiving at least one approval from a maintainer. +In general, we prefer that technical issues and maintainer membership are amicably worked out between the persons involved. If a dispute cannot be decided independently, the sponsors and maintainers can be called in to decide an issue. If the sponsors and maintainers themselves cannot decide an issue, the issue will be resolved by voting. The voting process is a simple majority in which each sponsor receives two votes and each maintainer receives one vote. -## GitHub Project Administration +# Adding new projects to the Backstage GitHub organization -Maintainers will be added to the collaborators list of the Backstage repository with "Write" access. - -## Changes in Governance - -All changes in Governance require a 2/3 majority vote. - -## Other Changes - -Unless specified above, all other changes to the project require a 2/3 majority vote. -Additionally, any maintainer may request that any change require a 2/3 majority vote. +New projects will be added to the Backstage organization via GitHub issue discussion in one of the existing projects in the organization. Once sufficient discussion has taken place (~3-5 business days but depending on the volume of conversation), the maintainers of the project where the issue was opened (since different projects in the organization may have different maintainers) will decide whether the new project should be added. See the section above on voting if the maintainers cannot easily decide. diff --git a/MAINTAINERS.md b/MAINTAINERS.md deleted file mode 100644 index 33437692c9..0000000000 --- a/MAINTAINERS.md +++ /dev/null @@ -1,21 +0,0 @@ -# Maintainers - -- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines. - -## Current Maintainers 🏓 - -- Stefan Ålund - Spotify (GitHub: @stefanalund, Discord: @stalund) -- Patrik Oldsberg - Spotify (GitHub: @Rugvip, Discord: @Rugvip) -- Fredrik Adelöw - Spotify (GitHub: @freben, Discord: @freben) -- Ben Lambert - Spotify (GitHub: @benjdlambert, Discord: @blam) - -## Plugin maintainers 🧩 - -Teams and individuals that maintain a plugin (or another non-core module of the code) can get write access to that part of the repo using CODEOWNERS. - -## Hall of Fame 👏 - -People that have made significant contributions to the project and earned write access: - -- Andrew Thauer - Wealthsimple (GitHub: @andrewthauer) -- Oliver Sand - SDA SE (GitHub: @Fox32) diff --git a/OWNERS.md b/OWNERS.md new file mode 100644 index 0000000000..8e5002fc0a --- /dev/null +++ b/OWNERS.md @@ -0,0 +1,24 @@ +- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines. +- See [GOVERNANCE.md](GOVERNANCE.md) for governance guidelines and responsibilities. + +This page lists all active sponsors and maintainers. + +# Sponsors + +- Niklas Gustavsson ([protocol7](https://github.com/protocol7)) (ngn@spotify.com) +- Dave Zolotusky ([dzolotusky](https://github.com/dzolotusky)) (dzolo@spotify.com) +- Lee Mills ([leemills83](https://github.com/leemills83)) (leem@spotify.com) + +# Maintainers + +- Patrik Oldsberg ([rugvip](https://github.com/rugvip)) (Discord: @Rugvip) +- Fredrik Adelöw ([freben](https://github.com/freben)) (Discord: @freben) +- Ben Lambert ([benjdlambert](https://github.com/benjdlambert)) (Discord: @blam) +- Stefan Ålund ([stefanalund](https://github.com/stefanalund)) (Discord: @stalund) + +# Friends of Backstage + +People that have made significant contributions to the project and earned write access. + +- Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) +- Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) diff --git a/app-config.yaml b/app-config.yaml index 148627dc5a..f6f71478d0 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -38,7 +38,7 @@ proxy: headers: Authorization: $env: TRAVISCI_AUTH_TOKEN - travis-api-version: 3 + travis-api-version: '3' '/newrelic/apm/api': target: https://api.newrelic.com/v2 @@ -46,22 +46,45 @@ proxy: X-Api-Key: $env: NEW_RELIC_REST_API_KEY + '/pagerduty': + target: https://api.pagerduty.com + headers: + Authorization: + $env: PAGERDUTY_TOKEN + + '/buildkite/api': + target: https://api.buildkite.com/v2/ + headers: + Authorization: + $env: BUILDKITE_TOKEN + + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN + organization: name: My Company +# Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs requestUrl: http://localhost:7000/api/techdocs + storageUrl: http://localhost:7000/api/techdocs/static/docs + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' + publisher: + type: 'local' # Alternatives - 'googleGcs'. Read documentation for using alternatives. sentry: organization: my-company rollbar: organization: my-company - accountToken: - $env: ROLLBAR_ACCOUNT_TOKEN + # NOTE: The rollbar-backend & accountToken key may be deprecated in the future (replaced by a proxy config) + accountToken: my-rollbar-account-token lighthouse: baseUrl: http://localhost:3003 @@ -114,7 +137,7 @@ catalog: $env: GITHUB_TOKEN #### Example for how to add your GitHub Enterprise instance using the API: # - target: https://ghe.example.net - # apiBaseUrl: https://ghe.example.net/api/v3 + # apiBaseUrl: https://ghe.example.net/api # token: # $env: GHE_TOKEN ldapOrg: @@ -134,6 +157,19 @@ catalog: # dn: ou=access,ou=groups,ou=example,dc=example,dc=net # options: # filter: (&(objectClass=some-group-class)(!(groupType=email))) + microsoftGraphOrg: + ### Example for how to add your Microsoft Graph tenant + #providers: + # - target: https://graph.microsoft.com/v1.0 + # authority: https://login.microsoftonline.com + # tenantId: + # $env: MICROSOFT_GRAPH_TENANT_ID + # clientId: + # $env: MICROSOFT_GRAPH_CLIENT_ID + # clientSecret: + # $env: MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN + # userFilter: accountEnabled eq true and userType eq 'member' + # groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') locations: # Backstage example components @@ -170,8 +206,11 @@ scaffolder: api: token: $env: AZURE_TOKEN - auth: + environment: development + ### Providing an auth.session.secret will enable session support in the auth-backend + # session: + # secret: custom session secret providers: google: development: @@ -216,6 +255,20 @@ auth: $env: AUTH_OAUTH2_AUTH_URL tokenUrl: $env: AUTH_OAUTH2_TOKEN_URL + oidc: + development: + metadataUrl: + $env: AUTH_OIDC_METADATA_URL + clientId: + $env: AUTH_OIDC_CLIENT_ID + clientSecret: + $env: AUTH_OIDC_CLIENT_SECRET + authorizationUrl: + $env: AUTH_OIDC_AUTH_URL + tokenUrl: + $env: AUTH_OIDC_TOKEN_URL + tokenSignedResponseAlg: + $env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG auth0: development: clientId: @@ -255,6 +308,9 @@ costInsights: bigQuery: name: BigQuery icon: search + events: + name: Events + icon: data metrics: DAU: name: Daily Active Users @@ -264,10 +320,12 @@ costInsights: homepage: clocks: - label: UTC - timzone: UTC + timezone: UTC - label: NYC timezone: 'America/New_York' - label: STO timezone: 'Europe/Stockholm' - label: TYO timezone: 'Asia/Tokyo' +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 7b60062265..96bfdb4351 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -7,7 +7,7 @@ This folder contains Helm charts that can easily create a Kubernetes deployment These charts depend on the `nginx-ingress` controller being present in the cluster. If it's not already installed you can run: -``` +```shell helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm install nginx-ingress ingress-nginx/ingress-nginx ``` @@ -16,7 +16,7 @@ helm install nginx-ingress ingress-nginx/ingress-nginx After choosing a DNS name where backstage will be hosted create a yaml file for your custom configuration. -``` +```yaml appConfig: app: baseUrl: https://backstage.mydomain.com @@ -30,12 +30,11 @@ appConfig: techdocs: storageUrl: https://backstage.mydomain.com/api/techdocs/static/docs requestUrl: https://backstage.mydomain.com/api/techdocs - ``` Then use it to run: -``` +```shell git clone https://github.com/backstage/backstage.git cd contrib/chart/backstage helm dependency update @@ -54,7 +53,7 @@ After a few minutes Backstage should be up and running in your cluster under the Make sure to create the appropriate DNS entry in your infrastructure. To find the public IP address run: -```bash +```shell $ kubectl get ingress NAME HOSTS ADDRESS PORTS AGE backstage-ingress * 123.1.2.3 80 17m @@ -74,10 +73,10 @@ These charts can install or reuse a `clusterIssuer` to generate certificates for To enable it you need to provide a valid email address in the chart's values: -``` +```yaml issuer: email: me@example.com - clusterIssuer: "letsencrypt-prod" + clusterIssuer: 'letsencrypt-prod' ``` By default, the charts use `letsencrypt-staging` so in the above example we instruct helm to use the production issuer @@ -92,7 +91,7 @@ Configuring a connection to an existing PostgreSQL instance is possible through First create a yaml file with the configuration you want to override, for example `backstage-prod.yaml`: -```bash +```yaml postgresql: enabled: false @@ -122,12 +121,11 @@ lighthouse: user: password: database: lighthouse_audit_service - ``` For the CA, create a `configMap` named `--postgres-ca` with a file called `ca.crt`: -``` +```shell kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" ``` @@ -135,7 +133,7 @@ kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" Now install the helm chart: -``` +```shell cd contrib/chart/backstage helm install -f backstage-prod.yaml my-backstage . ``` @@ -144,7 +142,7 @@ helm install -f backstage-prod.yaml my-backstage . The docker images used for the deployment can be configured through the charts values: -``` +```yaml frontend: image: repository: @@ -165,7 +163,7 @@ lighthouse: Create a docker-registry secret -``` +```shell kubectl create secret docker-registry # args ``` @@ -181,7 +179,7 @@ dockerRegistrySecretName: To install the charts a specific namespace use `--namespace `: -``` +```shell helm install -f my_values.yaml --namespace demos backstage . ``` @@ -189,7 +187,7 @@ helm install -f my_values.yaml --namespace demos backstage . To deploy backstage with the pre-loaded demo data disable `backend.demoData`: -``` +```shell helm install -f my_values.yaml --set backend.demoData=false backstage . ``` @@ -244,7 +242,7 @@ This error happens in the backend when it tries to connect to the configured Pos To uninstall Backstage simply run: -``` +```shell RELEASE_NAME= # use `helm list` to find out the name helm uninstall ${RELEASE_NAME} kubectl delete pvc data-${RELEASE_NAME}-postgresql-0 diff --git a/contrib/chart/backstage/templates/backend-secret.yaml b/contrib/chart/backstage/templates/backend-secret.yaml index b340f39d7c..299d893ec4 100644 --- a/contrib/chart/backstage/templates/backend-secret.yaml +++ b/contrib/chart/backstage/templates/backend-secret.yaml @@ -20,4 +20,5 @@ stringData: AZURE_TOKEN: {{ .Values.auth.azure.api.token }} NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }} TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }} + PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }} {{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index a4a0fadcc2..261f352f93 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -250,3 +250,4 @@ auth: gitlabToken: g newRelicRestApiKey: r travisciAuthToken: fake-travis-ci-auth-token + pagerdutyToken: h diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 5d633544e3..9b5d77bc7c 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -3,7 +3,7 @@ ExampleComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Typography, Grid } from '@material-ui/core'; import { InfoCard, @@ -18,7 +18,7 @@ import { import { useApi } from '@backstage/core-api'; import ExampleFetchComponent from '../ExampleFetchComponent'; -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { const identityApi = useApi(identityApiRef); const userId = identityApi.getUserId(); const profile = identityApi.getProfile(); diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 08d14e8c4c..6061b69e93 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -3,7 +3,7 @@ ExampleFetchComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -57,7 +57,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, @@ -76,7 +76,7 @@ export const DenseTable: FC = ({ viewer }) => { ); }; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { const auth = useApi(githubAuthApiRef); const { value, loading, error } = useAsync(async (): Promise => { diff --git a/docs/FAQ.md b/docs/FAQ.md index 7b906a341b..da10f9724c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -48,10 +48,9 @@ source candidates. (And we'll probably end up writing some brand new ones, too.) ### What's the roadmap for Backstage? We envision three phases, which you can learn about in -[our project roadmap](https://github.com/backstage/backstage#project-roadmap). -Even though the open source version of Backstage is relatively new compared to -our internal version, we have already begun work on various aspects of all three -phases. Looking at the +[our project roadmap](overview/roadmap.md). Even though the open source version +of Backstage is relatively new compared to our internal version, we have already +begun work on various aspects of all three phases. Looking at the [milestones for active issues](https://github.com/backstage/backstage/milestones) will also give you a sense of our progress. @@ -115,10 +114,65 @@ type of content. Plugins all use a common set of platform APIs and reusable UI components. Plugins can fetch data either from the backend or an API exposed through the proxy. -Learn more about -[the different components](https://github.com/backstage/backstage#overview) that +Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. +### How do I keep my Backstage app up to date? + +In many ways one can view Backstage as a library rather than an application or +service. The `@backstage/create-app` tool that is used to create your own +Backstage app is similar to +[`create-react-app`](https://github.com/facebook/create-react-app) in that it +gives you a starting point. The code you get is meant to be evolved, and most of +the functionality you get out of the box is brought in via npm dependencies. +Keeping your app up to date generally means keeping your dependencies up to +date. The Backstage CLI provides a command to help you with that. Simply run +`yarn backstage-cli versions:bump` at the root of your repo, and the latest +versions of all Backstage packages will be installed. + +While staying up to date with new releases and changes will keep your app up to +date, it can often be convenient to use the changes done to the +`@backstage/create-app` template as another method to stay up to date. For that +purpose, any changes done to the template are documented along with upgrade +instructions in the +[changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md) +of the `@backstage/create-app` package. + +### Why can't I dynamically install plugins without modifications the app? + +This decision is part of the core architecture and development flow of +Backstage. Plugins have a lot of freedom in what they provide and how they are +integrated into the app, and it would therefore add a lot of complexity to allow +plugins to be integrated via configuration the same way as they can be +integrated with code. + +By bundling all plugins and their dependencies into one app bundle it is also +possible to do significant optimizations to the app load time by allowing +plugins to share dependencies between each other when possible. This contributes +to Backstage being fast, which is an important part of the user and developer +experience. + +### Why are there no published Docker images or helm charts for Backstage? + +As mentioned above, Backstage is not a packaged service that you can use out of +the box. In order to get started with Backstage you need to use the +`@backstage/create-app` package to create and customize your own Backstage app. + +In order to build a Docker image from your own app, you can use the +`yarn build-image` command which is included out of the box in the app template. +By default this image will bundle up both the frontend and the backend into a +single image that you can deploy using your favorite tooling. + +There are also some examples that can help you deploy Backstage to kubernetes in +the +[contrib](https://github.com/backstage/backstage/tree/master/contrib/kubernetes) +folder. + +It is possible that example images will be provided in the future, which can be +used to quickly try out a small subset of the functionality of Backstage, but +these would not be able to provide much more functionality on top of what you +can see on a demo site. + ### Do I have to write plugins in TypeScript? No, you can use JavaScript if you prefer. We want to keep the Backstage core @@ -126,17 +180,17 @@ APIs in TypeScript, but aren't forcing it on individual plugins. ### How do I find out if a plugin already exists? -Before you write a plugin, +You can browse and search for all available plugins in the +[Plugin Marketplace](https://backstage.io/plugins). + +If you can't find it in the marketplace, before you write a plugin [search the plugin issues](https://github.com/backstage/backstage/issues?q=is%3Aissue+label%3Aplugin+) -to see if it already exists or is in the works. If no one's thought of it yet, -great! Open a new issue as +to see if is in the works. If no one's thought of it yet, great! Open a new +issue as [a plugin suggestion](https://github.com/backstage/backstage/issues/new/choose) and describe what your plugin will do. This will help coordinate our contributors' efforts and avoid duplicating existing functionality. -You can browse and search for all available plugins in the -[Plugin Marketplace](https://backstage.io/plugins). - ### Which plugin is used the most at Spotify? By far, our most-used plugin is our TechDocs plugin, which we use for creating @@ -182,6 +236,10 @@ comes to [deployment](https://backstage.io/docs/getting-started/deployment-k8s), the system integrator (typically, the infrastructure team in your organization) maintains Backstage in your own environment. +For more information, see our +[Owners](https://github.com/backstage/backstage/blob/master/OWNERS.md) and +[Governance](https://github.com/backstage/backstage/blob/master/GOVERNANCE.md). + ### Does Spotify provide a managed version of Backstage? No, this is not a service offering. We build the piece of software, and someone @@ -215,14 +273,14 @@ data is shared with. Yes. The core frontend framework could be used for building any large-scale web application where (1) multiple teams are building separate parts of the app, and (2) you want the overall experience to be consistent. That being said, in -[Phase 2](https://github.com/backstage/backstage#project-roadmap) of the project -we will add features that are needed for developer portals and systems for -managing software ecosystems. Our ambition will be to keep Backstage modular. +[Phase 2](overview/roadmap.md) of the project we will add features that are +needed for developer portals and systems for managing software ecosystems. Our +ambition will be to keep Backstage modular. ### How can I get involved? Jump right in! Come help us fix some of the -[early bugs and first issues](https://github.com/backstage/backstage/labels/good%20first%20issue) +[early bugs and good first issues](https://github.com/backstage/backstage/contribute) or reach [a new milestone](https://github.com/backstage/backstage/milestones). Or write an open source plugin for Backstage, like this [Lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse). diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index a4d154b44b..ff322c136f 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -11,7 +11,7 @@ possible residing within the plugin itself and its backend APIs. There will however always be a need for plugins to communicate outside of its boundaries, both with other plugins and the app itself. -Backstage provides two primary methods for plugins to communication across their +Backstage provides two primary methods for plugins to communicate across their boundaries in client-side code. The first one being the `createPlugin` API and the registration hooks passed to the `register` method, and the second one being Utility APIs. While the `createPlugin` API is focused on the initialization @@ -33,10 +33,10 @@ hook exported by `@backstage/core`, or the `withApis` HOC if you prefer class components. For example, the `ErrorApi` can be accessed like this: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useApi, errorApiRef } from '@backstage/core'; -export const MyComponent: FC<{}> = () => { +export const MyComponent = () => { const errorApi = useApi(errorApiRef); // Signal to the app that something went wrong, and display the error to the user. @@ -195,8 +195,8 @@ interface for the API, and create an `ApiRef` using `createApiRef` exported from `@backstage/core`. Also be sure to provide at least one implementation of the API, and to declare a default factory for the API in `createPlugin`. -Custom Utility APIs can be either public or private, which it is up to the -plugin to choose. Private APIs do not expose an external API surface, and it's +Custom Utility APIs can be either public or private, which is up to the plugin +to choose. Private APIs do not expose an external API surface, and it's therefore possible to make breaking changes to the API without affecting other users of the plugin. If an API is made public however, it opens up for other plugins to make use of the API, and it also makes it possible for users for your @@ -243,7 +243,7 @@ The figure below shows the relationship between The current method for connecting Utility API providers and consumers is via the React tree using an `ApiProvider`, which is added to the `AppProvider` of the `App`. In the future there may potentially be more ways to do this, in ways that -are not tied to react. A design goal of the Utility APIs was to not have them +are not tied to React. A design goal of the Utility APIs was to not have them directly tied to React. The indirection provided by Utility APIs also makes it straightforward to test diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index f91698c5ff..34d6449c6b 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -58,7 +58,7 @@ discover existing functionality in the ecosystem. APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. -framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs +framework APIs in Swift, Kotlin, Java, C++, TypeScript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 96f594daf7..51dcf042da 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -6,7 +6,7 @@ description: Architecture Decision Record (ADR) log on Avoid React.FC and React. ## Context -Facebook has removed `React.FC` from their base template for a Typescript +Facebook has removed `React.FC` from their base template for a TypeScript project. The reason for this was that it was found to be an unnecessary feature with next to no benefits in combination with a few downsides. diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index de71141ba0..18a6c5a4f0 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/docs/assets/techdocs/architecture-basic.drawio.svg b/docs/assets/techdocs/architecture-basic.drawio.svg new file mode 100644 index 0000000000..8e3be76f8b --- /dev/null +++ b/docs/assets/techdocs/architecture-basic.drawio.svg @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + +
+
+
+ Repo 1 +
+ (with docs) +
+
+
+
+ + Repo 1... + +
+
+ + + + +
+
+
+ Repo 2 +
+ (with docs) +
+
+
+
+ + Repo 2... + +
+
+ + + + +
+
+
+ TechDocs plugin +
+
+
+
+ + TechDocs plugin + +
+
+ + + + + +
+
+
+ Request TechDocs site +
+
+
+
+ + Request TechDocs site + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ Source code hosting +
+
+
+
+ + Source code hosting + +
+
+ + + + +
+
+
+ TechDocs Backend Plugin +
+
+
+
+ + TechDocs Backend Plugin + +
+
+ + + + +
+
+
+ (Stages) +
+
+
+
+ + (Stages) + +
+
+ + + + + + +
+
+
+ Prepare +
+
+
+
+ + Prepare + +
+
+ + + + + + +
+
+
+ Generate +
+
+
+
+ + Generate + +
+
+ + + + +
+
+
+ Publish +
+
+
+
+ + Publish + +
+
+ + + + + +
+
+
+ Local Filesystem +
+
+
+
+ + Local File... + +
+
+ + + + +
+
+
+ Fetch markdown files +
+
+
+
+ + Fetch mark... + +
+
+ + + + +
+
+
+ Build doc files using mkdocs +
+
+
+
+ + Build doc... + +
+
+ + + + +
+
+
+ Store generated static content +
+
+
+
+ + Store gene... + +
+
+ + + + +
+
+
+ TechDocs Basic (out-of-the-box) architecture +
+
+
+
+ + TechDocs Basic (out-of-the-box) architect... + +
+
+ + + + + + + + +
+
+
+ + Route handler + +
+
+
+
+ + Route hand... + +
+
+ + + + +
+
+
+ or +
+
+
+
+ + or + +
+
+ + + + +
+
+
+ Trigger new build if docs D.N.E. +
+ or are outdated +
+
+
+
+ + Trigger new build... + +
+
+ + + + +
+
+
+ Fetch files to render +
+
+
+
+ + Fetch files... + +
+
+ + + + + + + + + + + +
+
+
+ + GCP +
+ Bucket +
+
+
+
+
+ + GCP... + +
+
+ + + + +
+
+
+ Azure storage +
+
+
+
+ + Azure st... + +
+
+ + + + + +
+
+
+ Storage solutions +
+
+
+
+ + Storage solutions + +
+
+ + + + + + + + +
+
+
+ + Amazon S3 + +
+
+
+
+ + Amazon S3 + +
+
+ +
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/techdocs/architecture-recommended.drawio.svg b/docs/assets/techdocs/architecture-recommended.drawio.svg new file mode 100644 index 0000000000..e3af4b6b5f --- /dev/null +++ b/docs/assets/techdocs/architecture-recommended.drawio.svg @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + +
+
+
+ Repo 1 +
+ (with docs) +
+
+
+
+ + Repo 1... + +
+
+ + + + +
+
+
+ Repo 2 +
+ (with docs) +
+
+
+
+ + Repo 2... + +
+
+ + + + + + + + +
+
+
+ CI/CD System +
+
+
+
+ + CI/CD System + +
+
+ + + + + + +
+
+
+ techdocs-cli +
+
+
+
+ + techdocs-cli + +
+
+ + + + +
+
+
+ Cloud Storage +
+
+
+
+ + Cloud Storage + +
+
+ + + + + + +
+
+
+ + Amazon S3 + +
+
+
+
+ + Amazon S3 + +
+
+ + + + + + + + + +
+
+
+ + GCP +
+ Bucket +
+
+
+
+
+ + GCP... + +
+
+ + + + +
+
+
+ Azure storage +
+
+
+
+ + Azure st... + +
+
+ + + + +
+
+
+ New commit +
+
+
+
+ + New commit + +
+
+ + + + +
+
+
+ New commit +
+
+
+
+ + New commit + +
+
+ + + + +
+
+
+ Build docs +
+ & +
+ Store generated static content +
+
+
+
+ + Build docs... + +
+
+ + + + + + + +
+
+
+ TechDocs plugin +
+
+
+
+ + TechDocs plugin + +
+
+ + + + +
+
+
+ TechDocs Backend plugin +
+
+
+
+ + TechDocs Backend plu... + +
+
+ + + + + + +
+
+
+ Request TechDocs site +
+
+
+
+ + Request TechDocs site + +
+
+ + + + +
+
+
+ Fetch files to render +
+
+
+
+ + Fetch files to render + +
+
+ + + + + + + + + + + + + + + +
+
+
+ Source code hosting +
+
+
+
+ + Source code hosting + +
+
+ + + + + +
+
+
+ Caching +
+ (Optional) +
+
+
+
+ + Caching... + +
+
+ + + + + + +
+
+
+ Storage solutions +
+
+
+
+ + Storage solutions + +
+
+ + + + +
+
+
+ TechDocs Recommended deployment architecture +
+
+
+
+ + TechDocs Recommended deployment architecture + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/techdocs/techdocs_big_picture.png b/docs/assets/techdocs/techdocs_big_picture.png deleted file mode 100644 index 8b8f7a2338..0000000000 Binary files a/docs/assets/techdocs/techdocs_big_picture.png and /dev/null differ diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 442553d4b8..8dbae0daa7 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -6,57 +6,71 @@ description: Documentation on Auth backend classes ## How Does Authentication Work? -The Backstage application can use various authentication providers for -authentication. A provider has to implement an `AuthProviderRouteHandlers` -interface for handling authentication. This interface consists of four methods. -Each of these methods is hosted at an endpoint `/auth/[provider]/method`, where -`method` performs a certain operation as follows: +The Backstage application can use various external authentication providers for +authentication. An external provider is wrapped using an +`AuthProviderRouteHandlers` interface for handling authentication. This +interface consists of four methods. Each of these methods is hosted at an +endpoint (by default) `/api/auth/[provider]/method`, where `method` performs a +certain operation as follows: ``` - /auth/[provider]/start -> start - /auth/[provider]/handler/frame -> frameHandler - /auth/[provider]/refresh -> refresh - /auth/[provider]/logout -> logout + /auth/[provider]/start -> Initiate a login from the web page + /auth/[provider]/handler/frame -> Handle a finished authentication operation + /auth/[provider]/refresh -> Refresh the validity of a login + /auth/[provider]/logout -> Log out a logged-in user ``` -For more information on how these methods are used and for which purpose, refer -to the [OAuth documentation](oauth.md). +The flow is as follows: -For details on the parameters, input and output conditions for each method, -refer to the type documentation under -`plugins/auth-backend/src/providers/types.ts`. +1. A user attempts to sign in. +2. A popup window is opened, pointing to the `auth` endpoint. That endpoint does + initial preparations and then re-directs the user to an external + authenticator, still inside the popup. +3. The authenticator validates the user and returns the result of the validation + (success OR failure), to the wrapper's endpoint (`handler/frame`). +4. The `handler/frame` rendered b´webpage will issue the appropriate response to + the webpage that opened the popup window, and the popup is closed. +5. The user signs out by clicking on a UI interface and the webpage makes a + request to logout the user. There are currently two different classes for two authentication mechanisms that implement this interface: an `OAuthAdapter` for [OAuth](https://oauth.net/2/) based mechanisms and a `SAMLAuthProvider` for -[SAML](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) -based mechanisms. +[SAML](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html). -### OAuth mechanisms +If you do not have an `OAuth2` or `SAML` based authentication provider, look in +the section [below](#implementing-your-own-auth-wrapper). + +### OAuth Mechanisms + +For more information on how these methods are used and for which purpose, refer +to the [OAuth documentation](oauth.md). Currently OAuth is assumed to be the de facto authentication mechanism for Backstage based applications. Backstage comes with a "batteries-included" set of supported commonly used OAuth -providers: Okta, GitHub, Google, GitLab, and a generic OAuth2 provider. +providers: Okta, GitHub, Google, GitLab, and a generic OAuth2 provider. For a +list of available providers, look at the available wrappers in +`backstage/plugins/auth-backend/src/providers/`. -All of these use the authorization flow of OAuth2 to implement authentication. +All of these use the **authorization flow** of OAuth2 to implement +authentication. -If your authentication provider is any of the above mentioned (except generic -OAuth2) providers, you can configure them by setting the right variables in -`app-config.yaml` under the `auth` section. +If your authentication provider is any of the above mentioned providers, you can +configure them by setting the right variables in `app-config.yaml` under the +`auth` section. ### Configuration Each authentication provider (except SAML) needs five parameters: an OAuth -client ID, a client secret, an authorization endpoint and a token endpoint, and -an app origin. The app origin is the URL at which the frontend of the -application is hosted, and it is read from the `app.baseUrl` config. This is -required because the application opens a popup window to perform the -authentication, and once the flow is completed, the popup window sends a -`postMessage` to the frontend application to indicate the result of the -operation. Also this URL is used to verify that authentication requests are -coming from only this endpoint. +client ID, a client secret, an authorization endpoint, a token endpoint, and an +app origin. The app origin is the URL at which the frontend of the application +is hosted, and it is read from the `app.baseUrl` config. This is required +because the application opens a popup window to perform the authentication, and +once the flow is completed, the popup window sends a `postMessage` to the +frontend application to indicate the result of the operation. Also this URL is +used to verify that authentication requests are coming from only this endpoint. These values are configured via the `app-config.yaml` present in the root of your app folder. @@ -85,20 +99,60 @@ auth: ... ``` -## Technical Notes +## Implementing Your Own Auth Wrapper -### OAuthEnvironmentHandler +The core interface of any auth wrapper is the `AuthProviderRouteHandlers` +interface. This interface has four methods corresponding to the API described in +the initial section. Any auth wrapper will have to implement this interface. -The concept of an "env" is core to the way the auth backend works. It uses an +When initiating a login, a pop-up window is created by the frontend, to allow +the user to initiate a login. This login request is done to the `/start` +endpoint which is handled by the `start` method. + +The `start` method re-directs to the external auth provider who authenticates +the request and re-directs the request to the `/frame/handler` endpoint, which +is handled by the `frameHandler` method. + +The `frameHandler` returns an HTML response, containing a script that does a +`postMessage` to the frontend's window, containing the result of the request. +The `WebMessageResponse` type is the message sent by the `postMessage` to the +frontend. + +A `postMessageResponse` utility function wraps the logic of generating a +`postMessage` response that ensures that CORS is successfully handled. This +function takes an `express.Response`, a `WebMessageResponse` and the URL of the +frontend (`appOrigin`) as parameters and return an HTML page with the script and +the message. + +### OAuth Wrapping Interfaces. + +Each OAuth external provider is supported by a corresponding +[Passport](https://github.com/jaredhanson/passport) strategy. For a generic +OAuth2 provider, passport has a `passport-oauth2` strategy. The strategy class +handles the implementation details of working with each provider. + +Each strategy is wrapped by an `OAuthHandlers` interface. + +This interface cannot be directly used as an Express HTTP request handler. To do +so, `OAuthHandlers` are wrapped in an `OAuthAdapter`, which implements the +`AuthProviderRouterHandlers` interface. + +#### Env + +The concept of an `env` is core to the way the auth backend works. It uses an `env` query parameter to identify the environment in which the application is -running (`development`, `staging`, `production`, etc). Each runtime can support -multiple environments at the same time and the right handler for each request is -identified and dispatched to based on the `env` parameter. All -`AuthProviderRouteHandlers` are wrapped within an `OAuthEnvironmentHandler`. +running (`development`, `staging`, `production`, etc). Each runtime can +simultaneously support multiple environments at the same time and the right +handler for each request is identified and dispatched to, based on the `env` +parameter. -To instantiate multiple OAuth providers for different environments, use +`OAuthEnvironmentHandler` is a utility wrapper for an `OAuthHandlers` that +implements the `AuthProviderRouteHandlers` interface while supporting multiple +`env`s. + +To instantiate OAuth providers (the same but for different environments), use `OAuthEnvironmentHandler.mapConfig`. It's a helper to iterate over a -configuration object that is a map of environment to configurations. See one of +configuration object that is a map of environments to configurations. See one of the existing OAuth providers for an example of how it is used. Given the following configuration: @@ -113,13 +167,18 @@ production: ``` The `OAuthEnvironmentHandler.mapConfig(config, envConfig => ...)` call will -split the `config` by the top level `development` and `production` keys, and -pass on each block as `envConfig`. +split the config by the top level `development` and `production` keys, and pass +on each block as `envConfig`. -For a list of currently available providers, look in the `factories` module -located in `plugins/auth-backend/src/providers/factories.ts` +For convenience, the `AuthProviderFactory` is a factory function that has to be +implemented which can then generate a `AuthProviderRouteHandlers` for a given +provider. -### OAuth2 provider +All of the supported providers provide an `AuthProviderFactory` that returns an +`OAuthEnvironmentHandler`, capable of handling authentication for multiple +environments. + +### OAuth2 Provider The `oauth2` provider abstracts a generic **OAuth2 + OIDC** based authentication provider. What this means is that after the application has been given diff --git a/docs/auth/index.md b/docs/auth/index.md index 3f95efaf61..cf468c7bb7 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -93,6 +93,6 @@ sign-in methods. More details are provided in dedicated sections of the documentation. - [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the - [auth-backend](../../plugins/auth-backend). + [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend). - [Glossary](./glossary.md): Glossary of some common terms related to the auth flows. diff --git a/docs/conf/defining.md b/docs/conf/defining.md index bea03e4e44..358498929a 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -4,16 +4,115 @@ title: Defining Configuration for your Plugin description: Documentation on Defining Configuration for your Plugin --- -There is currently no tooling support or helpers for defining plugin -configuration. But it's on the roadmap. +Configuration in Backstage is organized via a configuration schema, which in +turn is defined using a superset of +[JSON Schema Draft-07](https://json-schema.org/specification-links.html#draft-7). +Each plugin or package within a Backstage app can contribute to the schema, +which during validation is stitched together into a single schema. -Meanwhile, document the config values that you are reading in your plugin -README. +## Schema Collection and Definition -## Format +Schemas are collected from all packages and dependencies in each repo that are a +part of the Backstage ecosystem, including transitive dependencies. The current +definition of "part of the ecosystem" is that a package has at least one +dependency in the `@backstage` namespace, but this is subject to change. + +Each package is searched for a schema at a single point of entry, a top-level +`"configSchema"` field in `package.json`. The field can either contain an +inlined JSON schema, or a relative path to a schema file. Supported schema file +formats are `.json` or `.d.ts`. + +> When defining a schema file, be sure to include the file in your +> `package.json` > `"files"` field as well! + +TypeScript configuration schema files should export a single `Config` type, for +example: + +```ts +export interface Config { + app: { + /** + * Frontend root URL + * @visibility frontend + */ + baseUrl: string; + }; +} +``` + +Separate `.json` schema files can use a top-level +`"$schema": "https://backstage.io/schema/config-v1"` declaration in order to +receive schema validation and autocompletion. For example: + +```json +{ + "$schema": "https://backstage.io/schema/config-v1", + "type": "object", + "properties": { + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Frontend root URL", + "visibility": "frontend" + } + }, + "required": ["baseUrl"] + }, + "required": ["app"] + } +} +``` + +## Visibility + +The `https://backstage.io/schema/config-v1` meta schema is a superset of JSON +Schema Draft 07. The single addition is a custom `visibility` keyword, which is +used to indicate whether the given config value should be visible in the +frontend or not. The possible values are `frontend`, `backend`, and `secret`, +where `backend` is the default. A visibility of `secret` has the same scope at +runtime, but it will be treated with more care in certain contexts, and defining +both `frontend` and `secret` for the same value in two different schemas will +result in an error during schema merging. + +The visibility only applies to the direct parent of where the keyword is placed +in the schema. For example, if you set the visibility to `frontend` for a subset +of the schema with `type: "object"`, but none of the descendants, only an empty +object will be available in the frontend. The full ancestry does not need to +have correctly defined visibilities however, so it is enough to only for example +declare the visibility of a leaf node of `type: "string"`. + +| `visibility` | | +| ------------ | ------------------------------------------------------------------ | +| `frontend` | Visible in frontend and backend | +| `backend` | (Default) Only in backend | +| `secret` | Only in backend and may be excluded from logs for security reasons | + +## Validation + +Schemas can be validated using the `backstage-cli config:check` command. If you +want to validate anything else than the default `app-config.yaml`, be sure to +pass in all of the configuration files as `--config ` options as well. + +To validate and examine the frontend configuration, use the +`backstage-cli config:print --frontend` command. Just like for validation you +may need to pass in all files using one or multiple `--config ` options. + +## Guidelines + +> Make limited use of static configuration. The first question to ask is whether +> a particular option actually needs to be static configuration, or if it might +> just as well be a TypeScript API. In general, options that you want to be able +> to change for different deployment environments should be static +> configuration, while it should otherwise be avoided. When defining configuration for your plugin, keep keys camelCased and stick to -existing casing conventions such as `baseUrl`. +existing casing conventions such as `baseUrl` rather than `baseURL`. It is also usually best to prefer objects over arrays, as it makes it possible to override individual values using separate files or environment variables. + +Avoid creating new top-level fields as much as possible. Either place your +configuration within an existing known top-level block, or create a single new +one using e.g. the name of the product that the plugin integrates. diff --git a/docs/conf/index.md b/docs/conf/index.md index 2c3ee06ba6..a6f1d1f6f7 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -28,11 +28,25 @@ development or small tweaks to be able to reuse deployment artifacts in different environments. The configuration is shared between the frontend and backend, meaning that -values that are common between the two only needs to be defined once. Such as -the `backend.baseUrl`. +values that are common between the two only need to be defined once. Such as the +`backend.baseUrl`. For more details, see [Writing Configuration](./writing.md). +## Configuration Schema + +The configuration is validated using JSON Schema definitions. Each plugin and +package can provide pieces of the configuration schema, which are stitched +together to form a complete schema during validation. The configuration schema +is also used to select what configuration is available in the frontend using a +custom `visibility` keyword, as configuration is by default only available in +the backend. + +You can validate your configuration against the schema using +`backstage-cli config:check`, and define a schema for your own plugin either +using JSON Schema or TypeScript. For more information, see +[Defining Configuration](./defining.md). + ## Reading Configuration As a plugin developer, you likely end up wanting to define configuration that @@ -49,5 +63,5 @@ More details are provided in dedicated sections of the documentation. plugin. - [Writing Configuration](./writing.md): How to provide configuration for your Backstage deployment. -- [Defining Configuration](./defining.md): How to define configuration for users - of your plugin. +- [Defining Configuration](./defining.md): How to define a configuration schema + for users of your plugin or package. diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 4eb92bb2ff..f28dd5b2d3 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -74,7 +74,7 @@ Note that if any config flags are provided, the default `app-config.yaml` files are NOT loaded. To include them you need to explicitly include them with a flag, for example: -``` +```shell yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml ``` @@ -97,10 +97,10 @@ order: - If no config flags are provided, `app-config.local.yaml` has higher priority than `app-config.yaml`. -## Secrets +## Secrets and Dynamic Data -Secrets are supported via a special secret keys that are prefixed with `$`, -which in turn provides a number of different ways to read in secrets. To load a +Secrets are supported via special data loading keys that are prefixed with `$`, +which in turn provide a number of different ways to read in secrets. To load a configuration value as a secret, supply an object with one of the special secret keys, for example `$env` or `$file`. A full list of supported secret keys can be found below. For example, the following will read the config key @@ -117,10 +117,6 @@ will return the value of the environment variable `MY_SECRET_KEY` when the backend started up. All secrets are loaded at startup, so changing the contents of secret files or environment variables will not be reflected at runtime. -Note that secrets will never be included in the frontend bundle or development -builds. When loading configuration you have to explicitly enable reading of -secrets, which is only done for the backend configuration. - As hinted at, secrets can be loaded from a bunch of different sources, and can be extended with more. Below is a list of the currently supported methods for loading secrets. @@ -155,8 +151,11 @@ used to point to a specific value inside the file. Supported file extensions are ```yaml $data: ./my-secrets.json#deployment.key +``` -# my-secrets.json +Example `my-secrets.json` file: + +```json { "deployment": { "key": "my-secret-key" diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index a330ef196a..e17c5ca36e 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -2,8 +2,8 @@ id: descriptor-format title: Descriptor Format of Catalog Entities sidebar_label: YAML File Format -description: Documentation on Descriptor Format of Catalog Entities which -describes the default data shape and semantics of catalog entities +# prettier-ignore +description: Documentation on Descriptor Format of Catalog Entities which describes the default data shape and semantics of catalog entities --- This section describes the default data shape and semantics of catalog entities. @@ -22,11 +22,16 @@ we recommend that you name them `catalog-info.yaml`. - [Overall Shape Of An Entity](#overall-shape-of-an-entity) - [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope) - [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata) +- [Common to All Kinds: Relations](#common-to-all-kinds-relations) - [Kind: Component](#kind-component) - [Kind: Template](#kind-template) - [Kind: API](#kind-api) - [Kind: Group](#kind-group) - [Kind: User](#kind-user) +- [Kind: Resource](#kind-resource) +- [Kind: System](#kind-system) +- [Kind: Domain](#kind-domain) +- [Kind: Location](#kind-location) ## Overall Shape Of An Entity @@ -39,7 +44,7 @@ software catalog API. "kind": "Component", "metadata": { "annotations": { - "backstage.io/managed-by-location": "file:/tmp/component-info.yaml", + "backstage.io/managed-by-location": "file:/tmp/catalog-info.yaml", "example.com/service-discovery": "artistweb", "circleci.com/project-slug": "github/example-org/artist-website" }, @@ -89,6 +94,43 @@ significance and have reserved purposes and distinct shapes. See below for details about these fields. +## Substitutions In The Descriptor Format + +The descriptor format supports substitutions using `$text`, `$json`, and +`$yaml`. + +Placeholders like `$json: https://example.com/entity.json` are substituted by +the content of the referenced file. Files can be referenced from any configured +integration similar to locations by passing an absolute URL. It's also possible +to reference relative files like `./referenced.yaml` from the same location. +Relative references are handled relative to the folder of the +`catalog-info.yaml` that contains the placeholder. There are three different +types of placeholders: + +- `$text`: Interprets the contents of the referenced file as plain text and + embeds it as a string. +- `$json`: Interprets the contents of the referenced file as JSON and embeds the + parsed structure. +- `$yaml`: Interprets the contents of the referenced file as YAML and embeds the + parsed structure. + +For example, this can be used to load the definition of an API entity from a web +server and embed it as a string in the field `spec.definition`: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The Petstore API +spec: + type: openapi + lifecycle: production + owner: petstore@example.com + definition: + $text: https://petstore.swagger.io/v2/swagger.json +``` + ## Common to All Kinds: The Envelope The root envelope object has the following structure. @@ -259,6 +301,61 @@ This field is optional, and currently has no special semantics. Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters in total. +## Common to All Kinds: Relations + +The `relations` root field is a read-only list of relations, between the current +entity and other entities, described in the +[well-known relations section](well-known-relations.md). Relations are commonly +two-way, so that there's a pair of relation types each describing one direction +of the relation. + +A relation as part of a single entity that's read out of the API may look as +follows. + +```js +{ + // ... + "relations": [ + { + "target": { + "kind": "group", + "namespace": "default", + "name": "dev.infra" + }, + "type": "ownedBy" + } + ], + "spec": { + "owner": "dev.infra", + // ... + } +} +``` + +The fields of a relation are: + +| Field | Type | Description | +| ---------- | ------ | -------------------------------------------------------------------------------- | +| `target` | Object | A complete [compound reference](references.md) to the other end of the relation. | +| `type` | String | The type of relation FROM a source entity TO the target entity. | +| `metadata` | Object | Reserved for future use. | + +Entity descriptor YAML files are not supposed to contain this field. Instead, +catalog processors analyze the entity descriptor data and its surroundings, and +deduce relations that are then attached onto the entity as read from the +catalog. + +Where relations are produced, they are to be considered the authoritative source +for that piece of data. In the example above, a plugin would do better to +consume the relation rather than `spec.owner` for deducing the owner of the +entity, because it may even be the case that the owner isn't taken from the YAML +at all - it could be taken from a CODEOWNERS file nearby instead for example. +Also, the `spec.owner` is on a shortened form and may have semantics associated +with it (such as the default kind being `Group` if not specified). + +See the [well-known relations section](well-known-relations.md) for a list of +well-known / common relations and their semantics. + ## Kind: Component Describes the following entity kind: @@ -285,7 +382,7 @@ spec: type: website lifecycle: production owner: artist-relations@example.com - implementsApis: + providesApis: - artist-api ``` @@ -310,7 +407,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.lifecycle` [required] @@ -349,12 +446,35 @@ group of people in an organizational structure. ### `spec.implementsApis` [optional] +**NOTE**: This field was marked for deprecation on Nov 25nd, 2020. It will be +removed entirely from the model on Dec 14th, 2020 in the repository and will not +be present in released packages following the next release after that. Please +update your code to not consume this field before the removal date. + Links APIs that are implemented by the component, e.g. `artist-api`. This field is optional. The software catalog expects a list of one or more strings that references the names of other entities of the `kind` `API`. +This field has the same behavior as `spec.providesApis`. + +### `spec.providesApis` [optional] + +Links APIs that are provided by the component, e.g. `artist-api`. This field is +optional. + +The software catalog expects a list of one or more strings that references the +names of other entities of the `kind` `API`. + +### `spec.consumesApis` [optional] + +Links APIs that are consumed by the component, e.g. `artist-api`. This field is +optional. + +The software catalog expects a list of one or more strings that references the +names of other entities of the `kind` `API`. + ## Kind: Template Describes the following entity kind: @@ -438,7 +558,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.templater` [required] @@ -533,6 +653,9 @@ The current set of well-known and common values for this field is: [OpenAPI](https://swagger.io/specification/) version 2 or version 3 spec. - `asyncapi` - An API definition based on the [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) spec. +- `graphql` - An API definition based on + [GraphQL schemas](https://spec.graphql.org/) for consuming + [GraphQL](https://graphql.org/) based APIs. - `grpc` - An API definition based on [Protocol Buffers](https://developers.google.com/protocol-buffers) to use with [gRPC](https://grpc.io/). @@ -600,10 +723,12 @@ metadata: description: The infra business unit spec: type: business-unit + profile: + displayName: Infrastructure + email: infrastructure@example.com + picture: https://example.com/groups/bu-infrastructure.jpeg parent: ops - ancestors: [ops, global-synergies, acme-corp] children: [backstage, other] - descendants: [backstage, other, team-a, team-b, team-c, team-d] ``` In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) @@ -626,6 +751,14 @@ Some common values for this field could be: - `product-area` - `root` - as a common virtual root of the hierarchy, if desired +### `spec.profile` [optional] + +Optional profile information about the group, mainly for display purposes. All +fields of this structure are also optional. The email would be a group email of +some form, that the group may wish to be used for contacting them. The picture +is expected to be a URL pointing to an image that's representative of the group, +and that a browser could fetch and render on a group page or similar. + ### `spec.parent` [optional] The immediate parent group in the hierarchy, if any. Not all groups must have a @@ -639,20 +772,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly, this field points to a group in the same namespace, so in those cases it is sufficient to enter only the `metadata.name` field of that group. -### `spec.ancestors` [required] - -The recursive list of parents up the hierarchy, by stepping through parents one -by one. The list must be present, but may be empty if `parent` is not present. -The first entry in the list is equal to `parent`, and then the following ones -are progressively farther up the hierarchy. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. - ### `spec.children` [required] The immediate child groups of this group in the hierarchy (whose `parent` field @@ -667,20 +786,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly, these entries point to groups in the same namespace, so in those cases it is sufficient to enter only the `metadata.name` field of those groups. -### `spec.descendants` [required] - -The immediate and recursive child groups of this group in the hierarchy -(children, and children's children, etc.). The list must be present, but may be -empty if there are no child groups. The items are not guaranteed to be ordered -in any particular way. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. - ## Kind: User Describes the following entity kind: @@ -740,3 +845,70 @@ with the default kind `Group` and the default namespace equal to the same namespace as the user. Only `Group` entities may be referenced. Most commonly, these entries point to groups in the same namespace, so in those cases it is sufficient to enter only the `metadata.name` field of those groups. + +## Kind: Resource + +This kind is not yet defined, but is reserved [for future use](system-model.md). + +## Kind: System + +This kind is not yet defined, but is reserved [for future use](system-model.md). + +## Kind: Domain + +This kind is not yet defined, but is reserved [for future use](system-model.md). + +## Kind: Location + +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Location` | + +A location is a marker that references other places to look for catalog data. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: org-data +spec: + type: url + targets: + - http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml + - http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `Location`, respectively. + +### `spec.type` [optional] + +The single location type, that's common to the targets specified in the spec. If +it is left out, it is inherited from the location type that originally read the +entity data. For example, if you have a `url` type location, that when read +results in a `Location` kind entity with no `spec.type`, then the referenced +targets in the entity will implicitly also be of `url` type. This is useful +because you can define a hierarchy of things in a directory structure using +relative target paths (see below), and it will work out no matter if it's +consumed locally on disk from a `file` location, or as uploaded on a VCS. + +### `spec.target` [optional] + +A single target as a string. Can be either an absolute path/URL (depending on +the type), or a relative path such as `./details/catalog-info.yaml` which is +resolved relative to the location of this Location entity itself. + +### `spec.targets` [optional] + +A list of targets as strings. They can all be either absolute paths/URLs +(depending on the type), or relative paths such as `./details/catalog-info.yaml` +which are resolved relative to the location of this Location entity itself. diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index 72170fb576..d4dca0c059 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -1,41 +1,351 @@ --- id: extending-the-model title: Extending the model -description: Documentation on Extending the model +description: Documentation on extending the catalog model --- -Backstage natively supports tracking of the following component -[`type`](descriptor-format.md)'s: +The Backstage catalog [entity data model](descriptor-format.md) is based on the +[Kubernetes objects format](https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/), +and borrows a lot of its semantics as well. This page describes those semantics +at a higher level and how to extend them to fit your organization. -- Services -- Websites -- Libraries -- Documentation -- Other +Backstage comes with a number of catalog concepts out of the box: -![](../../assets/software-catalog/bsc-extend.png) +- There are a number of builtin versioned _kinds_, such as `Component`, `User` + etc. These encapsulate the high level concept of an entity, and define the + schema for its entity definition data. +- An entity has both a _metadata_ object and a _spec_ object at the root. +- Each kind may or may not have a _type_. For example, there are several well + known types of component, such as `service` and `website`. These clarify the + more detailed nature of the entity, and may affect what features are exposed + in the interface. +- Entities may have a number of _[annotations](well-known-annotations.md)_ on + them. These can be added either by humans into the descriptor files, or added + by automated processes when the entity is ingested into the catalog. +- Entities may have a number of _labels_ on them. +- Entities may have a number of _relations_, expressing how they relate to each + other in different ways. -Since these types are likely not the only kind of software you will want to -track in Backstage, it is possible to add your own software types that fit your -organization's data model. Inside Spotify our model has grown significantly over -the years, and now includes ML models, Apps, data pipelines and many more. +We'll list different possibilities for extending this below. -## Adding a new type +## Adding a New apiVersion of an Existing Kind -TODO: Describe what changes are needed to add a new type that shows up in the -catalog. +Example intents: -## The Other type +> "I want to evolve this core kind, tweaking the semantics a bit so I will bump +> the apiVersion a step" + +> "This core kind is a decent fit but we want to evolve it at will so we'll move +> it to our own company's apiVersion space and use that instead of +> `backstage.io`." + +The `backstage.io` apiVersion space is reserved for use by the Backstage +maintainers. Please do not change or add versions within that space. + +If you add an [apiVersion](descriptor-format.md#apiversion-and-kind-required) +space of your own, you are effectively branching out from the underlying kind +and making your own. An entity kind is identified by the apiVersion + kind pair, +so even though the resulting entity may be similar to the core one, there will +be no guarantees that plugins will be able to parse or understand its data. See +below about adding a new kind. + +## Adding a New Kind + +Example intents: + +> "The kinds that come with the package are lacking. I want to model this other +> thing that is a poor fit for either of the builtins." + +> "This core kind is a decent fit but we want to evolve it at will so we'll move +> it to our own company's apiVersion space and use that instead of +> `backstage.io`." + +A [kind](descriptor-format.md#apiversion-and-kind-required) is an overarching +family, or an idea if you will, of entities that also share a schema. Backstage +comes with a number of builtin ones that we believe are useful for a large +variety of needs that one may want to model in Backstage. The primary ambition +is to map things to these kinds, but sometimes you may want or need to extend +beyond them. + +Introducing a new apiVersion is basically the same as adding a new kind. Bear in +mind that most plugins will be compiled against the builtin +`@backstage/catalog-model` package and have expectations that kinds align with +that. + +The catalog backend itself, from a storage and API standpoint, does not care +about the kind of entities it stores. Extending with new kinds is mainly a +matter of permitting them to pass validation when building the backend catalog +using the `CatalogBuilder`, and then to make plugins be able to understand the +new kind. + +For the consuming side, it's a different story. Adding a kind has a very large +impact. The very foundation of Backstage is to attach behavior and views and +functionality to entities that we ascribe some meaning to. There will be many +places where code checks `if (kind === 'X')` for some hard coded `X`, and casts +it to a concrete type that it imported from a package such as +`@backstage/catalog-model`. + +If you want to model something that doesn't feel like a fit for either of the +builtin kinds, feel free to reach out to the Backstage maintainers to discuss +how to best proceed. + +If you end up adding that new kind, you must namespace its `apiVersion` +accordingly with a prefix that makes sense, typically based on your organization +name - e.g. `my-company.net/v1`. Also do pick a new `kind` identifier that does +not collide with the builtin kinds. + +## Adding a New Type of an Existing Kind + +Example intents: + +> "This is clearly a component, but it's of a type that doesn't quite fit with +> the ones I've seen before." + +> "We don't call our teams "team", can't we put "flock" as the group type?" + +Some entity kinds have a `type` field in its spec. This is where an organization +are free to express the variety of entities within a kind. This field is +expected to follow some taxonomy that makes sense for yourself. The chosen value +may affect what operations and views are enabled in Backstage for that entity. +Inside Spotify our model has grown significantly over the years, and our +component types now include ML models, apps, data pipelines and many more. It might be tempting to put software that doesn't fit into any of the existing -types into Other. There are a few reasons why we advise against this; firstly, -we have found that it is preferred to match the conceptual model that your -engineers have when describing your software. Secondly, Backstage helps your -engineers manage their software by integrating the infrastructure tooling -through plugins. Different plugins are used for managing different types of -components. +types into an Other catch-all type. There are a few reasons why we advise +against this; firstly, we have found that it is preferred to match the +conceptual model that your engineers have when describing your software. +Secondly, Backstage helps your engineers manage their software by integrating +the infrastructure tooling through plugins. Different plugins are used for +managing different types of components. For example, the [Lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse) only makes sense for Websites. The more specific you can be in how you model your software, the easier it is to provide plugins that are contextual. + +Adding a new type takes relatively little effort and carries little risk. Any +type value is accepted by the catalog backend, but plugins may have to be +updated if you want particular behaviors attached to that new type. + +## Changing the Validation Rules for The Entity Envelope or Metadata Fields + +Example intents: + +> "We want to import our old catalog but the default set of allowed characters +> for a metadata.name are too strict." + +> "I want to change the rules for annotations so that I'm allowed to store any +> data in annotation values, not just strings." + +After pieces of raw entity data have been read from a location, they are passed +through a fixed number of so called `Validators`, as part of the entity policy +check step. They ensure that the types and syntax of the base envelope and +metadata make sense - in short, things that aren't entity-kind-specific. Some or +all of these validators can be replaced when building the backend catalog using +the `CatalogBuilder`. + +The risk and impact of this type of extension varies, based on what it is that +you want to do. For example, extending the valid character set for kinds, +namespaces and names can be fairly harmless, with a few notable exceptions - +there is code that expects these to never ever contain a colon or slash, for +example, and introducing URL-unsafe characters risks breaking plugins that +aren't careful about encoding arguments. Supporting non-strings in annotations +may be possible but has not yet been tried out in the real world - there is +likely to be some level of plugin breakage that can be hard to predict. + +Before making this kind of extension, we recommend that you contact the +Backstage maintainers or a support partner to discuss your use case. + +## Changing the Validation Rules for Core Entity Fields + +Example intent: + +> "I don't like that the owner is mandatory. I'd like it to be optional." + +After reading and policy-checked entity data from a location, it is sent through +the processor chain looking for processors that implement the +`validateEntityKind` step, to see that the data is of a known kind and abides by +its schema. There is a builtin processor that implements this for all known core +kinds and matches the data against their fixed validation schema. This processor +can be replaced when building the backend catalog using the `CatalogBuilder`, +with a processor of your own that validates the data differently. + +This type of extension is high risk, and may have high impact across the +ecosystem depending on the type of change that is made. It is therefore not +recommended in normal cases. There will be a large number of plugins and +processors - and even the core itself - that make assumptions about the shape of +the data and import the typescript data type from the `@backstage/catalog-model` +package. + +## Adding New Fields to the Metadata Object + +Example intent: + +> "Our entities have this auxiliary property that I would like to express for +> several entity kinds and it doesn't really fit as a spec field." + +The metadata object is currently left open for extension. Any unknown fields +found in the metadata will just be stored verbatim in the catalog. However we +want to caution against extending the metadata excessively. Firstly, you run the +risk of colliding with future extensions to the model. Secondly, it is common +that this type of extension lives more comfortably elsewhere - primarily in the +metadata labels or annotations, but sometimes you even may want to make a new +component type or similar instead. + +There are some situations where metadata can be the right place. If you feel +that you have run into such a case and that it would apply to others, do feel +free to contact the Backstage maintainers or a support partner to discuss your +use case. Maybe we can extend the core model to benefit both you and others. + +## Adding New Fields to the Spec Object of an Existing Kind + +Example intent: + +> "The builtin Component kind is fine but we want to add an additional field to +> the spec for describing whether it's in prod or staging." + +A kind's schema validation typically doesn't forbid "unknown" fields in an +entity `spec`, and the catalog will happily store whatever is in it. So doing +this will usually work from the catalog's point of view. + +Adding fields like this is subject to the same risks as mentioned about metadata +extensions above. Firstly, you run the risk of colliding with future extensions +to the model. Secondly, it is common that this type of extension lives more +comfortably elsewhere - primarily in the metadata labels or annotations, but +sometimes you even may want to make a new component type or similar instead. + +There are some situations where the spec can be the right place. If you feel +that you have run into such a case and that it would apply to others, do feel +free to contact the Backstage maintainers or a support partner to discuss your +use case. Maybe we can extend the core model to benefit both you and others. + +## Adding a New Annotation + +Example intents: + +> "Our custom made build system has the concept of a named pipeline-set, and we +> want to associate individual components with their corresponding pipeline-sets +> so we can show their build status." + +> "We have an alerting system that automatically monitors service health, and +> there's this integration key that binds the service to an alerts pool. We want +> to be able to show the ongoing alerts for our services in Backstage so it'd be +> nice to attach that integration key to the entity somehow." + +Annotations are mainly intended to be consumed by plugins, for feature detection +or linking into external systems. Sometimes they are added by humans, but often +they are automatically generated at ingestion time by processors. There is a set +of [well-known annotations](well-known-annotations.md), but you are free to add +additional ones. This carries no risk or impact to other systems as long as you +abide by the following naming rules. + +- The `backstage.io` annotation prefix is reserved for use by the Backstage + maintainers. Reach out to us if you feel that you would like to make an + addition to that prefix. +- Annotations that pertain to a well known third party system should ideally be + prefixed with a domain, in a way that makes sense to a reader and connects it + clearly to the system (or the maker of the system). For example, you might use + a `pagerduty.com` prefix for pagerduty related annotations, but maybe not + `ldap.com` for LDAP annotations since it's not directly affiliated with or + owned by an LDAP foundation/company/similar. +- Annotations that have no prefix at all, are considered local to your Backstage + instance and can be used freely as such, but you should not make use of them + outside of your organization. For example, if you were to open source a plugin + that generates or consumes annotations, then those annotations must be + properly prefixed with your company domain or a domain that pertains to the + annotation at hand. + +## Adding a New Label + +Example intents: + +> "Our process reaping system wants to periodically scrape for components that +> have a certain property." + +> "It'd be nice if our service owners could just tag their components somehow to +> let the CD system know to automatically generate SRV records or not for that +> service." + +Labels are mainly intended to be used for filtering of entities, by external +systems that want to find entities that have some certain property. This is +sometimes used for feature detection / selection. An example could be to add a +label `deployments.my-company.net/register-srv: "true"`. + +At the time of writing this, the use of labels is very limited and we are still +settling together with the community on how to best use them. If you feel that +your use case fits the labels best, we would appreciate if you let the Backstage +maintainers know. + +You are free to add labels. This carries no risk or impact to other systems as +long as you abide by the following naming rules. + +- The `backstage.io` label prefix is reserved for use by the Backstage + maintainers. Reach out to us if you feel that you would like to make an + addition to that prefix. +- Labels that pertain to a well known third party system should ideally be + prefixed with a domain, in a way that makes sense to a reader and connects it + clearly to the system (or the maker of the system). For example, you might use + a `pagerduty.com` prefix for pagerduty related labels, but maybe not + `ldap.com` for LDAP labels since it's not directly affiliated with or owned by + an LDAP foundation/company/similar. +- Labels that have no prefix at all, are considered local to your Backstage + instance and can be used freely as such, but you should not make use of them + outside of your organization. For example, if you were to open source a plugin + that generates or consumes labels, then those labels must be properly prefixed + with your company domain or a domain that pertains to the label at hand. + +## Adding a New Relation Type + +Example intents: + +> "We have this concept of service maintainership, separate from ownership, that +> we would like to make relations to individual users for." + +> We feel that we want to explicitly model the team-to-global-department mapping +> as a relation, because it is core to our org setup and we frequently query for +> it. + +Any processor can emit relations for entities as they are being processed, and +new processors can be added when building the backend catalog using the +`CatalogBuilder`. They can emit relations based on the entity data itself, or +based on information gathered from elsewhere. Relations are directed and go from +a source entity to a target entity. They are also tied to the entity that +originated them - the one that was subject to processing when the relation was +emitted. Relations may be dangling (referencing something that does not actually +exist by that name in the catalog), and callers need to be aware of that. + +There is a set of [well-known relations](well-known-relations.md), but you are +free to emit your own as well. You cannot change the fact that they are directed +and have a source and target that have to be an +[entity reference](references.md), but you can invent your own types. You do not +have to make any changes to the catalog backend in order to accept new relation +types. + +At the time of writing this, we do not have any namespacing/prefixing scheme for +relation types. The type is also not validated to contain only some particular +set of characters. Until rules for this are settled, you should stick to using +only letters, dashes and digits, and to avoid collisions with future core +relation types, you may want to prefix the type somehow. For example: +`myCompany-maintainerOf` + `myCompany-maintainedBy`. + +If you have a suggestion for a relation type to be elevated to the core +offering, reach out to the Backstage maintainers or a support partner. + +## Using a Well-Known Relation Type for a New Purpose + +Example intents: + +> "The ownerOf/ownedBy relation types sound like a good fit for expressing how +> users are technical owners of our company specific ServiceAccount kind, and we +> want to reuse those relation types for that." + +At the time of writing, this is uncharted territory. If the documented use of a +relation states that one end of the relation commonly is a User or a Group, for +example, then consumers are likely to have conditional statements on the form +`if (x.kind === 'User') {} else {}`, which get confused when an unexpected kind +appears. + +If you want to extend the use of an established relation type in a way that has +an effect outside of your organization, reach out to the Backstage maintainers +or a support partner to discuss risk/impact. It may even be that one end of the +relation could be considered for addition to the core. diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 325bed5e4a..e7d253f11e 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -1,8 +1,8 @@ --- id: external-integrations title: External integrations -description: Documentation on External integrations to integrate systems -with Backstage +# prettier-ignore +description: Documentation on External integrations to integrate systems with Backstage --- Backstage natively supports importing catalog data through the use of diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 08e6260e80..70541b85db 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -2,8 +2,8 @@ id: software-catalog-overview title: Backstage Service Catalog (alpha) sidebar_label: Overview -description: The Backstage Service Catalog — actually, a software catalog, since -it includes more than just services +# prettier-ignore +description: The Backstage Service Catalog — actually, a software catalog, since it includes more than just services --- ## What is a Service Catalog? diff --git a/docs/features/software-catalog/references.md b/docs/features/software-catalog/references.md index e438ddc977..d347035950 100644 --- a/docs/features/software-catalog/references.md +++ b/docs/features/software-catalog/references.md @@ -51,7 +51,7 @@ spec: type: service lifecycle: experimental owner: group:pet-managers - implementsApis: + providesApis: - petstore - internal/streetlights - hello-world @@ -66,7 +66,7 @@ catalog that is of kind `Group`, namespace `default` (which, actually, also can be left out in its own yaml file because that's the default value there too), and name `pet-managers`. -The entries in `implementsApis` are also references. In this case, none of them +The entries in `providesApis` are also references. In this case, none of them needs to specify a kind since we know from the context that that's the only kind that's supported here. The second entry specifies a namespace but the other ones don't, and in this context, the default is to refer to the same namespace as the diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 5cb6837340..3627c74b9a 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -2,9 +2,8 @@ id: well-known-annotations title: Well-known Annotations on Catalog Entities sidebar_label: Well-known Annotations -description: Documentation on lists a number of well known Annotations, that -have defined semantics. They can be attached to catalog entities and consumed -by plugins as needed +# prettier-ignore +description: Documentation that lists a number of well known Annotations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. --- This section lists a number of well known @@ -191,9 +190,25 @@ metadata: ``` 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 +when ingesting the entity from LDAP. Not all of them may be present, depending on what attributes that the server presented at ingestion time. +### graph.microsoft.com/tenant-id, graph.microsoft.com/group-id, graph.microsoft.com/user-id + +```yaml +# Example: +metadata: + annotations: + graph.microsoft.com/tenant-id: 6902611b-ffc1-463f-8af3-4d5285dc057b + graph.microsoft.com/group-id: c57e8ba2-6cc4-1039-9ebc-d5f241a7ca21 + graph.microsoft.com/user-id: 2de244b5-104b-4e8f-a3b8-dce3c31e54b6 +``` + +The value of these annotations are the corresponding attributes that were found +when ingesting the entity from the Microsoft Graph API. Not all of them may be +present, depending on what attributes that the server presented at ingestion +time. + ### sonarqube.org/project-key ```yaml @@ -223,22 +238,9 @@ annotation, with the same value format. ### backstage.io/definition-at-location -This annotation allowed to load the API definition from another location. Now -placeholders can be used instead: - -``` -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: petstore - description: The Petstore API -spec: - type: openapi - lifecycle: production - owner: petstore@example.com - definition: - $text: https://petstore.swagger.io/v2/swagger.json -``` +This annotation allowed to load the API definition from another location. Use +[substitution](./descriptor-format.md#substitutions-in-the-descriptor-format) +instead. ## Links diff --git a/docs/features/software-catalog/well-known-relations.md b/docs/features/software-catalog/well-known-relations.md new file mode 100644 index 0000000000..54f7833d1b --- /dev/null +++ b/docs/features/software-catalog/well-known-relations.md @@ -0,0 +1,93 @@ +--- +id: well-known-relations +title: Well-known Relations between Catalog Entities +sidebar_label: Well-known Relations +# prettier-ignore +description: Documentation that lists a number of well known Relations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. +--- + +This section lists a number of well known +[entity relation types](descriptor-format.md#common-to-all-kinds-relations), +that have defined semantics. They can be attached to catalog entities and +consumed by plugins as needed. + +If you are looking to extend the set of relations, see +[Extending the model](extending-the-model.md). + +## Relations + +This is a (non-exhaustive) list of relations that are known to be in active use. + +Each relation has a _source_ (implicitly: the entity that holds the relation), a +_target_ (the entity to which the source has a relation), and a _type_ that +tells what relation the source has with the target. The relation is directional; +there are commonly pairs of relation types and the entity at the other end will +have the opposite relation in the opposite direction (e.g. when querying for +`A`, you will see `A.ownedBy.B`, and when querying `B`, you will see +`B.ownerOf.A`). + +### `ownedBy` and `ownerOf` + +An ownership relation where the owner is usually an organizational entity +([User](descriptor-format.md#kind-user) or +[Group](descriptor-format.md#kind-group)), and the other entity can be anything. + +In Backstage, the owner of an entity is the singular entity (commonly a team) +that bears ultimate responsibility for the entity, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this relation is for display purposes in Backstage, so that people looking at +catalog entities can get an understanding of to whom this entity belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +entity, but there will always be one ultimate owner. + +This relation is commonly generated based on `spec.owner` of the owned entity, +where present. + +### `providesApi` and `apiProvidedBy` + +A relation with an [API](descriptor-format.md#kind-api) entity, typically from a +[Component](descriptor-format.md#kind-component) or +[System](descriptor-format.md#kind-system). + +These relations express that a component or system exposes an API - meaning that +it hosts callable endpoints from which you can consume that API. + +This relation is commonly generated based on `spec.providesApis` of the +component or system in question. + +### `consumesApi` and `apiConsumedBy` + +A relation with an [API](descriptor-format.md#kind-api) entity, typically from a +[Component](descriptor-format.md#kind-component) or +[System](descriptor-format.md#kind-system). + +These relations express that a component or system consumes an API - meaning +that it depends on endpoints of the API. + +This relation is commonly generated based on `spec.consumesApis` of the +component or system in question. + +### `dependsOn` and `dependencyOf` + +A relation denoting a dependency on another entity. + +This relation is a general expression of being in need of that other entity for +an entity to function. It can for example be used to express that a website +component needs a library component as part of its build, or that a service +component uses a persistent storage resource. + +### `parentOf` and `childOf` + +A parent/child relation to build up a tree, used for example to describe the +organizational structure between [Groups](descriptor-format.md#kind-group). + +This relation is commonly based on `spec.parent` and/or `spec.children`. + +### `memberOf` and `hasMember` + +A membership relation, typically for [Users](descriptor-format.md#kind-user) in +[Groups](descriptor-format.md#kind-group). + +This relation is commonly based on `spec.memberOf`. diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 75a75003f7..e2b69d6767 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -55,6 +55,10 @@ contains more information about the required fields. Once we have a `template.yaml` ready, we can then add it to the service catalog for use by the scaffolder. +_NOTE_: When the `publish` step is completed, it is currently assumed by the +scaffolder that the final repository should contain a `catalog-info.yaml` in +order to register this with the Catalog in Backstage. + Currently the catalog supports loading definitions from GitHub + Local Files. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index f1cf637997..16131a1ded 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -122,3 +122,12 @@ migrate Spotify's existing TechDocs features to open source. https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend [techdocs/container]: https://github.com/backstage/techdocs-container [techdocs/cli]: https://github.com/backstage/techdocs-cli + +## Feedback + +We have created a sweet and short TechDocs user survey - +https://docs.google.com/forms/d/e/1FAIpQLSdn5Vn3MQhCdyYRuW8cMzZkMQF0bFxXYN168gZRvESLfJWVVg/viewform + +This is to gather inputs from you (the Backstage community) which will help us +best serve TechDocs adopters and existing users. Your inputs will shape our +roadmap and we will share it in the open. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 926ec238e1..3969002a46 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -1,7 +1,153 @@ --- id: architecture -title: Architecture -description: Documentation on Architecture +title: TechDocs Architecture +description: Documentation on TechDocs Architecture --- -![TechDocs Big Picture](../../assets/techdocs/techdocs_big_picture.png) +## Basic (out-of-the-box) + +When you deploy Backstage (with TechDocs enabled by default), you get a basic +out-of-the box experience. + +TechDocs Architecture diagram + +> Note: See below for our recommended deployment architecture which takes care +> of stability, scalability and speed. + +When you open a TechDocs site in Backstage, the +[TechDocs Reader](./concepts.md#techdocs-reader) makes a request to +`techdocs-backend` with the entity ID and the path of the current page you are +looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to +render on the page in TechDocs/Backstage. + +The static files consist of HTML, CSS and Images generated by MkDocs. We remove +all the JavaScript before adding them to Backstage for security reasons. And +there are some additional techdocs metadata JSON files that TechDocs needs to +render a site. + +The TechDocs Reader then applies a list of "Transformers" (see +[Concepts](./concepts.md)) which modify the generated static HTML files for a +number of use cases e.g. Remove certain headers, filter out some HTML tags, etc. + +Currently, we use the Backstage server's (or techdocs-backend's) local file +system to store the generated files. Publishing to an external storage system +(AWS S3, GCS, etc.) is also possible, but has not been implemented yet. + +A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch +files from its source repository for docs site generation. 1. By using Git +and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is +heavily in progress. Please reach out to us on Discord in the #docs-like-code +channel to talk about it. + +## Recommended deployment + +This is how we recommend deploying TechDocs in production environment. + +TechDocs Architecture diagram + +The key difference in the recommended deployment approach is where the docs are +built. + +We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We +recommend using a CI/CD pipeline with the repository that has a dedicated +step/job to generate docs for TechDocs. The generated static files are then +stored in a cloud storage solution of your choice. +[Track progress here](https://github.com/backstage/backstage/issues/3096). + +Similar to how it is done in the Basic setup, the TechDocs Reader requests +`techdocs-backend` plugin for the docs site. `techdocs-backend` then requests +your configured storage solution for the necessary files and returns them to +TechDocs Reader. + +We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to +generate docs in your CI/CD system. +[Track progress here.](https://github.com/backstage/backstage/issues/3400) You +will be able to use `techdocs-cli` to generate docs and publish the generated +docs site files to your cloud storage system. + +Note about caching: We have noticed internally that some storage providers can +be quite slow, which is why we are recommending a cache that sits between the +TechDocs Reader and the Storage. + +_Feel free to suggest better ideas to us in #docs-like-code channel in Discord +or via a GitHub issue._ + +### Security consideration + +Our biggest security concern is managing the access to the docs in the cloud +storage. We also want to have only one security solution for all different types +of storage (GCS, AWS, custom SFTP server, etc.) Restricting access to the +storage and only allowing `techdocs-backend` to fetch files is a good way to +achieve this. + +This would also allow us to use the access control management Backstage when +that is ready. +[Track progress here.](https://github.com/backstage/backstage/issues/3218) + +In theory, you can directly enable TechDocs Reader to read from your storage. +But, you will have to think about how to do it without the docs being public and +how access to user groups is managed. + +For cloud storage access tokens, `techdocs-backend` only needs a token with Read +permissions. But in your CI/CD system, there needs to be a token with Write +permissions to publish the generated docs site files. + +## FAQs + +**Q: Why do you have separate "basic" and "recommended" deployment approaches?** + +A: The basic or out-of-the-box setup is what you get when you create a new app +or do a git clone of the Backstage repository. We want the first experience to +_just work magically_ so that you can have your first experience with TechDocs +which is smooth. However, if you decide to deploy Backstage/TechDocs for +production use, the basic setup would work but there are going to be downsides +as you scale with the number of documentation sites and sizes of them. So you +would want to make sure the deployment is as stable as possible. Hence there is +a recommended approach. There can be even more deployment approaches to TechDocs +and we welcome such "Alternative" ideas from the community. + +**Q: Why don't you recommend techdocs-backend local filesystem to serve static +files?** + +A: It would make scaling a Backstage instance harder. Think about the case where +we have distributed Backstage deployments. Using a separate file storage system +for TechDocs makes it easier to do some operations like delete a docs site and +wipe its contents. + +**Q: Why aren't docs built on the fly i.e. when users visits a page, generate +docs site in real-time?** + +A: Generating the content from Markdown on the fly is not optimal (although that +is how the basic out-of-the-box setup is implemented). Storage solutions act as +a cache for the generated static content. TechDocs is also currently built on +MkDocs which does not allow us to generate docs per-page, so we would have to +build all docs for a entity on every request. + +# Future work + +_Ideas here are far fetched and not in the project's milestone for near future +(~6 months)._ + +We currently depend on MkDocs to parse doc sites written in Markdown. And we +store the generated static assets and re-use it later to render in Backstage. A +better (futuristic) approach will be to directly parse whatever type of source +files you have in your docs repository and directly render in Backstage in +real-time. + +# Features status + +Status of all the features mentioned above. + +**In place ✅** + +- Basic setup with techdocs-backend file server as storage. + +**Work in progress 🚧** + +- Basic setup with cloud storage solution. + +**Not implemented yet ❌** + +- `techdocs-cli` is able to generate docs in CI/CD environment. +- `techdocs-cli` is able to publish docs site to any storage. +- `techdocs-backend` integration with Backstage access control management. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md new file mode 100644 index 0000000000..b3cb349778 --- /dev/null +++ b/docs/features/techdocs/configuration.md @@ -0,0 +1,73 @@ +--- +id: configuration +title: TechDocs Configuration Options +description: + Reference documentation for configuring TechDocs using app-config.yaml +--- + +Using the `app-config.yaml` in the Backstage app, you can configure TechDocs +using several options. This page serves as a reference to all the available +configuration options for TechDocs. + +```yaml +# File: app-config.yaml + +techdocs: + + # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. + + requestUrl: http://localhost:7000/api/techdocs + + + # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware + # to serve files from either a local directory or an External storage provider. + + storageUrl: http://localhost:7000/api/techdocs/static/docs + + + # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to + # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). + # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running + # into Docker in Docker situation. Read more here + # https://backstage.io/docs/features/techdocs/getting-started#disable-docker-in-docker-situation-optional + + generators: + techdocs: 'docker' + + + # techdocs.builder can be either 'local' or 'external. + # If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage + # and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture. + # If builder is set to 'external', techdocs-backend will only fetch the docs and will NOT try to generate and publish. In this case of 'external', + # we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository). This is the "Recommended" setup of + # the architecture. Read more here https://backstage.io/docs/features/techdocs/architecture + + builder: 'local' + + + # techdocs.publisher is used to configure the Storage option, whether you want to use the local filesystem to store generated docs + # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. + + publisher: + + # techdocs.publisher.type can be - 'local' or 'googleGcs' (awsS3, azureStorage, etc. to be available as well). + # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. + # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. + + type: 'local' + + + # Required when techdocs.publisher.type is set to 'googleGcs'. Skip otherwise. + + googleGcs: + # An API key is required to write to a storage bucket. + credentials: + $file: '/path/to/google_application_credentials.json', + + # Your GCP Project ID where the Cloud Storage Bucket is hosted. + projectId: 'gcp-project-id' + + # Cloud Storage Bucket Name + bucketName: 'techdocs-storage', + +``` diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index cbbbc1e6cd..e51c6a122e 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -95,7 +95,7 @@ environment is compatible with techdocs. You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g. apt). See our -[Dockerfile](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/Dockerfile) +[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) for the latest requirements. You should be trying to match your Dockerfile with this one. @@ -104,7 +104,7 @@ Note: We recommend Python version 3.7 or higher. Caveat: Please install the `mkdocs-techdocs-core` package after all other Python packages. The order is important to make sure we get correct version of some of the dependencies. For example, we want `Markdown` version to be -[3.2.2](https://github.com/spotify/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). +[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). You can also explicitly install `Markdown==3.2.2` after installing all other Python packages. diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md new file mode 100644 index 0000000000..55352b6c00 --- /dev/null +++ b/docs/features/techdocs/using-cloud-storage.md @@ -0,0 +1,95 @@ +--- +id: using-cloud-storage +title: Using Cloud Storage for TechDocs generated files +description: Using Cloud Storage for TechDocs generated files +--- + +In the [TechDocs architecture](./architecture.md) you have the option to choose +where you want to store the Generated static files which TechDocs uses to render +documentation. In both the "Basic" and "Recommended" setup, you can add cloud +storage providers like Google GCS, Amazon AWS S3, etc. By default, TechDocs uses +the local filesystem of the `techdocs-backend` plugin in the "Basic" setup. And +in the recommended setup, having one of the cloud storage is a prerequisite. +Read more on the TechDocs Architecture documentation page. + +On this page you can read how to enable them. + +## Configuring Google GCS Bucket with TechDocs + +Follow the +[official Google Cloud documentation](https://googleapis.dev/nodejs/storage/latest/index.html#quickstart) +for the latest instructions on the following steps involving GCP. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'googleGcs'`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' +``` + +**2. GCP (Google Cloud Platform) Project** + +Create or choose a dedicated GCP project. Set +`techdocs.publisher.googleGcs.projectId` to the project ID. + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + projectId: 'gcp-project-id +``` + +**3. Service account API key** + +Create a new Service Account and a key associated with it. In roles of the +service account, use "Storage Admin". + +If you want to create a custom role, make sure to include both `get` and +`create` permissions for both "Objects" and "Buckets". See +https://cloud.google.com/storage/docs/access-control/iam-permissions + +A service account can have many keys. Open your newly created account's page (in +IAM & Admin console), and create a new key. Use JSON format for the key. + +A `.json` file will be downloaded. This is the secret +key TechDocs will use to make API calls. Make it available in your Backstage +server and/or your local development server and set it in the app config +`techdocs.publisher.googleGcs.credentials`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + projectId: 'gcp-project-id' + credentials: + $file: '/path/to/google_application_credentials.json' +``` + +**4. GCS Bucket** + +Create a dedicated bucket for TechDocs sites. techdocs-backend will publish +documentation to this bucket. TechDocs will fetch files from here to serve +documentation in Backstage. + +Set the name of the bucket to `techdocs.publisher + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + projectId: 'gcp-project-id' + credentials: + $file: '/path/to/google_application_credentials.json' + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**5. That's it!** + +Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to +store the static generated documentation files. diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index 1d02f147c1..64b9e772d1 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -6,7 +6,53 @@ description: Documentation on How Configuring App with plugins ## Adding existing plugins to your app -Coming soon! +The following steps assume that you have created a new Backstage app and want to +add an existing plugin to it. We are using the +[CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md) +plugin in this example. + +1. Add the plugin's npm package to the repo: + +```bash +yarn add @backstage/plugin-circleci +``` + +2. Add the plugin itself: + +```js +// packages/app/src/plugins.ts +export { plugin as Circleci } from '@backstage/plugin-circleci'; +``` + +3. Register the plugin router: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; + +// Then somewhere inside +} +/>; +``` + +Note that stand-alone plugins that are not "attached" to the Software Catalog +would be added outside the `EntityPage`. + +4. [Optional] Add proxy config: + +```yaml +// app-config.yaml +proxy: + '/circleci/api': + target: https://circleci.com/api/v1.1 + headers: + Circle-Token: + $env: CIRCLECI_AUTH_TOKEN +``` ### Adding a plugin page to the Sidebar diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index c35eded6a5..52ea90db5b 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -14,7 +14,7 @@ need to run Backstage in your own environment. To create a Backstage app, you will need to have [Node.js](https://nodejs.org/en/download/) Active LTS Release installed -(currently v12). +(currently v14). Backstage provides a utility for creating new apps. It guides you through the initial setup of selecting the name of the app and a database for the backend. @@ -38,6 +38,42 @@ app-folder is the name that was provided when prompted. Inside that directory, it will generate all the files and folder structure needed for you to run your app. +### Linking in local Backstage packages + +It can often be useful to try out changes to the packages in the main Backstage +repo within your own app. For example if you want to make modifications to +`@backstage/core` and try them out in your app. + +To link in external packages, add them to your `package.json` and `lerna.json` +workspace paths. These can be either relative or absolute paths with or without +globs. For example: + +```json +"packages": [ + "packages/*", + "plugins/*", + "../backstage/packages/core", // New path added to work on @backstage/core +], +``` + +Then reinstall packages to make yarn set up symlinks: + +```bash +yarn install +``` + +With this in place you can now modify the `@backstage/core` package within the +main repo, and have those changes be reflected and tested in your app. Simply +run your app using `yarn start` as normal. + +Note that for backend packages you need to make sure that linked packages are +not dependencies of any non-linked package. If you for example want to work on +`@backstage/backend-common`, you need to also link in other backend plugins and +packages that depend on `@backstage/backend-common`, or temporarily disable +those plugins in your backend. This is because the transformation of backend +module tree stops whenever a non-local package is encountered, and from that +point node will `require` packages directly for that entire module subtree. + ### Troubleshooting The create app command doesn't always work as expected, this is a collection of @@ -51,7 +87,7 @@ You may encounter the following error message: Couldn't find any versions for "file-saver" that matches "eligrey-FileSaver.js-1.3.8.tar.gz-art-external" ``` -This is likely because you have a globally configured NPM proxy, which breaks +This is likely because you have a globally configured npm proxy, which breaks the installation of the `material-table` dependency. This is a known issue and being worked on in `material-table`, but for now you can work around it using the following: diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 7fc80b625c..16c837d676 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -10,7 +10,7 @@ you're planning to do. Creating a standalone instance makes it simpler to customize the application for your needs whilst staying up to date with the project. You will also depend on -`@backstage` packages from NPM, making the project much smaller. This is the +`@backstage` packages from npm, making the project much smaller. This is the recommended approach if you want to kick the tyres of Backstage or setup your own instance. diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index a30a918fd0..6de9d234e3 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -8,19 +8,19 @@ description: Documentation on How to run Backstage Locally - Node.js -First make sure you are using Node.js with an Active LTS Release, currently v12. +First make sure you are using Node.js with an Active LTS Release, currently v14. This is made easy with a version manager such as [nvm](https://github.com/nvm-sh/nvm) which allows for version switching. ```bash # Installing a new version -nvm install 12 -> Downloading and installing node v12.18.3... -> Now using node v12.18.3 (npm v6.14.6) +nvm install 14 +> Downloading and installing node v14.15.1... +> Now using node v14.15.1 (npm v6.14.8) # Checking your version node --version -> v12.18.3 +> v14.15.1 ``` - Yarn diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 5ff4d49a7c..71612cc5e4 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -39,7 +39,7 @@ If you have questions about the roadmap or want to provide feedback, we would love to hear from you! Please create an [Issue](https://github.com/backstage/backstage/issues/new/choose), ping us on [Discord](https://discord.gg/EBHEGzX) or reach out directly at -[alund@spotify.com](mailto:alund@spotify.com). +[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). Want to help out? Awesome ❤️ Head over to [CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md new file mode 100644 index 0000000000..c53193f063 --- /dev/null +++ b/docs/overview/stability-index.md @@ -0,0 +1,395 @@ +--- +id: stability-index +title: Stability Index +description: + An overview of the commitment to stability for different parts of the + Backstage codebase. +--- + +## Overview + +The purpose of the Backstage Stability Index is to communicate the stability of +various parts of the project. It is tracked using a scoring system where a +higher score indicates a higher level of stability and is a commitment to +smoother transitions between breaking changes. Importantly, the Stability Index +does not supersede [semver](https://semver.org/), meaning we will still adhere +to semver and only do breaking changes in minor releases as long as we are on +`0.x`. + +Each package or section is assigned a stability score between 0 and 3, with each +point building on top of the previous one: + +- **0** - Breaking changes are noted in the changelog, and documentation is + updated. +- **1** - The changelog entry includes a clearly documented upgrade path, + providing guidance for how to migrate previous usage patterns to the new + version. +- **2** - Breaking changes always include a deprecation phase where both the old + and the new APIs can be used in parallel. This deprecation must have been + released for at least two weeks before the deprecated API is removed in a + minor version bump. +- **3** - The time limit for the deprecation is 3 months instead of two weeks. + +TL;DR: + +- **0** - There's a changelog entry. +- **1** - There's a migration guide. +- **2** - 2 weeks of deprecation. +- **3** - 3 months of deprecation. + +## Packages + +### `example-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/app/) + +This is the `packages/app` package, and it serves as an example as well as +utility for local development in the main Backstage repo. + +Stability: `N/A` + +### `example-backend` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend/) + +This is the `packages/backend` package, and it serves as an example as well as +utility for local development in the main Backstage repo. + +Stability: `N/A` + +### `backend-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend-common/) + +A collection of common helpers to be used by both backend plugins, and for +constructing backend packages. + +Stability: `1` + +### `catalog-client` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) + +An HTTP client for interacting with the catalog backend. Usable both in frontend +and Backend. + +Stability: `0`. This is a very new addition and we have some immediate changes +planned. + +### `catalog-model` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) + +Contains the core catalog model, and utilities for working with entities. Usable +both in frontend and Backend. + +Stability: `2`. The catalog model is evolving, but because of the broad usage we + +want to ensure some stability. + +### `cli` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli/) + +The main toolchain used for Backstage development. The various CLI commands and +options passed to those commands, as well as the environment variables read by +the CLI, are considered to be the interface that the stability index refers to. +The build output may change over time and is not considered a breaking change +unless it is likely to affect external tooling. + +Stability: `2` + +### `cli-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli-common/) + +Lightweight utilities used by the various Backstage CLIs, not intended for +external use. + +Stability: `N/A` + +### `config` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config/) + +Provides the logic and interfaces for reading static configuration. + +Stability: `2` + +### `config-loader` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config-loader/) + +Used to load in static configuration, mainly for use by the CLI and +@backstage/backend-common. + +Stability: `1`. Mainly intended for internal use. + +### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/) + +#### Section: React Components + +All of the React components exported from `src/components/` and `src/layout/` + +Stability: `1`. These components have not received a proper review of the API, +but we also want to ensure stability. + +#### Section: Plugin API + +The parts of the core API that are used by plugins, and the way plugins expose +functionality to apps and other plugins. Includes for example `createPlugin`, +`createRouteRef`, `createApiRef`. + +Stability: `2`. There are planned breaking changes around the way that plugins +expose features and do routing. We still commit to keeping a short deprecation +period so that plugins outside of the main repo have time to migrate. + +#### Section: App API + +The APIs used exclusively in the app, such as `createApp` and the system icons. + +Stability: `2` + +#### Section: Utility API Definitions + +The type declarations of the core Utility APIs. + +Stability: `2`. Changes to the Utility API type declarations need time to +propagate. + +#### Section: Utility API Implementations + +The interfaces and default implementations for various Utility APIs, such as +ErrorApi, IdentityApi, the auth APIs, etc. + +Stability: `1`. Most changes to the core utility APIs will not lead to +widespread breaking changes since most apps rely on the default implementations. + +### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) + +The non-visual parts of @backstage/core. Everything in this packages is +re-exported from @backstage/core, and this package should not be used directly. + +Stability: See @backstage/core + +### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) + +The CLI used to scaffold new Backstage projects. + +Stability: `2` + +### `dev-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) + +Provides utilities for developing plugins in isolation. + +Stability: `0`. This package is largely broken and needs updates. + +### `docgen` [GitHub](https://github.com/backstage/backstage/tree/master/packages/docgen/) + +Internal CLI utility for generating API Documentation. + +Stability: `N/A` + +### `e2e-test` [GitHub](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) + +Internal CLI utility for running e2e tests. + +Stability: `N/A` + +### `integration` [GitHub](https://github.com/backstage/backstage/tree/master/packages/integration/) + +Provides shared utilities for managing integrations towards different types of +third party systems. This package is currently internal and its functionality +will likely be exposed via separate APIs in the future. + +Some of the functionality in this package is not available elsewhere yes, so if +it's necessary it can be used, but there will be breaking changes. + +Stability: `0` + +### `storybook` [GitHub](https://github.com/backstage/backstage/tree/master/packages/storybook/) + +Internal storybook build for publishing stories to +https://backstage.io/storybook + +Stability: `N/A` + +### `test-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils/) + +Utilities for writing tests for Backstage plugins and apps. + +Stability: `2` + +### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) + +Internal testing utilities that are separated out for usage in +@backstage/core-api. All exports are re-exported by @backstage/test-utils. This +package should not be depended on directly. + +Stability: See @backstage/test-utils + +### `theme` [GitHub](https://github.com/backstage/backstage/tree/master/packages/theme/) + +The core Backstage MUI theme along with customization utilities. + +#### Section: TypeScript + +This is the TypeScript API exported by the theme package. + +Stability: `2` + +#### Section: Visual Theme + +The visual theme exported by the theme packages, where for example changing a +color could be considered a breaking change. + +Stability: `1` + +## Plugins + +Plugins are rarely marked as stable as the `@backstage/core` plugin API is under +heavy development. + +Many backend plugins are split into "REST API" and "TypeScript Interface" +sections. The "TypeScript Interface" refers to the API used to integrate the +plugin into the backend. + +Any plugin that is not listed below is untracked and can generally be considered +unstable with a score of `0`. Open a Pull Request if you want your plugin to be +added! + +### `api-docs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) + +Components to discover and display API entities as an extension to the catalog +plugin. + +Stability: `0` + +### `app-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) + +A backend plugin that can be used to serve the frontend app and inject +configuration. + +Stability: `2` + +### `auth-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) + +A backend plugin that implements the backend portion of the various +authentication flows used in Backstage. + +#### Section: REST API + +Stability: `2` + +#### Section: TypeScript Interface + +Stability: `1` + +### `catalog` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog/) + +The frontend plugin for the catalog, with the table and building blocks for the +entity pages. + +Stability: `1`. We're planning some work to overhaul how entity pages are +constructed. + +### `catalog-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) + +The backend API for the catalog, also exposes the processing subsystem for +customization of the catalog. Powers the @backstage/plugin-catalog frontend +plugin. + +#### Section: REST API + +Stability: `1`. There are plans to remove and rework some endpoints. + +#### Section: TypeScript Interface + +Stability: `1`. There are plans to rework parts of the Processor interface. + +### `catalog-graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) + +Provides the catalog schema and resolvers for the graphql backend. + +Stability: `0`. Under heavy development and subject to change. + +### `explore` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/explore/) + +A frontend plugin that introduces the concept of exploring internal and external +tooling in an organization. + +Stability: `0`. Only an example at the moment and not customizable. + +### `graphiql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) + +Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. + +Stability: `1` + +### `graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphql/) + +A backend plugin that provides + +Stability: `0`. Under heavy development and subject to change. + +### `kubernetes` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) + +The frontend component of the Kubernetes plugin, used to browse and visualize +Kubernetes resources. + +Stability: `1`. + +### `kubernetes-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) + +The backend component of the Kubernetes plugin, used to fetch Kubernetes +resources from clusters and associate them with entities in the Catalog. + +Stability: `1`. + +### `proxy-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) + +A backend plugin used to set up proxying to other endpoints based on static +configuration. + +Stability: `1` + +### `register-component` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/register-component/) + +A frontend plugin that allows the user to register entity locations in the +catalog. + +Stability: `0`. This plugin is likely to be replaced by a generic entity import +plugin instead. + +### `scaffolder` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) + +The frontend scaffolder plugin where one can browse templates and initiate +scaffolding jobs. + +Stability: `1` + +### `scaffolder-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) + +The backend scaffolder plugin that provides an implementation for templates in +the catalog. + +Stability: `1`. There is planned work to rework the scaffolder in +https://github.com/backstage/backstage/issues/2771. + +### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) + +Visualize the your company's official guidelines of different areas of software +development. + +Stability: `0` + +### `techdocs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) + +The frontend component of the TechDocs plugin, used to browse technical +documentation of entities. + +Stability: `1` + +### `techdocs-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) + +The backend component of the TechDocs plugin, used to transform and serve +TechDocs. + +Stability: `0` + +### `user-settings` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) + +A frontend plugin that provides a page where the user can tweak various +settings. + +Stability: `1` + +### `welcome` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/welcome/) + +A plugin that can be used to welcome the user to Backstage. + +Stability: `0`. This used to be the start page for the example app, but has been +replaced by the catalog plugin. It is still viewable at `/welcome` but may be +removed. diff --git a/docs/overview/vision.md b/docs/overview/vision.md index df75a4f0a6..c17e2b17ba 100644 --- a/docs/overview/vision.md +++ b/docs/overview/vision.md @@ -19,5 +19,5 @@ We are working on making Backstage the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product -vision, we'd love to talk. You can email me directly: -[alund@spotify.com](mailto:alund@spotify.com). +vision, we'd love to talk. You can email us directly: +[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index f55b4d9229..f2d7d3c12a 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -52,19 +52,7 @@ configuration will lead to the proxy acting on backend requests to The value inside each route is either a simple URL string, or an object on the format accepted by -[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). It -is also possible to limit the forwarded HTTP methods with the configuration -`allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only -access. - -By default, the proxy will only forward safe HTTP request headers to the target. -Those are based on the headers that are considered safe for CORS and includes -headers like `content-type` or `last-modified`, as well as all headers that are -set by the proxy. If the proxy should forward other headers like -`authorization`, this must be enabled by the `allowedHeaders` config, for -example `allowedHeaders: ['Authorization']`. This should help to not -accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to -third-parties. +[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). If the value is a string, it is assumed to correspond to: @@ -85,3 +73,22 @@ except with the following caveats for convenience: `'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to `/api/proxy/larger-example/v1/some/path` will be translated to a request to `http://larger.example.com:8080/svc.v1/some/path`. + +There are also additional settings: + +- `allowedMethods`: Limit the forwarded HTTP methods. For example + `allowedMethods: ['GET']` enforces read-only access. +- `allowedHeaders`: A list of headers that should be forwarded to and received + from the target. + +By default, the proxy will only forward safe HTTP request headers to the target. +Those are based on the headers that are considered safe for CORS and includes +headers like `content-type` or `last-modified`, as well as all headers that are +set by the proxy. If the proxy should forward other headers like +`authorization`, this must be enabled by the `allowedHeaders` config, for +example `allowedHeaders: ['Authorization']`. This should help to not +accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to +third-parties. + +The same logic applies to headers that are sent from the target back to the +frontend. diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index 7eab61891e..efea02ee23 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -1,16 +1,16 @@ --- id: publishing title: Publishing -description: Documentation on Publishing NPM packages +description: Documentation on Publishing npm packages --- -## NPM +## npm -NPM packages are published through CI/CD in the +npm packages are published through CI/CD in the [.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) workflow. Every commit that is merged to master will be checked for new versions of all public packages, and any new versions will automatically be published to -NPM. +npm. ### Creating a new release diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index e60b5fa8b7..b564fd732c 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -234,7 +234,7 @@ This is especially true for edge cases! ## Non-React Classes -Testing a Javascript object which is _not_ a React component follows a lot of +Testing a JavaScript object which is _not_ a React component follows a lot of the same principles as testing objects in other languages. ### API Testing Principles @@ -243,7 +243,7 @@ Testing an API involves verifying four things: 1. Invalid inputs are caught before being sent to the server. 2. Valid inputs translate into a valid browser request. -3. Server response is translated into an expected Javascript object. +3. Server response is translated into an expected JavaScript object. 4. Server errors are handled gracefully. ### Mocking API Calls diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index bcea80e26b..622c085291 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -27,7 +27,7 @@ To inspect the state of a feature flag inside your plugin, you can use the `FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 45e3303124..4a58a5ecdc 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -4,7 +4,7 @@ title: createPlugin description: Documentation on createPlugin --- -Taking a plugin config as argument and returns a new plugin. +Takes a plugin config as an argument and returns a new plugin. ## Plugin Config @@ -28,18 +28,22 @@ type PluginHooks = { ### Creating a basic plugin -Showcasing adding multiple routes, a feature flag and a redirect. +Showcasing adding a route and a feature flag. ```jsx -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; +export const rootRouteRef = createRouteRef({ + path: '/new-plugin', + title: 'New Plugin', +}); + export default createPlugin({ id: 'new-plugin', register({ router, featureFlags }) { + router.addRoute(rootRouteRef, ExampleComponent); featureFlags.register('enable-example-component'); - - router.registerRoute('/new-plugin', ExampleComponent); }, }); ``` diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md index 034fba4cb8..190259841c 100644 --- a/docs/support/project-structure.md +++ b/docs/support/project-structure.md @@ -89,6 +89,12 @@ are separated out into their own folder, see further down. There are no "core" packages in the backend. Instead we have `backend-common` which contains helper middleware and other utils. +- [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client) - + An isomorphic client to interact with the Software Catalog. Backend plugins + can use the package directly. Frontend plugins can use the client by using + `@backstage/plugin-catalog` in combination with `useApi` and the + `catalogApiRef`. + - [`catalog-model/`](https://github.com/backstage/backstage/tree/master/packages/catalog-model) - You can consider this to be a library for working with the catalog of sorts. It contains the definition of an @@ -155,7 +161,7 @@ are separated out into their own folder, see further down. - [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) - Uses the - [Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API) + [TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) to read out definitions and generate documentation for it. - [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) - diff --git a/docs/support/support.md b/docs/support/support.md index 797aca041f..49075366af 100644 --- a/docs/support/support.md +++ b/docs/support/support.md @@ -5,15 +5,16 @@ description: Support and Community Details and Links --- - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the - project + project. - [Good First Issues](https://github.com/backstage/backstage/contribute) - Start - here if you want to contribute + here if you want to contribute. - [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the - technical direction -- [FAQ](../FAQ.md) - Frequently Asked Questions -- [Code of Conduct](../../CODE_OF_CONDUCT.md) - This is how we roll -- [Blog](https://backstage.io/blog/) - Announcements and updates + technical direction by reviewing _Request for Comments_ issues. +- [FAQ](../FAQ.md) - Frequently Asked Questions. +- [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md) - + This is how we roll. +- [Blog](https://backstage.io/blog/) - Announcements and updates. - [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to - our email newsletter + our email newsletter. - Give us a star ⭐️ - If you are using Backstage or think it is an interesting - project, we would love a star ❤️ + project, we would love a star! ❤️ diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 664d4b77d3..adefdfa73f 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -22,7 +22,7 @@ music and wants to have a theme tune for every service in Backstage. Sam built a Spotify plugin for Backstage that allows service owners to define a theme tune for their service. The theme tune plays whenever a user visits the -service page in Backstage. The plugin is published to NPM and available for any +service page in Backstage. The plugin is published to npm and available for any organization to easily install and add to their Backstage installation. # 1. A New Plugin diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 216ff9659f..0f8d9bb84b 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -81,13 +81,13 @@ import { useApi } from '@backstage/core-api'; _from inline:_ ```tsx -const ExampleComponent: FC<{}> = () => ( ... ) +const ExampleComponent = () => ( ... ) ``` _to block:_ ```tsx -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { return ( ... @@ -135,7 +135,7 @@ changes, let's start by wiping this component clean. 1. Replace everything in the file with the following: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -147,7 +147,7 @@ import { import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { return
Nothing to see yet
; }; @@ -223,7 +223,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, diff --git a/microsite/README.md b/microsite/README.md index d244caa22a..9589def3e3 100644 --- a/microsite/README.md +++ b/microsite/README.md @@ -204,3 +204,8 @@ For more information about custom pages, click [here](https://docusaurus.io/docs # Full Documentation Full documentation can be found on the [website](https://docusaurus.io/). + +## Additional notes + +- If you want to make images zoomable on click, add the `data-zoomable` attribute to your `img` element. + - In a docs or blog `.md` file, convert `![This is image](/microsite/static/img/code.png)` syntax to `This is image` diff --git a/microsite/blog/2020-03-16-announcing-backstage.md b/microsite/blog/2020-03-16-announcing-backstage.md index 3911debf08..5094de7b37 100644 --- a/microsite/blog/2020-03-16-announcing-backstage.md +++ b/microsite/blog/2020-03-16-announcing-backstage.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- @@ -41,4 +41,4 @@ We are envisioning three phases of the project and we have already begun work on - **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack. -Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email me directly: [alund@spotify.com](mailto:alund@spotify.com). +Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email us directly: [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md index 0a0ff9e0b1..2c2b81d64d 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.md +++ b/microsite/blog/2020-03-18-what-is-backstage.md @@ -1,6 +1,6 @@ --- title: What the heck is Backstage anyway? -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- @@ -85,4 +85,4 @@ We are envisioning [three phases](https://github.com/backstage/backstage/milesto https://github.com/backstage/backstage/milestones -Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [alund@spotify.com](mailto:alund@spotify.com). +Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/microsite/blog/2020-04-06-lighthouse-plugin.md b/microsite/blog/2020-04-06-lighthouse-plugin.md index b8fd68e783..dcdb4b78e5 100644 --- a/microsite/blog/2020-04-06-lighthouse-plugin.md +++ b/microsite/blog/2020-04-06-lighthouse-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Lighthouse for Backstage -author: Paul Marbach +author: Paul Marbach, Spotify authorURL: http://twitter.com/fastfrwrd authorImageURL: https://pbs.twimg.com/profile_images/1224058798958088192/JPxS8uzR_400x400.jpg --- diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index 85f40dd9ea..65102c919f 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -1,6 +1,6 @@ --- title: How to quickly set up Backstage -author: Marcus Eide +author: Marcus Eide, Spotify authorURL: https://github.com/marcuseide authorImageURL: https://secure.gravatar.com/avatar/20223f1e03673c7c1e6282fbebaf6942 --- diff --git a/microsite/blog/2020-05-14-tech-radar-plugin.md b/microsite/blog/2020-05-14-tech-radar-plugin.md index 80c1eb8b5a..b78cf9004f 100644 --- a/microsite/blog/2020-05-14-tech-radar-plugin.md +++ b/microsite/blog/2020-05-14-tech-radar-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Tech Radar for Backstage -author: Bilawal Hameed +author: Bilawal Hameed, Spotify authorURL: http://twitter.com/bilawalhameed authorImageURL: https://avatars0.githubusercontent.com/bih --- diff --git a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md index 68e3f903c7..3e3d8a537a 100644 --- a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md +++ b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md @@ -1,6 +1,6 @@ --- title: Weaveworks’ COVID-19 app uses Backstage UI -author: Jeff Feng +author: Jeff Feng, Spotify authorURL: https://github.com/fengypants authorImageURL: https://avatars2.githubusercontent.com/u/46946747 --- diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 103750dcc6..520a2a5f10 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -1,6 +1,6 @@ --- title: Starting Phase 2: The Service Catalog -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md index eeed43b713..4519f16d96 100644 --- a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md +++ b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md @@ -1,6 +1,6 @@ --- title: Backstage Service Catalog released in alpha -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund image: https://backstage.io/blog/assets/6/header.png --- @@ -49,6 +49,6 @@ Alpha is our shorthand for "we don’t yet think Backstage is ready for producti As with most alpha releases, you should expect things to change quite a lot until we reach the beta stage (we’re targeting the end of summer). There are obviously many things missing as well, but we wanted to start collecting feedback early and make it easier to see the end-to-end flow. -If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏 +If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏 To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community). diff --git a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md index 9d41a21d05..ce778c9dc2 100644 --- a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md +++ b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md @@ -1,6 +1,6 @@ --- title: How to enable authentication in Backstage using Passport -author: Lee Mills +author: Lee Mills, Spotify authorURL: https://github.com/leemills83 authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4 --- diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index c216d7e398..afda20e499 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage Software Templates -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- @@ -73,6 +73,6 @@ Backstage ships with four example templates, but since these are likely not the We have learned that one of the keys to getting these standards adopted is to keep an open process. Templates are code. By making it clear to your engineers that you are open to pull requests, and that teams with different needs can add their own templates, you are on the path of striking a good balance between autonomy and standardization. -If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏 +If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏 To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community). diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index ceab17d7be..f09c73fd83 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -1,6 +1,6 @@ --- title: Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage -author: Gary Niemen +author: Gary Niemen, Spotify authorURL: https://github.com/garyniemen --- diff --git a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md index fbf47a39c3..48a67e878c 100644 --- a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md +++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md @@ -1,6 +1,6 @@ --- title: Backstage has been accepted into the CNCF Sandbox -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- @@ -18,4 +18,4 @@ The Backstage community is healthy and growing quickly. Over [130 people](https: We’re excited to embark on this journey with the CNCF community. There’s so much great tech being built here, and it’s about time we share it to build even greater products, together. Entering into the CNCF Sandbox is just the first step. We are committed to working with the community to bring Backstage through the Incubation step, and finally all the way to becoming a Graduated, top-level project. -Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:alund@spotify.com) me directly. +Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:backstage-interest@spotify.com) me directly. diff --git a/microsite/blog/2020-09-30-backstage-design-system.md b/microsite/blog/2020-09-30-backstage-design-system.md index a1d087f755..fc227ea5d3 100644 --- a/microsite/blog/2020-09-30-backstage-design-system.md +++ b/microsite/blog/2020-09-30-backstage-design-system.md @@ -1,6 +1,6 @@ --- title: How to design for Backstage (even if you’re not a designer) -author: Kat Zhou +author: Kat Zhou, Spotify authorURL: http://twitter.com/katherinemzhou --- diff --git a/microsite/blog/2020-09-30-plugin-marketplace.md b/microsite/blog/2020-09-30-plugin-marketplace.md index 10928112b8..f4e9b749e2 100644 --- a/microsite/blog/2020-09-30-plugin-marketplace.md +++ b/microsite/blog/2020-09-30-plugin-marketplace.md @@ -1,6 +1,6 @@ --- title: The Plugin Marketplace is open -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index 15647f8418..c7ae9c492d 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -1,6 +1,6 @@ --- title: New Cost Insights plugin: The engineer’s solution to taming cloud costs -author: Janisa Anandamohan +author: Janisa Anandamohan, Spotify authorURL: https://twitter.com/janisa_a --- diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 6ce3c3dd84..1471d7b4d1 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -54,6 +54,12 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} + + + Spotify Engineering Blog + + Spotify for Developers + GitHub +

+ Made with ❤️  at Spotify +

{this.props.config.copyright}

); diff --git a/microsite/data/plugins/api-docs.yaml b/microsite/data/plugins/api-docs.yaml index 3343a46693..03ec818445 100644 --- a/microsite/data/plugins/api-docs.yaml +++ b/microsite/data/plugins/api-docs.yaml @@ -5,5 +5,5 @@ authorUrl: https://sda.se/ category: Discovery description: Components to discover and display API entities as an extension to the catalog plugin. documentation: https://github.com/backstage/backstage/blob/master/plugins/api-docs/README.md -iconUrl: https://thecoders.io/wp-content/uploads/2019/11/tech-swagger.svg +iconUrl: https://raw.githubusercontent.com/vscode-icons/vscode-icons/master/icons/file_type_swagger.svg npmPackageName: '@backstage/plugin-api-docs' diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml new file mode 100644 index 0000000000..a47fa08d82 --- /dev/null +++ b/microsite/data/plugins/argo-cd.yaml @@ -0,0 +1,12 @@ +--- +title: Argo CD +author: roadie.io +authorUrl: https://roadie.io +category: CI +description: View Argo CD status for your projects in Backstage. +documentation: https://roadie.io/backstage/plugins/argo-cd +iconUrl: https://roadie.io/images/logos/argo.png +npmPackageName: '@roadiehq/backstage-plugin-argo-cd' +tags: + - cd + - ci diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml index 7fa276f444..f325c0df8b 100644 --- a/microsite/data/plugins/aws-lambda.yaml +++ b/microsite/data/plugins/aws-lambda.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io category: Monitoring description: View AWS Lambda functions for your components in Backstage. documentation: https://roadie.io/backstage/plugins/aws-lambda -iconUrl: https://roadie.io/static/77f62f79e27ae8565496e4df7eef8be5/45f2b/logo.png +iconUrl: https://roadie.io/images/logos/lambda.png npmPackageName: '@roadiehq/backstage-plugin-aws-lambda' diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml new file mode 100644 index 0000000000..eeb9440aac --- /dev/null +++ b/microsite/data/plugins/buildkite.yaml @@ -0,0 +1,12 @@ +--- +title: Buildkite +author: roadie.io +authorUrl: https://roadie.io +category: CI +description: View Buildkite CI builds for your service in Backstage. +documentation: https://roadie.io/backstage/plugins/buildkite +iconUrl: https://roadie.io/images/logos/buildkite.png +npmPackageName: '@roadiehq/backstage-plugin-buildkite' +tags: + - ci + - cd diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml index 097ef00e50..8c1ea0be0a 100644 --- a/microsite/data/plugins/firebase-functions.yaml +++ b/microsite/data/plugins/firebase-functions.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: Monitoring description: View Firebase Functions details for your service in Backstage. documentation: https://roadie.io/backstage/plugins/firebase-functions -iconUrl: https://roadie.io/static/49fb23200ad0eaa6703b4ddf75c78cf1/45f2b/logo-vertical.png +iconUrl: https://roadie.io/images/logos/firebase.png npmPackageName: '@roadiehq/backstage-plugin-firebase-functions' diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml index 8ac8321da0..1dba3dcfd1 100644 --- a/microsite/data/plugins/github-insights.yaml +++ b/microsite/data/plugins/github-insights.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io category: Monitoring description: View GitHub Insights for your components in Backstage. documentation: https://roadie.io/backstage/plugins/github-insights -iconUrl: https://roadie.io/static/2ad5123c425908efde0c922d707e737b/06c84/code-icon.png +iconUrl: https://roadie.io/images/logos/insights.png npmPackageName: '@roadiehq/backstage-plugin-github-insights' diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 6479452c8e..8e44a4bf56 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: CI description: View GitHub pull requests for your service in Backstage. documentation: https://roadie.io/backstage/plugins/github-pull-requests -iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png +iconUrl: https://roadie.io/images/logos/github.png npmPackageName: '@roadiehq/backstage-plugin-github-pull-requests' diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml new file mode 100644 index 0000000000..579243d11c --- /dev/null +++ b/microsite/data/plugins/jira.yaml @@ -0,0 +1,9 @@ +--- +title: Jira +author: roadie.io +authorUrl: https://roadie.io +category: Project Management +description: View Jira summary for your projects in Backstage. +documentation: https://roadie.io/backstage/plugins/jira +iconUrl: https://roadie.io/images/logos/jira.png +npmPackageName: '@roadiehq/backstage-plugin-jira' diff --git a/microsite/data/plugins/security-insights.yaml b/microsite/data/plugins/security-insights.yaml index 1cbcfbc3aa..4f34f79027 100644 --- a/microsite/data/plugins/security-insights.yaml +++ b/microsite/data/plugins/security-insights.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: Security description: View Security Insights for your components in Backstage. documentation: https://roadie.io/backstage/plugins/security-insights -iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png +iconUrl: https://roadie.io/images/logos/github.png npmPackageName: '@roadiehq/backstage-plugin-security-insights' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index 520b884c20..fe1b18c9d4 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: CI description: View Travis CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/travis-ci -iconUrl: https://roadie.io/static/af2941eaf0af675facb281d566f42e14/45f2b/travis-ci-mascot-200x200.png +iconUrl: https://roadie.io/images/logos/travis.png npmPackageName: '@roadiehq/backstage-plugin-travis-ci' diff --git a/microsite/package.json b/microsite/package.json index a84364c214..e1860b8314 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -15,9 +15,9 @@ }, "devDependencies": { "@spotify/prettier-config": "^9.0.0", - "docusaurus": "^2.0.0-alpha.66", - "js-yaml": "^3.14.0", - "prettier": "^2.0.5" + "docusaurus": "^2.0.0-alpha.378053ac5", + "js-yaml": "^3.14.1", + "prettier": "^2.2.1" }, "prettier": "@spotify/prettier-config" } diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 64fdd2f420..91112dd3b3 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -7,6 +7,7 @@ "overview/vision", "overview/background", "overview/adopting", + "overview/stability-index", "overview/logos" ], "Getting Started": [ @@ -45,6 +46,7 @@ "features/software-catalog/descriptor-format", "features/software-catalog/references", "features/software-catalog/well-known-annotations", + "features/software-catalog/well-known-relations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", "features/software-catalog/software-catalog-api" @@ -72,6 +74,8 @@ "features/techdocs/concepts", "features/techdocs/architecture", "features/techdocs/creating-and-publishing", + "features/techdocs/configuration", + "features/techdocs/using-cloud-storage", "features/techdocs/troubleshooting", "features/techdocs/faqs" ] diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 6e8f8edddf..39ba05bcad 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -8,11 +8,8 @@ // See https://docusaurus.io/docs/site-config for all the possible // site configuration options. -// List of projects/orgs using your project for the users page. -const users = []; - const siteConfig = { - title: 'Backstage', // Title for your website. + title: 'Backstage Service Catalog and Developer Platform', // Title for your website. tagline: 'An open platform for building developer portals', url: 'https://backstage.io', // Your website URL cname: 'backstage.io', @@ -72,11 +69,6 @@ const siteConfig = { navGroupSubcategoryTitleColor: '#9e9e9e', }, - /* Colors for syntax highlighting */ - highlight: { - theme: 'dark', - }, - // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`, @@ -86,7 +78,11 @@ const siteConfig = { }, // Add custom scripts here that would be placed in - - - `); + res.end(``); }; export const ensuresXRequestedWith = (req: express.Request) => { const requiredHeader = req.header('X-Requested-With'); - if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { return false; } diff --git a/plugins/auth-backend/src/lib/flow/index.ts b/plugins/auth-backend/src/lib/flow/index.ts index a5f2f7a3ac..7f0627994f 100644 --- a/plugins/auth-backend/src/lib/flow/index.ts +++ b/plugins/auth-backend/src/lib/flow/index.ts @@ -15,3 +15,5 @@ */ export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers'; + +export type { WebMessageResponse } from './types'; diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts index 9f250a0285..bc6fcf1004 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -19,7 +19,7 @@ import { OAuthState } from './types'; export const readState = (stateString: string): OAuthState => { const state = Object.fromEntries( - new URLSearchParams(decodeURIComponent(stateString)), + new URLSearchParams(Buffer.from(stateString, 'hex').toString('utf-8')), ); if ( !state.nonce || @@ -40,7 +40,7 @@ export const encodeState = (state: OAuthState): string => { searchParams.append('nonce', state.nonce); searchParams.append('env', state.env); - return encodeURIComponent(searchParams.toString()); + return Buffer.from(searchParams.toString(), 'utf-8').toString('hex'); }; export const verifyNonce = (req: express.Request, providerId: string) => { diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index 668ab17ee1..8772842e2a 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -139,7 +139,7 @@ export class Auth0AuthProvider implements OAuthHandlers { const { profile } = response; if (!profile.email) { - throw new Error('Profile does not contain a profile'); + throw new Error('Profile does not contain an email'); } const id = profile.email.split('@')[0]; @@ -149,12 +149,12 @@ export class Auth0AuthProvider implements OAuthHandlers { } export const createAuth0Provider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'auth0'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const domain = envConfig.getString('domain'); diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index a764d0b50e..7bad4f4d81 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -18,14 +18,15 @@ import { createGithubProvider } from './github'; import { createGitlabProvider } from './gitlab'; import { createGoogleProvider } from './google'; import { createOAuth2Provider } from './oauth2'; +import { createOidcProvider } from './oidc'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; -import { AuthProviderFactory, AuthProviderFactoryOptions } from './types'; +import { AuthProviderFactory } from './types'; -const factories: { [providerId: string]: AuthProviderFactory } = { +export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, github: createGithubProvider, gitlab: createGitlabProvider, @@ -34,17 +35,6 @@ const factories: { [providerId: string]: AuthProviderFactory } = { auth0: createAuth0Provider, microsoft: createMicrosoftProvider, oauth2: createOAuth2Provider, + oidc: createOidcProvider, onelogin: createOneLoginProvider, }; - -export function createAuthProvider( - providerId: string, - options: AuthProviderFactoryOptions, -) { - const factory = factories[providerId]; - if (!factory) { - throw Error(`No auth provider available for '${providerId}'`); - } - - return factory(options); -} diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index bab7b3fc28..6e3f9a6000 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -137,12 +137,12 @@ export class GithubAuthProvider implements OAuthHandlers { } export const createGithubProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'github'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const enterpriseInstanceUrl = envConfig.getOptionalString( diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 4d4ecc3b56..df6d2daeb7 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -140,12 +140,12 @@ export class GitlabAuthProvider implements OAuthHandlers { } export const createGitlabProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'gitlab'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const audience = envConfig.getString('audience'); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 80143ca201..ae4f5fabd0 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -15,29 +15,29 @@ */ import express from 'express'; -import { Logger } from 'winston'; +import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Logger } from 'winston'; +import { CatalogIdentityClient } from '../../lib/catalog'; import { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthStartRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, executeRefreshTokenStrategy, makeProfileInfo, - executeFetchUserProfileStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; -import { - OAuthAdapter, - OAuthHandlers, - OAuthProviderOptions, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthRefreshRequest, -} from '../../lib/oauth'; -import passport from 'passport'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { AuthProviderFactory, RedirectInfo } from '../types'; type PrivateInfo = { refreshToken: string; @@ -175,14 +175,14 @@ export class GoogleAuthProvider implements OAuthHandlers { } export const createGoogleProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, logger, tokenIssuer, - discovery, + catalogApi, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'google'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; @@ -192,7 +192,7 @@ export const createGoogleProvider: AuthProviderFactory = ({ clientSecret, callbackUrl, logger, - identityClient: new CatalogIdentityClient({ discovery }), + identityClient: new CatalogIdentityClient({ catalogApi }), }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 99348438be..3f3d16f28f 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,4 +14,16 @@ * limitations under the License. */ -export { createAuthProvider } from './factories'; +export { factories as defaultAuthProviderFactories } from './factories'; + +// Export the minimal interface required for implementing a +// custom Authorization Handler +export type { + AuthProviderRouteHandlers, + AuthProviderFactoryOptions, + AuthProviderFactory, +} from './types'; + +// These types are needed for a postMessage from the login pop-up +// to the frontend +export type { AuthResponse, BackstageIdentity, ProfileInfo } from './types'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index baa66f0662..b0c3635be8 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -163,7 +163,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { }); } - private getUserPhoto(accessToken: string): Promise { + private getUserPhoto(accessToken: string): Promise { return new Promise(resolve => { got .get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', { @@ -184,7 +184,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, ); // User profile photo is optional, ignore errors and resolve undefined - resolve(); + resolve(undefined); }); }); } @@ -206,13 +206,12 @@ export class MicrosoftAuthProvider implements OAuthHandlers { } export const createMicrosoftProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'microsoft'; - const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const tenantID = envConfig.getString('tenantId'); diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index a657ea2195..04cb7670f3 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -148,7 +148,7 @@ export class OAuth2AuthProvider implements OAuthHandlers { const { profile } = response; if (!profile.email) { - throw new Error('Profile does not contain a profile'); + throw new Error('Profile does not contain an email'); } const id = profile.email.split('@')[0]; @@ -157,12 +157,12 @@ export class OAuth2AuthProvider implements OAuthHandlers { } export const createOAuth2Provider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'oauth2'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; diff --git a/plugins/api-docs/src/components/ApiEntityPage/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts similarity index 92% rename from plugins/api-docs/src/components/ApiEntityPage/index.ts rename to plugins/auth-backend/src/providers/oidc/index.ts index 561350744b..acacbde73d 100644 --- a/plugins/api-docs/src/components/ApiEntityPage/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ApiEntityPage } from './ApiEntityPage'; +export { createOidcProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts new file mode 100644 index 0000000000..f2bd8dcb90 --- /dev/null +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -0,0 +1,128 @@ +/* + * 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 express from 'express'; +import { Session } from 'express-session'; +import nock from 'nock'; +import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import { createOidcProvider, OidcAuthProvider } from './provider'; +import { JWT, JWK } from 'jose'; +import { AuthProviderFactoryOptions } from '../types'; +import { Config } from '@backstage/config'; +import { OAuthAdapter } from '../../lib/oauth'; + +const issuerMetadata = { + issuer: 'https://oidc.test', + authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', + token_endpoint: 'https://oidc.test/as/token.oauth2', + revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', + jwks_uri: 'https://oidc.test/pf/JWKS', + scopes_supported: ['openid'], + claims_supported: ['email'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], +}; + +const clientMetadata = { + callbackUrl: 'https://oidc.test/callback', + clientId: 'testclientid', + clientSecret: 'testclientsecret', + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + tokenSignedResponseAlg: 'none', +}; + +describe('OidcAuthProvider', () => { + it('hit the metadata url', async () => { + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata); + const provider = new OidcAuthProvider(clientMetadata); + const { strategy } = ((await (provider as any).implementation) as any) as { + strategy: { + _client: ClientMetadata; + _issuer: IssuerMetadata; + }; + }; + // Assert that the expected request to the metadaurl was made. + expect(scope.isDone()).toBeTruthy(); + const { _client, _issuer } = strategy; + expect(_client.client_id).toBe(clientMetadata.clientId); + expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); + }); + + it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => { + const jwt = { + sub: 'alice', + iss: 'https://oidc.test', + aud: clientMetadata.clientId, + exp: Date.now() + 10000, + }; + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata) + .post('/as/token.oauth2') + .reply(200, { + id_token: JWT.sign(jwt, JWK.None), + access_token: 'test', + authorization_signed_response_alg: 'HS256', + }) + .get('/idp/userinfo.openid') + .reply(200, { + sub: 'alice', + email: 'alice@oidc.test', + }); + const provider = new OidcAuthProvider(clientMetadata); + const req = { + method: 'GET', + url: 'https://oidc.test/?code=test2', + session: ({ 'oidc:oidc.test': 'test' } as any) as Session, + } as express.Request; + await provider.handler(req); + expect(scope.isDone()).toBeTruthy(); + }); + + it('createOidcProvider', async () => { + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata); + const options = { + globalConfig: { + appUrl: 'https://oidc.test', + baseUrl: 'https://oidc.test', + }, + config: ({ + keys: jest.fn(() => ['test']), + getConfig: jest.fn(() => ({ + getString: (key: string) => { + const conf = { + ...clientMetadata, + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + } as any; + return conf[key] as string; + }, + })), + } as any) as Config, + } as AuthProviderFactoryOptions; + const provider = createOidcProvider(options) as OAuthAdapter; + expect(provider.start).toBeDefined(); + await new Promise(resolve => process.nextTick(resolve)); // advance a tick to give nock a chance to intercept the request + expect(scope.isDone()).toBeTruthy(); + }); +}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts new file mode 100644 index 0000000000..9e44a82c37 --- /dev/null +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -0,0 +1,201 @@ +/* + * 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 express from 'express'; +import { + Issuer, + Client, + Strategy as OidcStrategy, + TokenSet, + UserinfoResponse, +} from 'openid-client'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types'; + +type PrivateInfo = { + refreshToken: string; +}; + +type OidcImpl = { + strategy: OidcStrategy; + client: Client; +}; + +export type OidcAuthProviderOptions = OAuthProviderOptions & { + metadataUrl: string; + tokenSignedResponseAlg?: string; +}; + +export class OidcAuthProvider implements OAuthHandlers { + private readonly implementation: Promise; + + constructor(options: OidcAuthProviderOptions) { + this.implementation = this.setupStrategy(options); + } + + async start(req: OAuthStartRequest): Promise { + const { strategy } = await this.implementation; + return await executeRedirectStrategy(req, strategy, { + accessType: 'offline', + prompt: 'none', + scope: `${req.scope} default`, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { strategy } = await this.implementation; + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest): Promise { + const { client } = await this.implementation; + const tokenset = await client.refresh(req.refreshToken); + if (!tokenset.access_token) { + throw new Error('Refresh failed'); + } + const profile = await client.userinfo(tokenset.access_token); + + return this.populateIdentity({ + providerInfo: { + accessToken: tokenset.access_token, + refreshToken: tokenset.refresh_token, + expiresInSeconds: tokenset.expires_in, + idToken: tokenset.id_token, + scope: tokenset.scope || '', + }, + profile, + }); + } + + private async setupStrategy( + options: OidcAuthProviderOptions, + ): Promise { + const issuer = await Issuer.discover(options.metadataUrl); + const client = new issuer.Client({ + client_id: options.clientId, + client_secret: options.clientSecret, + redirect_uris: [options.callbackUrl], + response_types: ['code'], + id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', + }); + + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false as true, + }, + ( + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportDoneCallback, + ) => { + const profile: ProfileInfo = { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }; + + done( + undefined, + { + providerInfo: { + idToken: tokenset.id_token || '', + accessToken: tokenset.access_token || '', + scope: tokenset.scope || '', + expiresInSeconds: tokenset.expires_in, + }, + profile, + }, + { + refreshToken: tokenset.refresh_token || '', + }, + ); + }, + ); + strategy.error = console.error; + return { strategy, client }; + } + + // Use this function to grab the user profile info from the token + // Then populate the profile with it + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Profile does not contain an email'); + } + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export const createOidcProvider: AuthProviderFactory = ({ + providerId, + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const metadataUrl = envConfig.getString('metadataUrl'); + const tokenSignedResponseAlg = envConfig.getString( + 'tokenSignedResponseAlg', + ); + + const provider = new OidcAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + metadataUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 09597696ba..84b258e489 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -168,12 +168,12 @@ export class OktaAuthProvider implements OAuthHandlers { } export const createOktaProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'okta'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const audience = envConfig.getString('audience'); diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts index 97cf182e5a..838b655d59 100644 --- a/plugins/auth-backend/src/providers/onelogin/provider.ts +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -147,12 +147,12 @@ export class OneLoginProvider implements OAuthHandlers { } export const createOneLoginProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const providerId = 'onelogin'; const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const issuer = envConfig.getString('issuer'); diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index a8edc9a714..7b663a5528 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -121,12 +121,12 @@ type SAMLProviderOptions = { }; export const createSamlProvider: AuthProviderFactory = ({ + providerId, globalConfig, config, tokenIssuer, }) => { const url = new URL(globalConfig.baseUrl); - const providerId = 'saml'; const entryPoint = config.getString('entryPoint'); const issuer = config.getString('issuer'); const opts = { diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 6776095f99..a40f2a96ee 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -14,11 +14,12 @@ * limitations under the License. */ +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; -import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type AuthProviderConfig = { /** @@ -112,11 +113,13 @@ export interface AuthProviderRouteHandlers { } export type AuthProviderFactoryOptions = { + providerId: string; globalConfig: AuthProviderConfig; config: Config; logger: Logger; tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; + catalogApi: CatalogApi; }; export type AuthProviderFactory = ( diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 6bb100de6b..85d60661e9 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -18,20 +18,29 @@ import express from 'express'; import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; import { Logger } from 'winston'; -import { createAuthProvider } from '../providers'; -import { Config } from '@backstage/config'; -import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; +import { + defaultAuthProviderFactories, + AuthProviderFactory, +} from '../providers'; import { NotFoundError, - PluginEndpointDiscovery, PluginDatabaseManager, + PluginEndpointDiscovery, } from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity'; +import session from 'express-session'; +import passport from 'passport'; + +type ProviderFactories = { [s: string]: AuthProviderFactory }; export interface RouterOptions { logger: Logger; database: PluginDatabaseManager; config: Config; discovery: PluginEndpointDiscovery; + providerFactories?: ProviderFactories; } export async function createRouter({ @@ -39,6 +48,7 @@ export async function createRouter({ config, discovery, database, + providerFactories, }: RouterOptions): Promise { const router = Router(); @@ -56,23 +66,44 @@ export async function createRouter({ keyDurationSeconds, logger: logger.child({ component: 'token-factory' }), }); + const catalogApi = new CatalogClient({ discoveryApi: discovery }); - router.use(cookieParser()); + const secret = config.getOptionalString('auth.session.secret'); + if (secret) { + router.use(cookieParser(secret)); + // TODO: Configure the server-side session storage. The default MemoryStore is not designed for production + router.use(session({ secret, saveUninitialized: false, resave: false })); + router.use(passport.initialize()); + router.use(passport.session()); + } else { + router.use(cookieParser()); + } router.use(express.urlencoded({ extended: false })); router.use(express.json()); + const allProviderFactories = { + ...defaultAuthProviderFactories, + ...providerFactories, + }; const providersConfig = config.getConfig('auth.providers'); const providers = providersConfig.keys(); for (const providerId of providers) { logger.info(`Configuring provider, ${providerId}`); try { - const provider = createAuthProvider(providerId, { + const providerFactory = allProviderFactories[providerId]; + if (!providerFactory) { + throw Error(`No auth provider available for '${providerId}'`); + } + + const provider = providerFactory({ + providerId, globalConfig: { baseUrl: authUrl, appUrl }, config: providersConfig.getConfig(providerId), logger, tokenIssuer, discovery, + catalogApi, }); const r = Router(); diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index a4f2377507..15a3347d5f 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -14,16 +14,16 @@ * limitations under the License. */ -import Knex from 'knex'; -import { Server } from 'http'; -import { Logger } from 'winston'; -import { createRouter } from './router'; import { createServiceBuilder, - useHotMemoize, loadBackendConfig, SingleHostDiscovery, + useHotMemoize, } from '@backstage/backend-common'; +import { Server } from 'http'; +import Knex from 'knex'; +import { Logger } from 'winston'; +import { createRouter } from './router'; export interface ServerOptions { logger: Logger; diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index e45da37691..43a07252b8 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,131 @@ # @backstage/plugin-catalog-backend +## 0.4.0 + +### Minor Changes + +- 83b6e0c1f: Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity. + + See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details. + +### Patch Changes + +- 6e8bb3ac0: leave unknown placeholder-lookalikes untouched in the catalog processing loop +- e708679d7: refreshAllLocations uses a child logger of the HigherOrderOperation with a meta `component` : `catalog-all-locations-refresh` +- 047c018c9: Batch the fetching of relations +- 38d63fbe1: Fix string template literal +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.3.0 + +### Minor Changes + +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError +- 08835a61d: Add support for relative targets and implicit types in Location entities. +- e42402b47: Gracefully handle missing codeowners. + + The CodeOwnersProcessor now also takes a logger as a parameter. + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + +## 0.2.3 + +### Patch Changes + +- 1ec19a3f4: Ignore empty YAML documents. Having a YAML file like this is now ingested without an error: + + ```yaml + apiVersion: backstage.io/v1alpha1 + kind: Component + metadata: + name: web + spec: + type: website + --- + + ``` + + This behaves now the same way as Kubernetes handles multiple documents in a single YAML file. + +- ab94c9542: Add `providesApis` and `consumesApis` to the component entity spec. +- 2daf18e80: Start emitting all known relation types from the core entity kinds, based on their spec data. +- Updated dependencies [3aa7efb3f] +- Updated dependencies [ab94c9542] +- Updated dependencies [2daf18e80] +- Updated dependencies [069cda35f] +- Updated dependencies [b3d4e4e57] + - @backstage/backend-common@0.3.2 + - @backstage/catalog-model@0.3.1 + +## 0.2.2 + +### Patch Changes + +- 0c2121240: Add support for reading groups and users from the Microsoft Graph API. +- 1185919f3: Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details. + + Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields. + + The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation. + + After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either. + + If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code. + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + +## 0.2.1 + +### Patch Changes + +- f531d307c: An entity A, that exists in the catalog, can no longer be overwritten by registering a different location that also tries to supply an entity with the same kind+namespace+name. Writes of that new entity will instead be rejected with a log message similar to `Rejecting write of entity Component:default/artist-lookup from file:/Users/freben/dev/github/backstage/packages/catalog-model/examples/components/artist-lookup-component.yaml because entity existed from github:https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml` +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.2.0 ### Minor Changes diff --git a/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js b/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js new file mode 100644 index 0000000000..a8cf62b4c4 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + // sqlite doesn't support dropPrimary so we recreate it properly instead + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + table.index('source_full_name', 'source_full_name_idx'); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropPrimary(); + table.index('source_full_name', 'source_full_name_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'source_full_name_idx'); + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } +}; diff --git a/plugins/catalog-backend/migrations/20201210185851_fk_index.js b/plugins/catalog-backend/migrations/20201210185851_fk_index.js new file mode 100644 index 0000000000..acf90b0a27 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201210185851_fk_index.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 390c91551f..1a5b24f64c 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.2.0", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,11 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", + "@azure/msal-node": "^1.0.0-alpha.8", + "@backstage/backend-common": "^0.4.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", "@octokit/graphql": "^4.5.6", "@types/express": "^4.17.6", + "@types/ldapjs": "^1.0.9", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", @@ -37,6 +48,7 @@ "lodash": "^4.17.15", "morgan": "^1.10.0", "p-limit": "^3.0.2", + "qs": "^6.9.4", "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", @@ -45,11 +57,10 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/test-utils": "^0.1.5", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", - "@types/ldapjs": "^1.0.9", "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 00898b33a6..5059d741f4 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -15,7 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import type { Entity } from '@backstage/catalog-model'; +import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; import { Database, DatabaseManager, Transaction } from '../database'; import { EntityFilters } from '../service/EntityFilters'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; @@ -80,9 +80,10 @@ describe('DatabaseEntitiesCatalog', () => { '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(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -113,9 +114,10 @@ describe('DatabaseEntitiesCatalog', () => { '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(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(transaction.rollback).toBeCalledTimes(1); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -127,6 +129,9 @@ describe('DatabaseEntitiesCatalog', () => { metadata: { name: 'c', namespace: 'd', + annotations: { + [LOCATION_ANNOTATION]: 'mock', + }, }, }; const dbEntity: Entity = { @@ -135,14 +140,14 @@ describe('DatabaseEntitiesCatalog', () => { metadata: { name: 'c', namespace: 'd', + description: 'changes', uid: 'u', + annotations: { + [LOCATION_ANNOTATION]: 'mock', + }, }, }; - db.entities.mockResolvedValue([ - { - entity: dbEntity, - }, - ]); + db.entities.mockResolvedValue([{ entity: dbEntity }]); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -154,7 +159,7 @@ describe('DatabaseEntitiesCatalog', () => { ); expect(db.entities).toHaveBeenCalledTimes(2); - expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledTimes(1); expect(result).toEqual([ { entityId: 'u', @@ -230,12 +235,11 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -308,6 +312,7 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 5c55a30a55..7ba6897236 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { ConflictError, NotFoundError } from '@backstage/backend-common'; import { Entity, entityHasChanges, - EntityRelationSpec, generateUpdatedEntity, getEntityName, LOCATION_ANNOTATION, @@ -73,38 +72,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return items.map(i => i.entity); } - private async addOrUpdateEntity( - entity: Entity, - tx: Transaction, - locationId?: string, - ): Promise { - // 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]; - } - - return response.entity; - } - async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { const entityResponse = await this.database.entityByUid(tx, uid); @@ -135,13 +102,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }); } - /** - * Writes a number of entities efficiently to storage. - * - * @param entities Some entities - * @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?: { @@ -150,120 +110,125 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { outputEntities?: boolean; }, ): Promise { - const locationId = options?.locationId; + // Group the requests by unique kind+namespace combinations. The reason for + // this is that the change detection and merging logic requires finding + // pre-existing versions of the entities in the database. Those queries are + // easier and faster to make if every batch revolves around a single kind- + // namespace pair. + const requestsByKindAndNamespace = groupBy(requests, ({ entity }) => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); - return await this.database.transaction(async tx => { - // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { - const name = getEntityName(entity); - return `${name.kind}:${name.namespace}`.toLowerCase(); - }); + // Go through the requests in reasonable batch sizes. Sometimes, sources + // produce tens of thousands of entities, and those are too large batch + // sizes to reasonably send to the database. + const batches = Object.values(requestsByKindAndNamespace) + .map(requests => chunk(requests, BATCH_SIZE)) + .flat(); - const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; - - for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupRequests[0].entity); - - // Go through the new entities in reasonable chunk sizes (sometimes, - // sources produce tens of thousands of entities, and those are too large - // batch sizes to reasonably send to the database) - for (const batch of chunk(groupRequests, BATCH_SIZE)) { - tasks.push( - limiter(async () => { - const first = serializeEntityRef(batch[0].entity); - const last = serializeEntityRef(batch[batch.length - 1].entity); - let modifiedEntityIds: EntityUpsertResponse[] = []; - - this.logger.debug( - `Considering batch ${first}-${last} (${batch.length} entries)`, + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + const tasks = batches.map(batch => + limiter(async () => { + // Retry the batch write a few times to deal with contention + for (let attempt = 1; ; ++attempt) { + try { + return this.batchAddOrUpdateEntitiesSingleBatch(batch, options); + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, ); + } else { + throw e; + } + } + } + }), + ); - // Retry the batch write a few times to deal with contention - const context = { - kind, - namespace, - 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, tx)), - ); - } - if (toUpdate.length) { - modifiedEntityIds.push( - ...(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, tx); - modifiedEntityIds.push({ entityId }); - } + const responses = await Promise.all(tasks); + return responses.flat(); + } - break; - } catch (e) { - if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { - this.logger.warn( - `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, - ); - } else { - throw e; - } - } - } + // Defines the actual logic of running a single batch. All of these share a + // common kind and namespace. + private async batchAddOrUpdateEntitiesSingleBatch( + batch: EntityUpsertRequest[], + options?: { + locationId?: string; + dryRun?: boolean; + outputEntities?: boolean; + }, + ) { + const { kind, namespace } = getEntityName(batch[0].entity); + const context = { + kind, + namespace, + locationId: options?.locationId, + }; - if (options?.outputEntities) { - const writtenEntities = await this.database.entities( - tx, - EntityFilters.ofMatchers({ - 'metadata.uid': modifiedEntityIds.map(e => e.entityId), - }), - ); + this.logger.debug( + `Considering batch ${serializeEntityRef( + batch[0].entity, + )}-${serializeEntityRef(batch[batch.length - 1].entity)} (${ + batch.length + } entries)`, + ); - modifiedEntityIds = writtenEntities.map(e => ({ - entityId: e.entity.metadata.uid!, - entity: e.entity, - })); - } + return this.database.transaction(async tx => { + const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( + batch, + context, + tx, + ); - return modifiedEntityIds; - }), - ); + let responses = new Array(); + if (toAdd.length) { + const items = await this.batchAdd(toAdd, context, tx); + responses.push(...items); + } + if (toUpdate.length) { + const items = await this.batchUpdate(toUpdate, context, tx); + responses.push(...items); + } + for (const { entity, relations } of toIgnore) { + // TODO(Rugvip): We currently always update relations, but we + // likely want to figure out a way to avoid that + const entityId = entity.metadata.uid; + if (entityId) { + await this.database.setRelations(tx, entityId, relations); + responses.push({ entityId }); } } - const entityUpserts = (await Promise.all(tasks)).flat(); + if (options?.outputEntities) { + const writtenEntities = await this.database.entities( + tx, + EntityFilters.ofMatchers({ + 'metadata.uid': responses.map(e => e.entityId), + }), + ); + responses = writtenEntities.map(e => ({ + entityId: e.entity.metadata.uid!, + entity: e.entity, + })); + } + // If this is only a dry run, cancel the database transaction even if it + // was successful. if (options?.dryRun) { - // If this is only a dry run, cancel the database transaction even if it was successful. await tx.rollback(); - this.logger.debug(`Performed successful dry run of adding entities`); } - return entityUpserts; + return responses; }); } - // Set the relations originating from an entity using the DB layer - private async setRelations( - originatingEntityId: string, - relations: EntityRelationSpec[], - tx: Transaction, - ): Promise { - await this.database.setRelations(tx, originatingEntityId, relations); - } - // Given a batch of entities that were just read from a location, take them // into consideration by comparing against the existing catalog entities and // produce the list of entities to be added, and the list of entities to be @@ -279,6 +244,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }> { const markTimestamp = process.hrtime(); + // Here we make use of the fact that all of the entities share kind and + // namespace within a batch const names = requests.map(({ entity }) => entity.metadata.name); const oldEntities = await this.database.entities( tx, @@ -300,12 +267,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { for (const request of requests) { const newEntity = request.entity; const oldEntity = oldEntitiesByName.get(newEntity.metadata.name); + const newLocation = newEntity.metadata.annotations?.[LOCATION_ANNOTATION]; + const oldLocation = + oldEntity?.metadata.annotations?.[LOCATION_ANNOTATION]; if (!oldEntity) { toAdd.push(request); + } else if (oldLocation !== newLocation) { + this.logger.warn( + `Rejecting write of entity ${serializeEntityRef( + newEntity, + )} from ${newLocation} because entity existed from ${oldLocation}`, + ); + toIgnore.push(request); } else if (entityHasChanges(oldEntity, newEntity)) { // TODO(freben): This currently uses addOrUpdateEntity under the hood, // but should probably calculate the end result entity right here - // instead and call a dedicated batch update database method instead + // instead and call a dedicated batch update database method toUpdate.push(request); } else { toIgnore.push(request); @@ -332,22 +309,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const res = await this.database.addEntities( tx, - requests.map(({ entity }) => ({ locationId, entity })), + requests.map(({ entity, relations }) => ({ + locationId, + entity, + relations, + })), ); - const entityIds = res.map(({ entity }) => ({ + const responses = res.map(({ entity }) => ({ entityId: entity.metadata.uid!, })); - for (const [index, { entityId }] of entityIds.entries()) { - await this.setRelations(entityId, requests[index].relations, tx); - } - this.logger.debug( `Added ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return entityIds; + return responses; } // Efficiently updates the given entities into storage, under the assumption @@ -358,19 +335,52 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); - const responseIds: EntityUpsertResponse[] = []; + const responses: EntityUpsertResponse[] = []; + // TODO(freben): Still not batched - for (const entity of requests) { - const res = await this.addOrUpdateEntity(entity.entity, tx, locationId); + for (const request of requests) { + const res = await this.addOrUpdateEntity(tx, request, locationId); const entityId = res.metadata.uid!; - responseIds.push({ entityId }); - await this.setRelations(entityId, entity.relations, tx); + responses.push({ entityId }); } this.logger.debug( `Updated ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return responseIds; + return responses; + } + + // TODO(freben): Incorporate this into batchUpdate which is the only caller + private async addOrUpdateEntity( + tx: Transaction, + { entity, relations }: EntityUpsertRequest, + locationId?: string, + ): Promise { + // 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, relations }, + existing.entity.metadata.etag, + existing.entity.metadata.generation, + ); + } else { + const added = await this.database.addEntities(tx, [ + { locationId, entity, relations }, + ]); + response = added[0]; + } + + return response.entity; } } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index 9ce53d2850..91245c0789 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -70,7 +70,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { async logUpdateSuccess( locationId: string, - entityName?: string, + entityName?: string | string[], ): Promise { await this.database.addLocationUpdateLogEvent( locationId, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 52bb6e95ab..b012ee3606 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -32,17 +32,30 @@ export type EntityUpsertResponse = { }; export type EntitiesCatalog = { + /** + * Fetch entities. + * + * @param filter A filter to apply when reading + */ entities(filter?: EntityFilter): Promise; + + /** + * Removes a single entity. + * + * @param uid The metadata.uid of the entity + */ removeEntityByUid(uid: string): Promise; /** * Writes a number of entities efficiently to storage. * - * @param entities Some entities - * @param locationId The location that they all belong to + * @param requests The entities and their relations + * @param options.locationId The location that they all belong to (default none) + * @param options.dryRun Whether to throw away the results (default false) + * @param options.outputEntities Whether to return the resulting entities (default false) */ batchAddOrUpdateEntities( - entities: EntityUpsertRequest[], + requests: EntityUpsertRequest[], options?: { locationId?: string; dryRun?: boolean; @@ -80,7 +93,10 @@ export type LocationsCatalog = { locations(): Promise; location(id: string): Promise; locationHistory(id: string): Promise; - logUpdateSuccess(locationId: string, entityName?: string): Promise; + logUpdateSuccess( + locationId: string, + entityName?: string | string[], + ): Promise; logUpdateFailure( locationId: string, error?: Error, diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index ffffd85f04..130ed41eb1 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -54,6 +54,7 @@ describe('CommonDatabase', () => { }, spec: { i: 'j' }, }, + relations: [], }; entityResponse = { @@ -152,6 +153,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -159,6 +161,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, ]; await expect( @@ -174,6 +177,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -181,6 +185,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'nS1' }, }, + relations: [], }, ]; await expect( @@ -196,6 +201,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -203,6 +209,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns2' }, }, + relations: [], }, ]; await expect( @@ -285,7 +292,7 @@ describe('CommonDatabase', () => { db.addEntities(tx, [entityRequest]), ); const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion); expect(updated.entity.kind).toEqual(added.entity.kind); @@ -305,7 +312,7 @@ describe('CommonDatabase', () => { ); added.entity.metadata.name! = 'new!'; const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.metadata.name).toEqual('new!'); }); @@ -316,7 +323,11 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, 'garbage'), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + 'garbage', + ), ), ).rejects.toThrow(ConflictError); }); @@ -327,7 +338,12 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, undefined, 1e20), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + undefined, + 1e20, + ), ), ).rejects.toThrow(ConflictError); }); @@ -347,7 +363,10 @@ describe('CommonDatabase', () => { spec: { c: null }, }; await db.transaction(async tx => { - await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]); + await db.addEntities(tx, [ + { entity: e1, relations: [] }, + { entity: e2, relations: [] }, + ]); }); const result = await db.transaction(async tx => db.entities(tx)); expect(result.length).toEqual(2); @@ -385,7 +404,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -426,7 +445,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -566,8 +585,8 @@ describe('CommonDatabase', () => { const { id2: secondEntityId } = await db.transaction(async tx => { const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [ - { entity: entity1 }, - { entity: entity2 }, + { entity: entity1, relations: [] }, + { entity: entity2, relations: [] }, ]); const id1 = e1?.metadata?.uid!; const id2 = e2?.metadata?.uid!; @@ -665,7 +684,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index f86ab9e598..d641cbb7b6 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -95,35 +95,6 @@ export class CommonDatabase implements Database { } } - async addEntity( - txOpaque: Transaction, - request: DbEntityRequest, - ): Promise { - const tx = txOpaque as Knex.Transaction; - - if (request.entity.metadata.uid !== undefined) { - throw new InputError('May not specify uid for new entities'); - } else if (request.entity.metadata.etag !== undefined) { - throw new InputError('May not specify etag for new entities'); - } else if (request.entity.metadata.generation !== undefined) { - throw new InputError('May not specify generation for new entities'); - } - - const newEntity = lodash.cloneDeep(request.entity); - newEntity.metadata = { - ...newEntity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, - }; - - const newRow = this.toEntityRow(request.locationId, newEntity); - await tx('entities').insert(newRow); - await this.updateEntitiesSearch(tx, newRow.id, newEntity); - - return { locationId: request.locationId, entity: newEntity }; - } - async addEntities( txOpaque: Transaction, request: DbEntityRequest[], @@ -132,9 +103,10 @@ export class CommonDatabase implements Database { const result: DbEntityResponse[] = []; const entityRows: DbEntitiesRow[] = []; + const relationRows: DbEntitiesRelationsRow[] = []; const searchRows: DbEntitiesSearchRow[] = []; - for (const { entity, locationId } of request) { + for (const { entity, relations, locationId } of request) { if (entity.metadata.uid !== undefined) { throw new InputError('May not specify uid for new entities'); } else if (entity.metadata.etag !== undefined) { @@ -145,28 +117,27 @@ export class CommonDatabase implements Database { throw new InputError('May not specify relations for new entities'); } + const uid = generateEntityUid(); + const etag = generateEntityEtag(); + const generation = 1; const newEntity = { ...entity, metadata: { ...entity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, + uid, + etag, + generation, }, }; result.push({ entity: newEntity, locationId }); entityRows.push(this.toEntityRow(locationId, newEntity)); - searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity)); + relationRows.push(...this.toRelationRows(uid, relations)); + searchRows.push(...buildEntitySearch(uid, newEntity)); } await tx.batchInsert('entities', entityRows, BATCH_SIZE); - await tx('entities_search') - .whereIn( - 'entity_id', - entityRows.map(r => r.id), - ) - .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); await tx.batchInsert('entities_search', searchRows, BATCH_SIZE); return result; @@ -181,8 +152,7 @@ export class CommonDatabase implements Database { const tx = txOpaque as Knex.Transaction; const { uid } = request.entity.metadata; - - if (uid === undefined) { + if (!uid) { throw new InputError('Must specify uid when updating entities'); } @@ -193,40 +163,48 @@ export class CommonDatabase implements Database { if (oldRows.length !== 1) { throw new NotFoundError('No matching entity found'); } + const etag = oldRows[0].etag; + const generation = Number(oldRows[0].generation); - // Validate the old entity - const oldRow = oldRows[0]; - // The Number cast is here because sqlite reads it as a string, no matter - // what the table actually says - oldRow.generation = Number(oldRow.generation); - if (matchingEtag) { - if (matchingEtag !== oldRow.etag) { - throw new ConflictError( - `Etag mismatch, expected="${matchingEtag}" found="${oldRow.etag}"`, - ); - } + // Validate the old entity. The Number cast is here because sqlite reads it + // as a string, no matter what the table actually says. + if (matchingEtag && matchingEtag !== etag) { + throw new ConflictError( + `Etag mismatch, expected="${matchingEtag}" found="${etag}"`, + ); } - if (matchingGeneration) { - if (matchingGeneration !== oldRow.generation) { - throw new ConflictError( - `Generation mismatch, expected="${matchingGeneration}" found="${oldRow.generation}"`, - ); - } + if (matchingGeneration && matchingGeneration !== generation) { + throw new ConflictError( + `Generation mismatch, expected="${matchingGeneration}" found="${generation}"`, + ); } // Store the updated entity; select on the old etag to ensure that we do // not lose to another writer const newRow = this.toEntityRow(request.locationId, request.entity); const updatedRows = await tx('entities') - .where({ id: oldRow.id, etag: oldRow.etag }) + .where({ id: uid, etag }) .update(newRow); - - // If this happens, somebody else changed the entity just now if (updatedRows !== 1) { throw new ConflictError(`Failed to update entity`); } - await this.updateEntitiesSearch(tx, oldRow.id, request.entity); + const relationRows = this.toRelationRows(uid, request.relations); + await tx('entities_relations') + .where({ originating_entity_id: uid }) + .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); + + try { + const entries = buildEntitySearch(uid, request.entity); + await tx('entities_search') + .where({ entity_id: uid }) + .del(); + await tx.batchInsert('entities_search', entries, BATCH_SIZE); + } catch { + // ignore intentionally - if this happens, the entity was deleted before + // we got around to writing the entries + } return request; } @@ -271,7 +249,7 @@ export class CommonDatabase implements Database { .select('entities.*') .orderBy('full_name', 'asc'); - return Promise.all(rows.map(row => this.toEntityResponse(tx, row))); + return this.toEntityResponses(tx, rows); } async entityByName( @@ -290,7 +268,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async entityByUid( @@ -307,7 +285,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async removeEntityByUid(txOpaque: Transaction, uid: string): Promise { @@ -326,26 +304,12 @@ export class CommonDatabase implements Database { relations: EntityRelationSpec[], ): Promise { const tx = txOpaque as Knex.Transaction; + const relationRows = this.toRelationRows(originatingEntityId, relations); - // remove all relations that exist for the originating entity id. await tx('entities_relations') .where({ originating_entity_id: originatingEntityId }) .del(); - - const serializeName = (e: EntityName) => - `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - - const relationsRows: DbEntitiesRelationsRow[] = relations.map( - ({ source, target, type }) => ({ - originating_entity_id: originatingEntityId, - source_full_name: serializeName(source), - target_full_name: serializeName(target), - type, - }), - ); - - // TODO(blam): translate constraint failures to sane NotFoundError instead - await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); } async addLocation( @@ -428,7 +392,7 @@ export class CommonDatabase implements Database { async addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise { // Remove log entries older than a day @@ -438,30 +402,19 @@ export class CommonDatabase implements Database { .where('created_at', '<', cutoff.toISOString()) .del(); - await this.database( - 'location_update_log', - ).insert({ - status, - location_id: locationId, - entity_name: entityName, - message, - }); - } + const items: Partial[] = [entityName] + .flat() + .map(n => ({ + status, + location_id: locationId, + entity_name: n, + message, + })); - private async updateEntitiesSearch( - tx: Knex.Transaction, - entityId: string, - data: Entity, - ): Promise { - try { - const entries = buildEntitySearch(entityId, data); - await tx('entities_search') - .where({ entity_id: entityId }) - .del(); - await tx('entities_search').insert(entries); - } catch { - // ignore intentionally - if this happens, the entity was deleted before - // we got around to writing the entries + for (const chunk of lodash.chunk(items, BATCH_SIZE)) { + await this.database( + 'location_update_log', + ).insert(chunk); } } @@ -490,30 +443,85 @@ export class CommonDatabase implements Database { }; } - private async toEntityResponse( - tx: Knex.Transaction, - row: DbEntitiesRow, - ): Promise { - const entity = JSON.parse(row.data) as Entity; - entity.metadata.uid = row.id; - entity.metadata.etag = row.etag; - entity.metadata.generation = Number(row.generation); // cast due to sqlite + private toRelationRows( + originatingEntityId: string, + relations: EntityRelationSpec[], + ): DbEntitiesRelationsRow[] { + const serializeName = (e: EntityName) => + `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - // TODO(Rugvip): This is here because it's simple for now, but we likely - // need to refactor this to be more efficient or introduce pagination. - const relations = await tx('entities_relations') - .where({ source_full_name: row.full_name }) - .orderBy(['type', 'target_full_name']) - .select(); - - entity.relations = relations.map(r => ({ - target: parseEntityName(r.target_full_name), - type: r.type, + const rows = relations.map(({ source, target, type }) => ({ + originating_entity_id: originatingEntityId, + source_full_name: serializeName(source), + target_full_name: serializeName(target), + type, })); - return { - locationId: row.location_id || undefined, - entity, - }; + return deduplicateRelations(rows); + } + + private async toEntityResponses( + tx: Knex.Transaction, + rows: DbEntitiesRow[], + ): Promise { + // TODO(Rugvip): This is here because it's simple for now, but we likely + // need to refactor this to be more efficient or introduce pagination. + const relations = await this.getRelationsPerFullName( + tx, + rows.map(r => r.full_name), + ); + + const result = new Array(); + for (const row of rows) { + const entity = JSON.parse(row.data) as Entity; + entity.metadata.uid = row.id; + entity.metadata.etag = row.etag; + entity.metadata.generation = Number(row.generation); // cast due to sqlite + + entity.relations = (relations[row.full_name] ?? []).map(r => ({ + target: parseEntityName(r.target_full_name), + type: r.type, + })); + + result.push({ + locationId: row.location_id || undefined, + entity, + }); + } + + return result; + } + + // Returns a mapping from e.g. component:default/foo to the relations whose + // source_full_name matches that. + private async getRelationsPerFullName( + tx: Knex.Transaction, + sourceFullNames: string[], + ): Promise> { + const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500); + + const relations = new Array(); + for (const batch of batches) { + relations.push( + ...(await tx('entities_relations') + .whereIn('source_full_name', batch) + .orderBy(['type', 'target_full_name']) + .select()), + ); + } + + return lodash.groupBy( + deduplicateRelations(relations), + r => r.source_full_name, + ); } } + +function deduplicateRelations( + rows: DbEntitiesRelationsRow[], +): DbEntitiesRelationsRow[] { + return lodash.uniqBy( + rows, + r => `${r.source_full_name}:${r.target_full_name}:${r.type}`, + ); +} diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 1307b063f2..5a9f60853a 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -33,6 +33,7 @@ export type DbEntitiesRow = { export type DbEntityRequest = { locationId?: string; entity: Entity; + relations: EntityRelationSpec[]; }; export type DbEntityResponse = { @@ -184,10 +185,12 @@ export type Database = { removeEntityByUid(tx: Transaction, uid: string): Promise; /** - * Remove current relations for the entity and replace them with the new relations array + * Remove current relations for the entity and replace them with the new + * relations array. + * * @param tx An ongoing transaction - * @param entityUid the entity uid - * @param relations the relationships to be set + * @param entityUid The entity uid + * @param relations The relationships to be set */ setRelations( tx: Transaction, @@ -208,7 +211,7 @@ export type Database = { addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise; }; diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index e82699f526..b112e32ef5 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -147,10 +147,10 @@ export class CatalogRulesEnforcer { } for (const matcher of matchers) { - if (matcher.type !== location.type) { + if (matcher.type !== location?.type) { continue; } - if (matcher.target && matcher.target !== location.target) { + if (matcher.target && matcher.target !== location?.target) { continue; } return true; @@ -165,7 +165,7 @@ export class CatalogRulesEnforcer { } for (const matcher of matchers) { - if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) { + if (entity?.kind?.toLowerCase() !== matcher.kind.toLowerCase()) { continue; } diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 40b48ca4ab..b9078df93f 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -372,10 +372,9 @@ describe('HigherOrderOperations', () => { '123', undefined, ); - expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith( - '123', + expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith('123', [ 'c1', - ); + ]); }); it('logs unsuccessful updates when reader fails', async () => { diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index f352e8d3fd..b7e9c48fe5 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation { // Write if (!previousLocation && !dryRun) { // TODO: We do not include location operations in the dryRun. We might perform - // this operation as a seperate dry run. + // this operation as a separate dry run. await this.locationsCatalog.addLocation(location); } if (readerOutput.entities.length === 0) { @@ -116,28 +116,34 @@ export class HigherOrderOperations implements HigherOrderOperation { */ async refreshAllLocations(): Promise { const startTimestamp = process.hrtime(); - this.logger.info('Beginning locations refresh'); + const logger = this.logger.child({ + component: 'catalog-all-locations-refresh', + }); + + logger.info('Locations Refresh: Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - this.logger.info(`Visiting ${locations.length} locations`); + logger.info(`Locations Refresh: Visiting ${locations.length} locations`); for (const { data: location } of locations) { - this.logger.info( - `Refreshing location ${location.type}:${location.target}`, + logger.info( + `Locations Refresh: Refreshing location ${location.type}:${location.target}`, ); try { await this.refreshSingleLocation(location); await this.locationsCatalog.logUpdateSuccess(location.id, undefined); } catch (e) { - this.logger.warn( - `Failed to refresh location ${location.type}:${location.target}, ${e}`, + logger.warn( + `Locations Refresh: Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, ); await this.locationsCatalog.logUpdateFailure(location.id, e); } } - this.logger.info( - `Completed locations refresh in ${durationText(startTimestamp)}`, + logger.info( + `Locations Refresh: Completed locations refresh in ${durationText( + startTimestamp, + )}`, ); } @@ -152,7 +158,7 @@ export class HigherOrderOperations implements HigherOrderOperation { for (const item of readerOutput.errors) { this.logger.warn( - `Failed item in location ${item.location.type}:${item.location.target}, ${item.error}`, + `Failed item in location ${item.location.type}:${item.location.target}, ${item.error.stack}`, ); } @@ -182,12 +188,10 @@ export class HigherOrderOperations implements HigherOrderOperation { this.logger.info(`Posting update success markers`); - for (const entity of readerOutput.entities) { - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.entity.metadata.name, - ); - } + await this.locationsCatalog.logUpdateSuccess( + location.id, + readerOutput.entities.map(e => e.entity.metadata.name), + ); this.logger.info( `Wrote ${readerOutput.entities.length} entities from location ${ diff --git a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts new file mode 100644 index 0000000000..97df326fc5 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Logger } from 'winston'; +import parseGitUri from 'git-url-parse'; +import { + AnalyzeLocationRequest, + AnalyzeLocationResponse, + LocationAnalyzer, +} from './types'; + +export class RepoLocationAnalyzer implements LocationAnalyzer { + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + async analyzeLocation( + request: AnalyzeLocationRequest, + ): Promise { + const { owner, name, source } = parseGitUri(request.location.target); + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: name, + // Probably won't handle properly self-hosted git providers with custom url + annotations: { [`${source}/project-slug`]: `${owner}/${name}` }, + }, + spec: { type: 'other', lifecycle: 'unknown' }, + }; + + this.logger.debug(`entity created for ${request.location.target}`); + return { + existingEntityFiles: [], + generateEntities: [{ entity, fields: [] }], + }; + } +} diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 8031b8ff0d..a9248bb3fb 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -97,7 +97,7 @@ export class LocationReaders implements LocationReader { output.errors.push({ location: item.location, error: new Error( - `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`, + `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.type} ${item.location.target}`, ), }); } @@ -117,7 +117,7 @@ export class LocationReaders implements LocationReader { items = newItems; } - const message = `Max recursion depth ${MAX_DEPTH} reached for ${location.type} ${location.target}`; + const message = `Max recursion depth ${MAX_DEPTH} reached for location ${location.type} ${location.target}`; logger.warn(message); output.errors.push({ location, error: new Error(message) }); return output; diff --git a/plugins/catalog-backend/src/ingestion/index.ts b/plugins/catalog-backend/src/ingestion/index.ts index 4ebb2edc79..fad4ae34f7 100644 --- a/plugins/catalog-backend/src/ingestion/index.ts +++ b/plugins/catalog-backend/src/ingestion/index.ts @@ -16,12 +16,5 @@ export { HigherOrderOperations } from './HigherOrderOperations'; export { LocationReaders } from './LocationReaders'; -export type { - AddLocationResult, - HigherOrderOperation, - LocationReader, - ReadLocationEntity, - ReadLocationError, - ReadLocationResult, -} from './types'; +export * from './types'; export * from './processors'; diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts new file mode 100644 index 0000000000..5fa63d800a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -0,0 +1,231 @@ +/* + * 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 { + ApiEntity, + ComponentEntity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; +import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; + +describe('BuiltinKindsEntityProcessor', () => { + describe('postProcessEntity', () => { + const processor = new BuiltinKindsEntityProcessor(); + const location = { type: 'a', target: 'b' }; + const emit = jest.fn(); + + afterEach(() => jest.resetAllMocks()); + + it('generates relations for component entities', async () => { + const entity: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + lifecycle: 'l', + implementsApis: ['a'], + providesApis: ['b'], + consumesApis: ['c'], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(8); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'a' }, + type: 'apiProvidedBy', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'providesApi', + target: { kind: 'API', namespace: 'default', name: 'a' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'b' }, + type: 'apiProvidedBy', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'providesApi', + target: { kind: 'API', namespace: 'default', name: 'b' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'c' }, + type: 'apiConsumedBy', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'consumesApi', + target: { kind: 'API', namespace: 'default', name: 'c' }, + }, + }); + }); + + it('generates relations for api entities', async () => { + const entity: ApiEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + lifecycle: 'l', + definition: 'd', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'API', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); + + it('generates relations for user entities', async () => { + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'n' }, + spec: { + memberOf: ['g'], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'User', namespace: 'default', name: 'n' }, + type: 'memberOf', + target: { kind: 'Group', namespace: 'default', name: 'g' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'g' }, + type: 'hasMember', + target: { kind: 'User', namespace: 'default', name: 'n' }, + }, + }); + }); + + it('generates relations for group entities', async () => { + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { + type: 't', + parent: 'p', + children: ['c'], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'n' }, + type: 'childOf', + target: { kind: 'Group', namespace: 'default', name: 'p' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'p' }, + type: 'parentOf', + target: { kind: 'Group', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'c' }, + type: 'childOf', + target: { kind: 'Group', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'n' }, + type: 'parentOf', + target: { kind: 'Group', namespace: 'default', name: 'c' }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 4d1eb5e583..aa68222a4e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -15,15 +15,33 @@ */ import { + ApiEntity, apiEntityV1alpha1Validator, + ComponentEntity, componentEntityV1alpha1Validator, Entity, + getEntityName, + GroupEntity, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, + LocationSpec, + parseEntityRef, + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CHILD_OF, + RELATION_CONSUMES_API, + RELATION_HAS_MEMBER, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PARENT_OF, + RELATION_PROVIDES_API, templateEntityV1alpha1Validator, + UserEntity, userEntityV1alpha1Validator, } from '@backstage/catalog-model'; -import { CatalogProcessor } from './types'; +import * as result from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ @@ -45,4 +63,126 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { return false; } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + const selfRef = getEntityName(entity); + + /* + * Utilities + */ + + function doEmit( + targets: string | string[] | undefined, + context: { defaultKind: string; defaultNamespace: string }, + outgoingRelation: string, + incomingRelation: string, + ): void { + if (!targets) { + return; + } + for (const target of [targets].flat()) { + const targetRef = parseEntityRef(target, context); + emit( + result.relation({ + source: selfRef, + type: outgoingRelation, + target: targetRef, + }), + ); + emit( + result.relation({ + source: targetRef, + type: incomingRelation, + target: selfRef, + }), + ); + } + } + + /* + * Emit relations for the Component kind + */ + + if (entity.kind === 'Component') { + const component = entity as ComponentEntity; + doEmit( + component.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + component.spec.implementsApis, + { defaultKind: 'API', defaultNamespace: selfRef.namespace }, + RELATION_PROVIDES_API, + RELATION_API_PROVIDED_BY, + ); + doEmit( + component.spec.providesApis, + { defaultKind: 'API', defaultNamespace: selfRef.namespace }, + RELATION_PROVIDES_API, + RELATION_API_PROVIDED_BY, + ); + doEmit( + component.spec.consumesApis, + { defaultKind: 'API', defaultNamespace: selfRef.namespace }, + RELATION_CONSUMES_API, + RELATION_API_CONSUMED_BY, + ); + } + + /* + * Emit relations for the API kind + */ + + if (entity.kind === 'API') { + const api = entity as ApiEntity; + doEmit( + api.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + + /* + * Emit relations for the User kind + */ + + if (entity.kind === 'User') { + const user = entity as UserEntity; + doEmit( + user.spec.memberOf, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_MEMBER_OF, + RELATION_HAS_MEMBER, + ); + } + + /* + * Emit relations for the Group kind + */ + + if (entity.kind === 'Group') { + const group = entity as GroupEntity; + doEmit( + group.spec.parent, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_CHILD_OF, + RELATION_PARENT_OF, + ); + doEmit( + group.spec.children, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_PARENT_OF, + RELATION_CHILD_OF, + ); + } + + return entity; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index e4ddb249d6..be4f15e501 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { CodeOwnersEntry } from 'codeowners-utils'; import { @@ -27,6 +28,8 @@ import { resolveCodeOwner, } from './CodeOwnersProcessor'; +const logger = getVoidLogger(); + describe('CodeOwnersProcessor', () => { const mockUrl = ({ basePath = '' } = {}): string => `https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`; @@ -157,18 +160,21 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read }; - const result = await findRawCodeOwners(mockLocation(), reader); + const reader = { read, readTree: jest.fn() }; + const result = await findRawCodeOwners(mockLocation(), { + reader, + logger, + }); expect(result).toEqual(ownersText); }); - it('should raise error when no codeowner', async () => { + it('should return undefined when no codeowner', async () => { const read = jest.fn().mockRejectedValue(mockReadResult()); - const reader = { read }; + const reader = { read, readTree: jest.fn() }; await expect( - findRawCodeOwners(mockLocation(), reader), - ).rejects.toBeInstanceOf(Error); + findRawCodeOwners(mockLocation(), { reader, logger }), + ).resolves.toBeUndefined(); }); it('should look at known codeowner locations', async () => { @@ -178,9 +184,12 @@ describe('CodeOwnersProcessor', () => { .mockImplementationOnce(() => mockReadResult({ error: 'foo' })) .mockImplementationOnce(() => mockReadResult({ error: 'bar' })) .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read }; + const reader = { read, readTree: jest.fn() }; - const result = await findRawCodeOwners(mockLocation(), reader); + const result = await findRawCodeOwners(mockLocation(), { + reader, + logger, + }); expect(read.mock.calls.length).toBe(5); expect(read.mock.calls[0]).toEqual([mockReadUrl('')]); @@ -197,21 +206,21 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); - const reader = { read }; + const reader = { read, readTree: jest.fn() }; - const owner = await resolveCodeOwner(mockLocation(), reader); + const owner = await resolveCodeOwner(mockLocation(), { reader, logger }); expect(owner).toBe('backstage-core'); }); - it('should raise an error when no codeowner', async () => { + it('should return undefined when no codeowner', async () => { const read = jest .fn() .mockImplementation(() => mockReadResult({ error: 'error: foo' })); - const reader = { read }; + const reader = { read, readTree: jest.fn() }; await expect( - resolveCodeOwner(mockLocation(), reader), - ).rejects.toBeInstanceOf(Error); + resolveCodeOwner(mockLocation(), { reader, logger }), + ).resolves.toBeUndefined(); }); }); @@ -221,8 +230,8 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); - const reader = { read }; - const processor = new CodeOwnersProcessor({ reader }); + const reader = { read, readTree: jest.fn() }; + const processor = new CodeOwnersProcessor({ reader, logger }); return { entity, processor, read }; }; diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index 8a10c4169b..e2b8a8b62c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { NotFoundError, UrlReader } from '@backstage/backend-common'; import { Entity, LocationSpec } from '@backstage/catalog-model'; import * as codeowners from 'codeowners-utils'; import { CodeOwnersEntry } from 'codeowners-utils'; @@ -22,6 +22,7 @@ import { CodeOwnersEntry } from 'codeowners-utils'; import 'core-js/features/promise'; import parseGitUri from 'git-url-parse'; import { filter, get, head, pipe, reverse } from 'lodash/fp'; +import { Logger } from 'winston'; import { CatalogProcessor } from './types'; const ALLOWED_LOCATION_TYPES = [ @@ -41,6 +42,7 @@ const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab']; type Options = { reader: UrlReader; + logger: Logger; }; export class CodeOwnersProcessor implements CatalogProcessor { @@ -60,7 +62,10 @@ export class CodeOwnersProcessor implements CatalogProcessor { return entity; } - const owner = await resolveCodeOwner(location, this.options.reader); + const owner = await resolveCodeOwner(location, this.options); + if (!owner) { + return entity; + } return { ...entity, @@ -71,12 +76,11 @@ export class CodeOwnersProcessor implements CatalogProcessor { export async function resolveCodeOwner( location: LocationSpec, - reader: UrlReader, + options: Options, ): Promise { - const ownersText = await findRawCodeOwners(location, reader); - + const ownersText = await findRawCodeOwners(location, options); if (!ownersText) { - throw Error(`Unable to find codeowners file for: ${location.target}`); + return undefined; } const owners = parseCodeOwners(ownersText); @@ -86,18 +90,33 @@ export async function resolveCodeOwner( export async function findRawCodeOwners( location: LocationSpec, - reader: UrlReader, + options: Options, ): Promise { const readOwnerLocation = async (basePath: string): Promise => { const ownerUrl = buildCodeOwnerUrl( location.target, `${basePath}/CODEOWNERS`, ); - const data = await reader.read(ownerUrl); + const data = await options.reader.read(ownerUrl); return data.toString(); }; - return Promise.any(KNOWN_LOCATIONS.map(readOwnerLocation)); + const candidates = KNOWN_LOCATIONS.map(readOwnerLocation); + return Promise.any(candidates).catch((aggregateError: AggregateError) => { + const hardError = aggregateError.errors.find( + error => !(error instanceof NotFoundError), + ); + if (hardError) { + options.logger.warn( + `Failed to read codeowners for location ${location.type}:${location.target}, ${hardError}`, + ); + } else { + options.logger.debug( + `Failed to find codeowners for location ${location.type}:${location.target}`, + ); + } + return undefined; + }); } export function buildCodeOwnerUrl( diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 9aecb8b86e..ab6e3aa008 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -18,9 +18,9 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; +import { getOrganizationTeams, getOrganizationUsers } from './github'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { getOrganizationTeams, getOrganizationUsers } from './util/github'; import { buildOrgHierarchy } from './util/org'; /** @@ -64,6 +64,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { const client = !provider.token ? graphql : graphql.defaults({ + baseUrl: provider.apiBaseUrl, headers: { authorization: `token ${provider.token}`, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts new file mode 100644 index 0000000000..998dfb62a2 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import { toAbsoluteUrl } from './LocationEntityProcessor'; +import path from 'path'; + +describe('LocationEntityProcessor', () => { + describe('toAbsoluteUrl', () => { + it('handles files', () => { + const base: LocationSpec = { + type: 'file', + target: `some${path.sep}path${path.sep}catalog-info.yaml`, + }; + expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe( + `some${path.sep}path${path.sep}c`, + ); + expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`); + }); + + it('handles urls', () => { + const base: LocationSpec = { + type: 'url', + target: 'http://a.com/b/catalog-info.yaml', + }; + expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d'); + expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d'); + expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z'); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts index 86e17f07f2..1c7e13f1c4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -17,27 +17,52 @@ import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import path from 'path'; + +export function toAbsoluteUrl(base: LocationSpec, target: string): string { + try { + if (base.type === 'file') { + if (target.startsWith('.')) { + return path.join(path.dirname(base.target), target); + } + return target; + } + return new URL(target, base.target).toString(); + } catch (e) { + return target; + } +} export class LocationRefProcessor implements CatalogProcessor { async postProcessEntity( entity: Entity, - _location: LocationSpec, + location: LocationSpec, emit: CatalogProcessorEmit, ): Promise { if (entity.kind === 'Location') { - const location = entity as LocationEntity; - if (location.spec.target) { + const locationEntity = entity as LocationEntity; + + const type = locationEntity.spec.type || location.type; + if (type === 'file' && location.target.endsWith(path.sep)) { emit( - result.location( - { type: location.spec.type, target: location.spec.target }, - false, + result.inputError( + location, + `LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, ), ); } - if (location.spec.targets) { - for (const target of location.spec.targets) { - emit(result.location({ type: location.spec.type, target }, false)); - } + + const targets = new Array(); + if (locationEntity.spec.target) { + targets.push(locationEntity.spec.target); + } + if (locationEntity.spec.targets) { + targets.push(...locationEntity.spec.targets); + } + + for (const maybeRelativeTarget of targets) { + const target = toAbsoluteUrl(location, maybeRelativeTarget); + emit(result.location({ type, target }, false)); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/MicrosoftGraphOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/MicrosoftGraphOrgReaderProcessor.ts new file mode 100644 index 0000000000..3456f2cafa --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/MicrosoftGraphOrgReaderProcessor.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; +import { + MicrosoftGraphClient, + MicrosoftGraphProviderConfig, + readMicrosoftGraphConfig, + readMicrosoftGraphOrg, +} from './microsoftGraph'; +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; + +/** + * Extracts teams and users out of an LDAP server. + */ +export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { + private readonly providers: MicrosoftGraphProviderConfig[]; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const c = config.getOptionalConfig('catalog.processors.microsoftGraphOrg'); + return new MicrosoftGraphOrgReaderProcessor({ + ...options, + providers: c ? readMicrosoftGraphConfig(c) : [], + }); + } + + constructor(options: { + providers: MicrosoftGraphProviderConfig[]; + logger: Logger; + }) { + this.providers = options.providers; + this.logger = options.logger; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== 'microsoft-graph-org') { + return false; + } + + const provider = this.providers.find(p => + location.target.startsWith(p.target), + ); + if (!provider) { + throw new Error( + `There is no Microsoft Graph Org provider that matches ${location.target}. Please add a configuration entry for it under catalog.processors.microsoftGraphOrg.providers.`, + ); + } + + // Read out all of the raw data + const startTimestamp = Date.now(); + this.logger.info('Reading Microsoft Graph users and groups'); + + // We create a client each time as we need one that matches the specific provider + const client = MicrosoftGraphClient.create(provider); + const { users, groups } = await readMicrosoftGraphOrg( + client, + provider.tenantId, + { + userFilter: provider.userFilter, + groupFilter: provider.groupFilter, + }, + ); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${users.length} users and ${groups.length} groups from Microsoft Graph in ${duration} seconds`, + ); + + // Done! + for (const group of groups) { + emit(results.entity(location, group)); + } + for (const user of users) { + emit(results.entity(location, user)); + } + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts deleted file mode 100644 index 4c67589789..0000000000 --- a/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts +++ /dev/null @@ -1,74 +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. - */ - -import { - Entity, - ENTITY_DEFAULT_NAMESPACE, - LocationSpec, - parseEntityRef, - ApiEntityV1alpha1, - ComponentEntityV1alpha1, - RELATION_OWNED_BY, - RELATION_OWNER_OF, - getEntityName, -} from '@backstage/catalog-model'; -import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import * as result from './results'; - -const includedKinds = new Set(['api', 'component']); - -export class OwnerRelationProcessor implements CatalogProcessor { - async postProcessEntity( - entity: Entity, - _location: LocationSpec, - emit: CatalogProcessorEmit, - ): Promise { - if (!includedKinds.has(entity.kind.toLowerCase())) { - return entity; - } - const apiOrComponentEntity = entity as - | ApiEntityV1alpha1 - | ComponentEntityV1alpha1; - - const owner = apiOrComponentEntity.spec?.owner; - if (owner) { - const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE; - - const selfRef = getEntityName(entity); - const ownerRef = parseEntityRef(owner, { - defaultKind: 'group', - defaultNamespace: namespace, - }); - - emit( - result.relation({ - source: selfRef, - type: RELATION_OWNED_BY, - target: ownerRef, - }), - ); - emit( - result.relation({ - source: ownerRef, - type: RELATION_OWNER_OF, - target: selfRef, - }), - ); - } - - return entity; - } -} diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index 48cd2dae01..8b2632ad5d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -27,7 +27,7 @@ import { describe('PlaceholderProcessor', () => { const read: jest.MockedFunction = jest.fn(); - const reader: UrlReader = { read }; + const reader: UrlReader = { read, readTree: jest.fn() }; beforeEach(() => { jest.resetAllMocks(); @@ -88,7 +88,7 @@ describe('PlaceholderProcessor', () => { ); }); - it('rejects multiple placeholders', async () => { + it('ignores multiple placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { foo: jest.fn(), @@ -96,41 +96,35 @@ describe('PlaceholderProcessor', () => { }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); - it('rejects unknown placeholders', async () => { + it('ignores unknown placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { bar: jest.fn(), }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow('Encountered unknown placeholder $foo'); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index 0e23d51f61..b7b3df7b6e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -76,21 +76,28 @@ export class PlaceholderProcessor implements CatalogProcessor { ? [data, false] : [Object.fromEntries(entries.map(([k, [v]]) => [k, v])), true]; } else if (keys.length !== 1) { - throw new Error( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + // This was an object that had more than one key, some of which were + // dollar prefixed. We only handle the case where there is exactly one + // such key; anything else is left alone. + return [data, false]; } const resolverKey = keys[0].substr(1); + const resolverValue = data[keys[0]]; const resolver = this.options.resolvers[resolverKey]; - if (!resolver) { - throw new Error(`Encountered unknown placeholder \$${resolverKey}`); + if (!resolver || typeof resolverValue !== 'string') { + // If there was no such placeholder resolver or if the value was not a + // string, we err on the side of safety and assume that this is + // something that's best left alone. For example, if the input contains + // JSONSchema, there may be "$ref": "#/definitions/node" nodes in the + // document. + return [data, false]; } return [ await resolver({ key: resolverKey, - value: data[keys[0]], + value: resolverValue, baseUrl: location.target, read: this.options.reader.read.bind(this.options.reader), }), diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts similarity index 90% rename from plugins/catalog-backend/src/ingestion/processors/util/github.test.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 54afdd1bdc..8911e84d99 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ +import { msw } from '@backstage/test-utils'; import { graphql } from '@octokit/graphql'; import { graphql as graphqlMsw } from 'msw'; import { setupServer } from 'msw/node'; -import { msw } from '@backstage/test-utils'; import { getOrganizationTeams, getOrganizationUsers, @@ -78,6 +78,9 @@ describe('github', () => { { slug: 'team', combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', parentTeam: { slug: 'parent', combinedSlug: '', @@ -96,13 +99,18 @@ describe('github', () => { const output = { groups: [ expect.objectContaining({ - metadata: expect.objectContaining({ name: 'team' }), + metadata: expect.objectContaining({ + name: 'team', + description: 'The one and only team', + }), spec: { type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, parent: 'parent', - ancestors: [], children: [], - descendants: [], }, }), ], diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts similarity index 94% rename from plugins/catalog-backend/src/ingestion/processors/util/github.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.ts index 96cf4a7668..2b33d72f65 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -45,7 +45,9 @@ export type User = { export type Team = { slug: string; combinedSlug: string; + name?: string; description?: string; + avatarUrl?: string; parentTeam?: Team; members: Connection; }; @@ -137,6 +139,9 @@ export async function getOrganizationTeams( nodes { slug combinedSlug + name + description + avatarUrl parentTeam { slug } members(first: 100, membership: IMMEDIATE) { pageInfo { hasNextPage } @@ -162,14 +167,23 @@ export async function getOrganizationTeams( }, spec: { type: 'team', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; - if (team.description) entity.metadata.description = team.description; - if (team.parentTeam) entity.spec.parent = team.parentTeam.slug; + if (team.description) { + entity.metadata.description = team.description; + } + if (team.name) { + entity.spec.profile!.displayName = team.name; + } + if (team.avatarUrl) { + entity.spec.profile!.picture = team.avatarUrl; + } + if (team.parentTeam) { + entity.spec.parent = team.parentTeam.slug; + } const memberNames: string[] = []; groupMemberUsers.set(team.slug, memberNames); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/index.ts b/plugins/catalog-backend/src/ingestion/processors/github/index.ts new file mode 100644 index 0000000000..e903ab669a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/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 { getOrganizationTeams, getOrganizationUsers } from './github'; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index ef9c6332cd..2b477f18f9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -16,18 +16,18 @@ import * as results from './results'; -export { results }; -export * from './types'; - -export { parseEntityYaml } from './util/parse'; - export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; -export { OwnerRelationProcessor } from './OwnerRelationProcessor'; +export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor'; export { LocationRefProcessor } from './LocationEntityProcessor'; +export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; export { StaticLocationProcessor } from './StaticLocationProcessor'; +export * from './types'; export { UrlReaderProcessor } from './UrlReaderProcessor'; +export { parseEntityYaml } from './util/parse'; +export { results }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 7e6036668f..cc43827d87 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -66,6 +66,7 @@ describe('readLdapConfig', () => { name: 'cn', description: 'description', type: 'groupType', + displayName: 'cn', memberOf: 'memberOf', members: 'member', }, @@ -114,6 +115,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, @@ -161,6 +165,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index 2a63b20619..7608f247d4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -109,6 +109,15 @@ export type GroupConfig = { // The name of the attribute that shall be used for the value of the // spec.type field of the entity. Defaults to "groupType". type: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.displayName field of the entity. Defaults to "cn". + displayName: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.email field of the entity. + email?: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.picture field of the entity. + picture?: string; // The name of the attribute that shall be used for the values of the // spec.parent field of the entity. Defaults to "memberOf". memberOf: string; @@ -141,6 +150,7 @@ const defaultConfig = { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', type: 'groupType', memberOf: 'memberOf', members: 'member', @@ -220,6 +230,9 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { name: c.getOptionalString('name'), description: c.getOptionalString('description'), type: c.getOptionalString('type'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), memberOf: c.getOptionalString('memberOf'), members: c.getOptionalString('members'), }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts index e644bab22a..4155c2124e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts @@ -47,7 +47,7 @@ function group(data: RecursivePartial): GroupEntity { apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: 'name' }, - spec: { type: 'type', ancestors: [], children: [], descendants: [] }, + spec: { type: 'type', children: [] }, } as GroupEntity, data, ); @@ -138,6 +138,8 @@ describe('readLdapGroups', () => { cn: ['cn-value'], description: ['description-value'], tt: ['type-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], memberOf: ['x', 'y', 'z'], member: ['e', 'f', 'g'], entryDN: ['dn-value'], @@ -151,6 +153,9 @@ describe('readLdapGroups', () => { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', type: 'tt', memberOf: 'memberOf', members: 'member', @@ -173,9 +178,12 @@ describe('readLdapGroups', () => { }, spec: { type: 'type-value', - ancestors: [], + profile: { + displayName: 'cn-value', + email: 'mail-value', + picture: 'avatarUrl-value', + }, children: [], - descendants: [], }, }), ]); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts index 63fce00bd8..f0fb19ac02 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -150,9 +150,8 @@ export async function readLdapGroups( }, spec: { type: 'unknown', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; @@ -180,6 +179,15 @@ export async function readLdapGroups( mapStringAttr(attributes, map.type, v => { entity.spec.type = v; }); + mapStringAttr(attributes, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(attributes, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(attributes, map.picture, v => { + entity.spec.profile!.picture = v; + }); mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts new file mode 100644 index 0000000000..5ef82432bb --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts @@ -0,0 +1,363 @@ +/* + * 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 * as msal from '@azure/msal-node'; +import { msw } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { MicrosoftGraphClient } from './client'; + +describe('MicrosoftGraphClient', () => { + const confidentialClientApplication: jest.Mocked = { + acquireTokenByClientCredential: jest.fn(), + } as any; + let client: MicrosoftGraphClient; + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + confidentialClientApplication.acquireTokenByClientCredential.mockResolvedValue( + { token: 'ACCESS_TOKEN' } as any, + ); + client = new MicrosoftGraphClient( + 'https://example.com', + confidentialClientApplication, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should perform raw request', async () => { + worker.use( + rest.get('https://other.example.com/', (_, res, ctx) => + res(ctx.status(200), ctx.json({ value: 'example' })), + ), + ); + + const response = await client.requestRaw('https://other.example.com/'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ value: 'example' }); + expect( + confidentialClientApplication.acquireTokenByClientCredential, + ).toBeCalledTimes(1); + expect( + confidentialClientApplication.acquireTokenByClientCredential, + ).toBeCalledWith({ scopes: ['https://graph.microsoft.com/.default'] }); + }); + + it('should perform simple api request', async () => { + worker.use( + rest.get('https://example.com/users', (_, res, ctx) => + res(ctx.status(200), ctx.json({ value: 'example' })), + ), + ); + + const response = await client.requestApi('users'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ value: 'example' }); + }); + + it('should perform api request with filter, select and expand', async () => { + worker.use( + rest.get('https://example.com/users', (req, res, ctx) => + res(ctx.status(200), ctx.json({ queryString: req.url.search })), + ), + ); + + const response = await client.requestApi('users', { + filter: 'test eq true', + expand: ['children'], + select: ['id', 'children'], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + queryString: + '?$filter=test%20eq%20true&$select=id,children&$expand=children', + }); + }); + + it('should perform collection request for a single page', async () => { + worker.use( + rest.get('https://example.com/users', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: ['first'], + }), + ), + ), + ); + + const values = await collectAsyncIterable( + client.requestCollection('users'), + ); + + expect(values).toEqual(['first']); + }); + + it('should perform collection request for multiple pages', async () => { + worker.use( + rest.get('https://example.com/users', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: ['first'], + '@odata.nextLink': 'https://example.com/users2', + }), + ), + ), + ); + worker.use( + rest.get('https://example.com/users2', (_, res, ctx) => + res(ctx.status(200), ctx.json({ value: ['second'] })), + ), + ); + + const values = await collectAsyncIterable( + client.requestCollection('users'), + ); + + expect(values).toEqual(['first', 'second']); + }); + + it('should load user profile', async () => { + worker.use( + rest.get('https://example.com/users/user-id', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + surname: 'Example', + }), + ), + ), + ); + + const userProfile = await client.getUserProfile('user-id'); + + expect(userProfile).toEqual({ surname: 'Example' }); + }); + + it('should throw expection if load user profile fails', async () => { + worker.use( + rest.get('https://example.com/users/user-id', (_, res, ctx) => + res(ctx.status(404)), + ), + ); + + await expect(() => client.getUserProfile('user-id')).rejects.toThrowError(); + }); + + it('should load user profile photo with max size of 120', async () => { + worker.use( + rest.get('https://example.com/users/user-id/photos', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { + height: 120, + id: 120, + }, + { + height: 500, + id: 500, + }, + ], + }), + ), + ), + ); + worker.use( + rest.get( + 'https://example.com/users/user-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getUserPhotoWithSizeLimit('user-id', 120); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should not fail if user has no profile photo', async () => { + worker.use( + rest.get('https://example.com/users/user-id/photos', (_, res, ctx) => + res(ctx.status(404)), + ), + ); + + const photo = await client.getUserPhotoWithSizeLimit('user-id', 120); + + expect(photo).toBeFalsy(); + }); + + it('should load user profile photo', async () => { + worker.use( + rest.get('https://example.com/users/user-id/photo/*', (_, res, ctx) => + res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getUserPhoto('user-id'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load user profile photo for size 120', async () => { + worker.use( + rest.get( + 'https://example.com/users/user-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getUserPhoto('user-id', '120'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load users', async () => { + worker.use( + rest.get('https://example.com/users', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [{ surname: 'Example' }], + }), + ), + ), + ); + + const values = await collectAsyncIterable(client.getUsers()); + + expect(values).toEqual([{ surname: 'Example' }]); + }); + + it('should load group profile photo with max size of 120', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photos', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { + height: 120, + id: 120, + }, + ], + }), + ), + ), + ); + worker.use( + rest.get( + 'https://example.com/groups/group-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhotoWithSizeLimit('group-id', 120); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load group profile photo', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photo/*', (_, res, ctx) => + res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhoto('group-id'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load groups', async () => { + worker.use( + rest.get('https://example.com/groups', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [{ displayName: 'Example' }], + }), + ), + ), + ); + + const values = await collectAsyncIterable(client.getGroups()); + + expect(values).toEqual([{ displayName: 'Example' }]); + }); + + it('should load group members', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/members', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { '@odata.type': '#microsoft.graph.user' }, + { '@odata.type': '#microsoft.graph.group' }, + ], + }), + ), + ), + ); + + const values = await collectAsyncIterable( + client.getGroupMembers('group-id'), + ); + + expect(values).toEqual([ + { '@odata.type': '#microsoft.graph.user' }, + { '@odata.type': '#microsoft.graph.group' }, + ]); + }); + + it('should load organization', async () => { + worker.use( + rest.get('https://example.com/organization/tentant-id', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + displayName: 'Example', + }), + ), + ), + ); + + const organization = await client.getOrganization('tentant-id'); + + expect(organization).toEqual({ displayName: 'Example' }); + }); +}); + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts new file mode 100644 index 0000000000..bdb5918b2f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts @@ -0,0 +1,231 @@ +/* + * 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 * as msal from '@azure/msal-node'; +import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; +import fetch from 'cross-fetch'; +import qs from 'qs'; +import { MicrosoftGraphProviderConfig } from './config'; + +export type ODataQuery = { + filter?: string; + expand?: string[]; + select?: string[]; +}; + +export type GroupMember = + | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' }) + | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' }); + +export class MicrosoftGraphClient { + static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient { + const clientConfig: msal.Configuration = { + auth: { + clientId: config.clientId, + clientSecret: config.clientSecret, + authority: `${config.authority}/${config.tenantId}`, + }, + }; + const pca = new msal.ConfidentialClientApplication(clientConfig); + return new MicrosoftGraphClient(config.target, pca); + } + + constructor( + private readonly baseUrl: string, + private readonly pca: msal.ConfidentialClientApplication, + ) {} + + async *requestCollection( + path: string, + query?: ODataQuery, + ): AsyncIterable { + let response = await this.requestApi(path, query); + + for (;;) { + if (response.status !== 200) { + await this.handleError(path, response); + } + + const result = await response.json(); + const elements: T[] = result.value; + + yield* elements; + + // Follow cursor to the next page if one is available + if (!result['@odata.nextLink']) { + return; + } + + response = await this.requestRaw(result['@odata.nextLink']); + } + } + + async requestApi(path: string, query?: ODataQuery): Promise { + const queryString = qs.stringify( + { + $filter: query?.filter, + $select: query?.select?.join(','), + $expand: query?.expand?.join(','), + }, + { + addQueryPrefix: true, + // Microsoft Graph doesn't like an encoded query string + encode: false, + }, + ); + + return await this.requestRaw(`${this.baseUrl}/${path}${queryString}`); + } + + async requestRaw(url: string): Promise { + // Make sure that we always have a valid access token (might be cached) + const token = await this.pca.acquireTokenByClientCredential({ + scopes: ['https://graph.microsoft.com/.default'], + }); + + return await fetch(url, { + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + }); + } + + async getUserProfile(userId: string): Promise { + const response = await this.requestApi(`users/${userId}`); + + if (response.status !== 200) { + await this.handleError('user profile', response); + } + + return await response.json(); + } + + async getUserPhotoWithSizeLimit( + userId: string, + maxSize: number, + ): Promise { + return await this.getPhotoWithSizeLimit('users', userId, maxSize); + } + + async getUserPhoto( + userId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('users', userId, sizeId); + } + + async *getUsers(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`users`, query); + } + + async getGroupPhotoWithSizeLimit( + groupId: string, + maxSize: number, + ): Promise { + return await this.getPhotoWithSizeLimit('groups', groupId, maxSize); + } + + async getGroupPhoto( + groupId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('groups', groupId, sizeId); + } + + async *getGroups(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`groups`, query); + } + + async *getGroupMembers(groupId: string): AsyncIterable { + yield* this.requestCollection(`groups/${groupId}/members`); + } + + async getOrganization( + tenantId: string, + ): Promise { + const response = await this.requestApi(`organization/${tenantId}`); + + if (response.status !== 200) { + await this.handleError(`organization/${tenantId}`, response); + } + + return await response.json(); + } + + private async getPhotoWithSizeLimit( + entityName: string, + id: string, + maxSize: number, + ): Promise { + const response = await this.requestApi(`${entityName}/${id}/photos`); + + if (response.status === 404) { + return undefined; + } else if (response.status !== 200) { + await this.handleError(`${entityName} photos`, response); + } + + const result = await response.json(); + const photos = result.value as MicrosoftGraph.ProfilePhoto[]; + let selectedPhoto: MicrosoftGraph.ProfilePhoto | undefined = undefined; + + // Find the biggest picture that is smaller than the max size + for (const p of photos) { + if ( + !selectedPhoto || + (p.height! >= selectedPhoto.height! && p.height! <= maxSize) + ) { + selectedPhoto = p; + } + } + + if (!selectedPhoto) { + return undefined; + } + + return await this.getPhoto(entityName, id, selectedPhoto.id!); + } + + private async getPhoto( + entityName: string, + id: string, + sizeId?: string, + ): Promise { + const path = sizeId + ? `${entityName}/${id}/photos/${sizeId}/$value` + : `${entityName}/${id}/photo/$value`; + const response = await this.requestApi(path); + + if (response.status === 404) { + return undefined; + } else if (response.status !== 200) { + await this.handleError('photo', response); + } + + return `data:image/jpeg;base64,${Buffer.from( + await response.arrayBuffer(), + ).toString('base64')}`; + } + + private async handleError(path: string, response: Response): Promise { + const result = await response.json(); + const error = result.error as MicrosoftGraph.PublicError; + + throw new Error( + `Error while reading ${path} from Microsoft Graph: ${error.code} - ${error.message}`, + ); + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts new file mode 100644 index 0000000000..11c63828d4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { readMicrosoftGraphConfig } from './config'; + +describe('readMicrosoftGraphConfig', () => { + it('applies all of the defaults', () => { + const config = { + providers: [ + { + target: 'target', + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + ], + }; + const actual = readMicrosoftGraphConfig( + ConfigReader.fromConfigs([{ context: '', data: config }]), + ); + const expected = [ + { + target: 'target', + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + authority: 'https://login.microsoftonline.com', + userFilter: undefined, + groupFilter: undefined, + }, + ]; + expect(actual).toEqual(expected); + }); + + it('reads all the values', () => { + const config = { + providers: [ + { + target: 'target', + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + authority: 'https://login.example.com/', + userFilter: 'accountEnabled eq true', + groupFilter: 'securityEnabled eq false', + }, + ], + }; + const actual = readMicrosoftGraphConfig( + ConfigReader.fromConfigs([{ context: '', data: config }]), + ); + const expected = [ + { + target: 'target', + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + authority: 'https://login.example.com', + userFilter: 'accountEnabled eq true', + groupFilter: 'securityEnabled eq false', + }, + ]; + expect(actual).toEqual(expected); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts new file mode 100644 index 0000000000..c4d09e1372 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts @@ -0,0 +1,58 @@ +/* + * 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 { Config } from '@backstage/config'; + +export type MicrosoftGraphProviderConfig = { + target: string; + authority: string; + tenantId: string; + clientId: string; + clientSecret: string; + userFilter?: string; + groupFilter?: string; +}; + +export function readMicrosoftGraphConfig( + config: Config, +): MicrosoftGraphProviderConfig[] { + const providers: MicrosoftGraphProviderConfig[] = []; + const providerConfigs = config.getOptionalConfigArray('providers') ?? []; + + for (const providerConfig of providerConfigs) { + const target = providerConfig.getString('target').replace(/\/+$/, ''); + const authority = + providerConfig.getOptionalString('authority')?.replace(/\/+$/, '') || + 'https://login.microsoftonline.com'; + const tenantId = providerConfig.getString('tenantId'); + const clientId = providerConfig.getString('clientId'); + const clientSecret = providerConfig.getString('clientSecret'); + const userFilter = providerConfig.getOptionalString('userFilter'); + const groupFilter = providerConfig.getOptionalString('groupFilter'); + + providers.push({ + target, + authority, + tenantId, + clientId, + clientSecret, + userFilter, + groupFilter, + }); + } + + return providers; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/constants.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/constants.ts new file mode 100644 index 0000000000..6d34d0c159 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/constants.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +/** + * The tenant id used by the Microsoft Graph API + */ +export const MICROSOFT_GRAPH_TENANT_ID_ANNOTATION = + 'graph.microsoft.com/tenant-id'; + +/** + * The group id used by the Microsoft Graph API + */ +export const MICROSOFT_GRAPH_GROUP_ID_ANNOTATION = + 'graph.microsoft.com/group-id'; + +/** + * The user id used by the Microsoft Graph API + */ +export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id'; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/index.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/index.ts new file mode 100644 index 0000000000..882125fd84 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { MicrosoftGraphClient } from './client'; +export type { MicrosoftGraphProviderConfig } from './config'; +export { readMicrosoftGraphConfig } from './config'; +export { readMicrosoftGraphOrg } from './read'; +export { + MICROSOFT_GRAPH_GROUP_ID_ANNOTATION, + MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, + MICROSOFT_GRAPH_USER_ID_ANNOTATION, +} from './constants'; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts new file mode 100644 index 0000000000..07d540c848 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts @@ -0,0 +1,347 @@ +/* + * 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 { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import merge from 'lodash/merge'; +import { RecursivePartial } from '../../../util'; +import { GroupMember, MicrosoftGraphClient } from './client'; +import { + normalizeEntityName, + readMicrosoftGraphGroups, + readMicrosoftGraphOrganization, + readMicrosoftGraphUsers, + resolveRelations, +} from './read'; + +function user(data: RecursivePartial): UserEntity { + return merge( + {}, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'name' }, + spec: { profile: {}, memberOf: [] }, + } as UserEntity, + data, + ); +} + +function group(data: RecursivePartial): GroupEntity { + return merge( + {}, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'name', + }, + spec: { + children: [], + type: 'team', + }, + } as GroupEntity, + data, + ); +} + +describe('read microsoft graph', () => { + const client: jest.Mocked = { + getUsers: jest.fn(), + getGroups: jest.fn(), + getGroupMembers: jest.fn(), + getUserPhotoWithSizeLimit: jest.fn(), + getGroupPhotoWithSizeLimit: jest.fn(), + getOrganization: jest.fn(), + } as any; + + afterEach(() => jest.resetAllMocks()); + + describe('normalizeEntityName', () => { + it('should normalize name to valid entity name', () => { + expect(normalizeEntityName('User Name')).toBe('user_name'); + }); + + it('should normalize e-mail to valid entity name', () => { + expect(normalizeEntityName('user.name@example.com')).toBe( + 'user.name_example.com', + ); + }); + }); + + describe('readMicrosoftGraphUsers', () => { + it('should read users', async () => { + async function* getExampleUsers() { + yield { + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }; + } + + client.getUsers.mockImplementation(getExampleUsers); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + const { users } = await readMicrosoftGraphUsers(client, { + userFilter: 'accountEnabled eq true', + }); + + expect(users).toEqual([ + user({ + metadata: { + annotations: { + 'graph.microsoft.com/user-id': 'userid', + }, + name: 'user.name_example.com', + }, + spec: { + profile: { + displayName: 'User Name', + email: 'user.name@example.com', + picture: 'data:image/jpeg;base64,...', + }, + }, + }), + ]); + + expect(client.getUsers).toBeCalledTimes(1); + expect(client.getUsers).toBeCalledWith({ + filter: 'accountEnabled eq true', + select: ['id', 'displayName', 'mail'], + }); + expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1); + expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120); + }); + }); + + describe('readMicrosoftGraphOrganization', () => { + it('should read organization', async () => { + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + + const { rootGroup } = await readMicrosoftGraphOrganization( + client, + 'tenantid', + ); + + expect(rootGroup).toEqual( + group({ + metadata: { + annotations: { + 'graph.microsoft.com/tenant-id': 'tenantid', + }, + name: 'organization_name', + description: 'Organization Name', + }, + spec: { + type: 'root', + profile: { + displayName: 'Organization Name', + }, + }, + }), + ); + + expect(client.getOrganization).toBeCalledTimes(1); + expect(client.getOrganization).toBeCalledWith('tenantid'); + }); + }); + + describe('readMicrosoftGraphGroups', () => { + it('should read groups', async () => { + async function* getExampleGroups() { + yield { + id: 'groupid', + displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', + }; + } + + async function* getExampleGroupMembers(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.group', + id: 'childgroupid', + }; + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid', + }; + } + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + const { + groups, + groupMember, + groupMemberOf, + rootGroup, + } = await readMicrosoftGraphGroups(client, 'tenantid', { + groupFilter: 'securityEnabled eq false', + }); + + const expectedRootGroup = group({ + metadata: { + annotations: { + 'graph.microsoft.com/tenant-id': 'tenantid', + }, + name: 'organization_name', + description: 'Organization Name', + }, + spec: { + type: 'root', + profile: { + displayName: 'Organization Name', + }, + }, + }); + expect(groups).toEqual([ + expectedRootGroup, + group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'groupid', + }, + name: 'group_name', + description: 'Group Description', + }, + spec: { + type: 'team', + profile: { + displayName: 'Group Name', + email: 'group@example.com', + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, + }, + }), + ]); + expect(rootGroup).toEqual(expectedRootGroup); + expect(groupMember.get('groupid')).toEqual(new Set(['childgroupid'])); + expect(groupMemberOf.get('userid')).toEqual(new Set(['groupid'])); + expect(groupMember.get('organization_name')).toEqual(new Set()); + + expect(client.getGroups).toBeCalledTimes(1); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq false', + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], + }); + expect(client.getGroupMembers).toBeCalledTimes(1); + expect(client.getGroupMembers).toBeCalledWith('groupid'); + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1); + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120); + }); + }); + + describe('resolveRelations', () => { + it('should resolve relations', async () => { + const rootGroup = group({ + metadata: { + annotations: { + 'graph.microsoft.com/tenant-id': 'tenant-id-root', + }, + name: 'root', + }, + spec: { + type: 'root', + }, + }); + const groupA = group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'group-id-a', + }, + name: 'a', + }, + }); + const groupB = group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'group-id-b', + }, + name: 'b', + }, + }); + const groupC = group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'group-id-c', + }, + name: 'c', + }, + }); + const user1 = user({ + metadata: { + annotations: { + 'graph.microsoft.com/user-id': 'user-id-1', + }, + name: 'user1', + }, + }); + const user2 = user({ + metadata: { + annotations: { + 'graph.microsoft.com/user-id': 'user-id-2', + }, + name: 'user2', + }, + }); + const groups = [rootGroup, groupA, groupB, groupC]; + const users = [user1, user2]; + const groupMember = new Map>(); + groupMember.set('group-id-b', new Set(['group-id-c'])); + const groupMemberOf = new Map>(); + groupMemberOf.set('user-id-1', new Set(['group-id-a'])); + groupMemberOf.set('user-id-2', new Set(['group-id-c'])); + + // We have a root groups + // We have three groups: a, b, c. c is child of b + // we have two users: u1, u2. u1 is member of a, u2 is member of c + resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf); + + expect(rootGroup.spec.parent).toBeUndefined(); + expect(rootGroup.spec.children).toEqual( + expect.arrayContaining(['a', 'b']), + ); + + expect(groupA.spec.parent).toEqual('root'); + expect(groupA.spec.children).toEqual(expect.arrayContaining([])); + + expect(groupB.spec.parent).toEqual('root'); + expect(groupB.spec.children).toEqual(expect.arrayContaining(['c'])); + + expect(groupC.spec.parent).toEqual('b'); + expect(groupC.spec.children).toEqual(expect.arrayContaining([])); + + expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a'])); + expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c'])); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts new file mode 100644 index 0000000000..4409422645 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts @@ -0,0 +1,363 @@ +/* + * 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 { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import limiterFactory from 'p-limit'; +import { buildMemberOf, buildOrgHierarchy } from '../util/org'; +import { MicrosoftGraphClient } from './client'; +import { + MICROSOFT_GRAPH_GROUP_ID_ANNOTATION, + MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, + MICROSOFT_GRAPH_USER_ID_ANNOTATION, +} from './constants'; + +export function normalizeEntityName(name: string): string { + return name + .trim() + .toLocaleLowerCase() + .replace(/[^a-zA-Z0-9_\-\.]/g, '_'); +} + +export async function readMicrosoftGraphUsers( + client: MicrosoftGraphClient, + options?: { userFilter?: string }, +): Promise<{ + users: UserEntity[]; // With all relations empty +}> { + const entities: UserEntity[] = []; + const promises: Promise[] = []; + const limiter = limiterFactory(10); + + for await (const user of client.getUsers({ + filter: options?.userFilter, + select: ['id', 'displayName', 'mail'], + })) { + if (!user.id || !user.displayName || !user.mail) { + continue; + } + + const name = normalizeEntityName(user.mail); + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name, + annotations: { + [MICROSOFT_GRAPH_USER_ID_ANNOTATION]: user.id!, + }, + }, + spec: { + profile: { + displayName: user.displayName!, + email: user.mail!, + + // TODO: Additional fields? + // jobTitle: user.jobTitle || undefined, + // officeLocation: user.officeLocation || undefined, + // mobilePhone: user.mobilePhone || undefined, + }, + memberOf: [], + }, + }; + + // Download the photos in parallel, otherwise it can take quite some time + const loadPhoto = limiter(async () => { + entity.spec.profile!.picture = await client.getUserPhotoWithSizeLimit( + user.id!, + // We are limiting the photo size, as users with full resolution photos + // can make the Backstage API slow + 120, + ); + }); + + promises.push(loadPhoto); + entities.push(entity); + } + + // Wait for all photos to be downloaded + await Promise.all(promises); + + return { users: entities }; +} + +export async function readMicrosoftGraphOrganization( + client: MicrosoftGraphClient, + tenantId: string, +): Promise<{ + rootGroup: GroupEntity; // With all relations empty +}> { + // For now we expect a single root organization + const organization = await client.getOrganization(tenantId); + const name = normalizeEntityName(organization.displayName!); + const rootGroup: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: name, + description: organization.displayName!, + annotations: { + [MICROSOFT_GRAPH_TENANT_ID_ANNOTATION]: organization.id!, + }, + }, + spec: { + type: 'root', + profile: { + displayName: organization.displayName!, + }, + children: [], + }, + }; + + return { rootGroup }; +} + +export async function readMicrosoftGraphGroups( + client: MicrosoftGraphClient, + tenantId: string, + options?: { groupFilter?: string }, +): Promise<{ + groups: GroupEntity[]; // With all relations empty + rootGroup: GroupEntity | undefined; // With all relations empty + groupMember: Map>; + groupMemberOf: Map>; +}> { + const groups: GroupEntity[] = []; + const groupMember: Map> = new Map(); + const groupMemberOf: Map> = new Map(); + const limiter = limiterFactory(10); + + const { rootGroup } = await readMicrosoftGraphOrganization(client, tenantId); + groupMember.set(rootGroup.metadata.name, new Set()); + groups.push(rootGroup); + + const promises: Promise[] = []; + + for await (const group of client.getGroups({ + filter: options?.groupFilter, + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], + })) { + if (!group.id || !group.displayName) { + continue; + } + + const name = normalizeEntityName(group.mailNickname || group.displayName); + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: name, + annotations: { + [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, + }, + }, + spec: { + type: 'team', + profile: {}, + children: [], + }, + }; + + if (group.description) { + entity.metadata.description = group.description; + } + if (group.displayName) { + entity.spec.profile!.displayName = group.displayName; + } + if (group.mail) { + entity.spec.profile!.email = group.mail; + } + + // Download the members in parallel, otherwise it can take quite some time + const loadGroupMembers = limiter(async () => { + for await (const member of client.getGroupMembers(group.id!)) { + if (!member.id) { + continue; + } + + if (member['@odata.type'] === '#microsoft.graph.user') { + ensureItem(groupMemberOf, member.id, group.id!); + } + + if (member['@odata.type'] === '#microsoft.graph.group') { + ensureItem(groupMember, group.id!, member.id); + } + } + }); + + // TODO: Loading groups doesn't work right now as Microsoft Graph doesn't + // allows this yet: https://microsoftgraph.uservoice.com/forums/920506-microsoft-graph-feature-requests/suggestions/37884922-allow-application-to-set-or-update-a-group-s-photo + /*/ / Download the photos in parallel, otherwise it can take quite some time + const loadPhoto = limiter(async () => { + entity.spec.profile!.picture = await client.getGroupPhotoWithSizeLimit( + group.id!, + // We are limiting the photo size, as groups with full resolution photos + // can make the Backstage API slow + 120, + ); + }); + + promises.push(loadPhoto);*/ + promises.push(loadGroupMembers); + groups.push(entity); + } + + // Wait for all group members and photos to be loaded + await Promise.all(promises); + + return { + groups, + rootGroup, + groupMember, + groupMemberOf, + }; +} + +export function resolveRelations( + rootGroup: GroupEntity | undefined, + groups: GroupEntity[], + users: UserEntity[], + groupMember: Map>, + groupMemberOf: Map>, +) { + // Build reference lookup tables, we reference them by the id the the graph + const groupMap: Map = new Map(); // by group-id or tenant-id + + for (const group of groups) { + if (group.metadata.annotations![MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]) { + groupMap.set( + group.metadata.annotations![MICROSOFT_GRAPH_GROUP_ID_ANNOTATION], + group, + ); + } + if (group.metadata.annotations![MICROSOFT_GRAPH_TENANT_ID_ANNOTATION]) { + groupMap.set( + group.metadata.annotations![MICROSOFT_GRAPH_TENANT_ID_ANNOTATION], + group, + ); + } + } + + // Resolve all member relationships into the reverse direction + const parentGroups = new Map>(); + + groupMember.forEach((members, groupId) => + members.forEach(m => ensureItem(parentGroups, m, groupId)), + ); + + // Make sure every group (except root) has at least one parent. If the parent is missing, add the root. + if (rootGroup) { + const tenantId = rootGroup.metadata.annotations![ + MICROSOFT_GRAPH_TENANT_ID_ANNOTATION + ]; + + groups.forEach(group => { + const groupId = group.metadata.annotations![ + MICROSOFT_GRAPH_GROUP_ID_ANNOTATION + ]; + + if (!groupId) { + return; + } + + if (retrieveItems(parentGroups, groupId).size === 0) { + ensureItem(parentGroups, groupId, tenantId); + ensureItem(groupMember, tenantId, groupId); + } + }); + } + + groups.forEach(group => { + const id = + group.metadata.annotations![MICROSOFT_GRAPH_GROUP_ID_ANNOTATION] ?? + group.metadata.annotations![MICROSOFT_GRAPH_TENANT_ID_ANNOTATION]; + + retrieveItems(groupMember, id).forEach(m => { + const childGroup = groupMap.get(m); + if (childGroup) { + group.spec.children.push(childGroup.metadata.name); + } + }); + + retrieveItems(parentGroups, id).forEach(p => { + const parentGroup = groupMap.get(p); + if (parentGroup) { + // TODO: Only having a single parent group might not match every companies model, but fine for now. + group.spec.parent = parentGroup.metadata.name; + } + }); + }); + + // Make sure that all groups have proper parents and children + buildOrgHierarchy(groups); + + // Set relations for all users + users.forEach(user => { + const id = user.metadata.annotations![MICROSOFT_GRAPH_USER_ID_ANNOTATION]; + + retrieveItems(groupMemberOf, id).forEach(p => { + const parentGroup = groupMap.get(p); + if (parentGroup) { + user.spec.memberOf.push(parentGroup.metadata.name); + } + }); + }); + + // Make sure all transitive memberships are available + buildMemberOf(groups, users); +} + +export async function readMicrosoftGraphOrg( + client: MicrosoftGraphClient, + tenantId: string, + options?: { userFilter?: string; groupFilter?: string }, +): Promise<{ users: UserEntity[]; groups: GroupEntity[] }> { + const { users } = await readMicrosoftGraphUsers(client, { + userFilter: options?.userFilter, + }); + const { + groups, + rootGroup, + groupMember, + groupMemberOf, + } = await readMicrosoftGraphGroups(client, tenantId, { + groupFilter: options?.groupFilter, + }); + + resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf); + users.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + groups.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + + return { users, groups }; +} + +function ensureItem( + target: Map>, + key: string, + value: string, +) { + let set = target.get(key); + if (!set) { + set = new Set(); + target.set(key, set); + } + set!.add(value); +} + +function retrieveItems( + target: Map>, + key: string, +): Set { + return target.get(key) ?? new Set(); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts index e9dddc8226..c9056eebb4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { GroupEntity } from '@backstage/catalog-model'; -import { buildOrgHierarchy } from './org'; +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { buildMemberOf, buildOrgHierarchy } from './org'; function g( name: string, @@ -26,7 +26,7 @@ function g( apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name }, - spec: { type: 'team', parent, children, ancestors: [], descendants: [] }, + spec: { type: 'team', parent, children }, }; } @@ -43,27 +43,34 @@ describe('buildOrgHierarchy', () => { expect(d.spec.children).toEqual([]); }); - it('fills out descendants', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); + it('sets parent of groups children', () => { + const a = g('a', undefined, ['b', 'd']); + const b = g('b', undefined, ['c']); + const c = g('c', undefined, []); + const d = g('d', undefined, []); buildOrgHierarchy([a, b, c, d]); - expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd'])); - expect(b.spec.descendants).toEqual(expect.arrayContaining(['c'])); - expect(c.spec.descendants).toEqual([]); - expect(d.spec.descendants).toEqual([]); - }); - - it('fills out ancestors', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); - buildOrgHierarchy([a, b, c, d]); - expect(a.spec.ancestors).toEqual([]); - expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a'])); - expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b'])); - expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a'])); + expect(a.spec.parent).toBeUndefined(); + expect(b.spec.parent).toBe('a'); + expect(c.spec.parent).toBe('b'); + expect(d.spec.parent).toBe('a'); + }); +}); + +describe('buildMemberOf', () => { + it('fills indirect member of groups', () => { + const a = g('a', undefined, []); + const b = g('b', 'a', []); + const c = g('c', 'b', []); + const u: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'n' }, + spec: { profile: {}, memberOf: ['c'] }, + }; + + const groups = [a, b, c]; + buildOrgHierarchy(groups); + buildMemberOf(groups, [u]); + expect(u.spec.memberOf).toEqual(expect.arrayContaining(['a', 'b', 'c'])); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.ts index a280a265a8..787e408e60 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { GroupEntity } from '@backstage/catalog-model'; +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; export function buildOrgHierarchy(groups: GroupEntity[]) { const groupsByName = new Map(groups.map(g => [g.metadata.name, g])); @@ -35,61 +35,44 @@ export function buildOrgHierarchy(groups: GroupEntity[]) { } // - // Make sure that g.descendants is complete + // Make sure that g.children.parent is g // - function visitDescendants(current: GroupEntity): string[] { - if (current.spec.descendants.length) { - return current.spec.descendants; - } - - const accumulator = new Set(); - for (const childName of current.spec.children) { - accumulator.add(childName); + for (const group of groups) { + const selfName = group.metadata.name; + for (const childName of group.spec.children) { const child = groupsByName.get(childName); - if (child) { - for (const d of visitDescendants(child)) { - accumulator.add(d); + if (child && !child.spec.parent) { + child.spec.parent = selfName; + } + } + } +} + +// Ensure that users have their transitive group memberships. Requires that +// the groups were previously processed with buildOrgHierarchy() +export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) { + const groupsByName = new Map(groups.map(g => [g.metadata.name, g])); + + users.forEach(user => { + const transitiveMemberOf = new Set(); + + const todo = [...user.spec.memberOf]; + for (;;) { + const current = todo.pop(); + if (!current) { + break; + } + + if (!transitiveMemberOf.has(current)) { + transitiveMemberOf.add(current); + const group = groupsByName.get(current); + if (group?.spec.parent) { + todo.push(group.spec.parent); } } } - const descendants = Array.from(accumulator); - current.spec.descendants = descendants; - return descendants; - } - - for (const group of groups) { - visitDescendants(group); - } - - // - // Make sure that g.ancestors is complete - // - - function visitAncestors(current: GroupEntity): string[] { - if (current.spec.ancestors.length) { - return current.spec.ancestors; - } - - let ancestors: string[]; - const parentName = current.spec.parent; - if (!parentName) { - ancestors = []; - } else { - const parent = groupsByName.get(parentName); - if (parent) { - ancestors = [parentName, ...visitAncestors(parent)]; - } else { - ancestors = [parentName]; - } - } - - current.spec.ancestors = ancestors; - return ancestors; - } - - for (const group of groups) { - visitAncestors(group); - } + user.spec.memberOf = [...transitiveMemberOf]; + }); } diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts index 8cb91fb2ce..53201486bc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts @@ -115,6 +115,41 @@ describe('parseEntityYaml', () => { ]); }); + it('should handle empty yaml documents', () => { + // This happens if the user accidentially adds a "---" + // at the end of a file + const results = Array.from( + parseEntityYaml( + Buffer.from( + ` + apiVersion: backstage.io/v1alpha1 + kind: Component + metadata: + name: web + spec: + type: website +--- + `, + 'utf8', + ), + testLoc, + ), + ); + + expect(results).toEqual([ + result.entity(testLoc, { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'web', + }, + spec: { + type: 'website', + }, + }), + ]); + }); + it('should emit parsing errors', () => { const results = Array.from( parseEntityYaml(Buffer.from('`', 'utf8'), testLoc), diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts index 3f07a815b0..c3dcd42d62 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts @@ -40,6 +40,9 @@ export function* parseEntityYaml( const json = document.toJSON(); if (lodash.isPlainObject(json)) { yield result.entity(location, json as Entity); + } else if (json === null) { + // Ignore null values, these happen if there is an empty document in the + // YAML file, for example if --- is added to the end of the file. } else { const message = `Expected object at root, got ${typeof json}`; yield result.generalError(location, message); diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index a83b4adecc..c8d642b6b5 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import type { +import { Entity, EntityRelationSpec, Location, LocationSpec, } from '@backstage/catalog-model'; +import { RecursivePartial } from '../util/RecursivePartial'; // // HigherOrderOperation @@ -68,3 +69,75 @@ export type ReadLocationError = { location: LocationSpec; error: Error; }; + +// +// LocationAnalyzer +// + +export type LocationAnalyzer = { + /** + * Generates an entity configuration for given git repository. It's used for + * importing new component to the backstage app. + * + * @param location Git repository to analyze and generate config for. + */ + analyzeLocation( + location: AnalyzeLocationRequest, + ): Promise; +}; + +export type AnalyzeLocationRequest = { + location: LocationSpec; +}; + +export type AnalyzeLocationResponse = { + existingEntityFiles: AnalyzeLocationExistingEntity[]; + generateEntities: AnalyzeLocationGenerateEntity[]; +}; + +// If the folder pointed to already contained catalog info yaml files, they are +// read and emitted like this so that the frontend can inform the user that it +// located them and can make sure to register them as well if they weren't +// already +type AnalyzeLocationExistingEntity = { + location: LocationSpec; + isRegistered: boolean; + entity: Entity; +}; + +// This is some form of representation of what the analyzer could deduce. +// We should probably have a chat about how this can best be conveyed to +// the frontend. It'll probably contain a (possibly incomplete) entity, plus +// enough info for the frontend to know what form data to show to the user +// for overriding/completing the info. +type AnalyzeLocationGenerateEntity = { + // Some form of partial representation of the entity + entity: RecursivePartial; + // Lists the suggestions that the user may want to override + fields: AnalyzeLocationEntityField[]; +}; + +// This is where I get really vague. Something like this perhaps? Or it could be +// something like a json-schema that contains enough info for the frontend to +// be able to present a form and explanations +type AnalyzeLocationEntityField = { + // e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the + // entity again if the user wants to change it + field: string; + + // The outcome of the analysis for this particular field + state: + | 'analysisSuggestedValue' + | 'analysisSuggestedNoValue' + | 'needsUserInput'; + + // If the analysis did suggest a value, this is where it would be. Not sure if we want + // to limit this to strings or if we want it to be any JsonValue + value: string | null; + + // A text to show to the user to inform about the choices made. Like, it could say + // "Found a CODEOWNERS file that covers this target, so we suggest leaving this + // field empty; which would currently make it owned by X" where X is taken from the + // codeowners file. + description: string; +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index 45d92e6555..58897a10a7 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -40,7 +40,10 @@ const dummyEntityYaml = yaml.stringify(dummyEntity); describe('CatalogBuilder', () => { let db: Knex; - const reader: jest.Mocked = { read: jest.fn() }; + const reader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + }; const env: CatalogEnvironment = { logger: getVoidLogger(), database: { getClient: async () => db }, @@ -141,7 +144,7 @@ describe('CatalogBuilder', () => { owner: 'o', lifecycle: 'l', }, - relations: [], + relations: expect.anything(), }, ]); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index b041dac5ab..f6170135fd 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -37,28 +37,30 @@ import { import { DatabaseManager } from '../database'; import { AnnotateLocationEntityProcessor, + BuiltinKindsEntityProcessor, CatalogProcessor, CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, HigherOrderOperation, HigherOrderOperations, + LdapOrgReaderProcessor, LocationReaders, LocationRefProcessor, - OwnerRelationProcessor, + MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, StaticLocationProcessor, UrlReaderProcessor, } from '../ingestion'; import { CatalogRulesEnforcer } from '../ingestion/CatalogRules'; -import { BuiltinKindsEntityProcessor } from '../ingestion/processors/BuiltinKindsEntityProcessor'; -import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor'; +import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; import { jsonPlaceholderResolver, textPlaceholderResolver, yamlPlaceholderResolver, } from '../ingestion/processors/PlaceholderProcessor'; +import { LocationAnalyzer } from '../ingestion/types'; export type CatalogEnvironment = { logger: Logger; @@ -202,6 +204,7 @@ export class CatalogBuilder { entitiesCatalog: EntitiesCatalog; locationsCatalog: LocationsCatalog; higherOrderOperation: HigherOrderOperation; + locationAnalyzer: LocationAnalyzer; }> { const { config, database, logger } = this.env; @@ -229,11 +232,13 @@ export class CatalogBuilder { locationReader, logger, ); + const locationAnalyzer = new RepoLocationAnalyzer(logger); return { entitiesCatalog, locationsCatalog, higherOrderOperation, + locationAnalyzer, }; } @@ -278,10 +283,10 @@ export class CatalogBuilder { new FileReaderProcessor(), GithubOrgReaderProcessor.fromConfig(config, { logger }), LdapOrgReaderProcessor.fromConfig(config, { logger }), + MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), - new CodeOwnersProcessor({ reader }), + new CodeOwnersProcessor({ reader, logger }), new LocationRefProcessor(), - new OwnerRelationProcessor(), new AnnotateLocationEntityProcessor(), ); } diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index ebad544bef..43c2967233 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -15,29 +15,38 @@ */ import { errorHandler } from '@backstage/backend-common'; +import { + locationSpecSchema, + analyzeLocationSchema, +} from '@backstage/catalog-model'; import type { Entity } from '@backstage/catalog-model'; -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 { EntityFilters } from './EntityFilters'; +import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types'; import { translateQueryToFieldMapper } from './filterQuery'; +import { EntityFilters } from './EntityFilters'; import { requireRequestBody, validateRequestBody } from './util'; export interface RouterOptions { entitiesCatalog?: EntitiesCatalog; locationsCatalog?: LocationsCatalog; higherOrderOperation?: HigherOrderOperation; + locationAnalyzer?: LocationAnalyzer; logger: Logger; } export async function createRouter( options: RouterOptions, ): Promise { - const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options; + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, + } = options; const router = Router(); router.use(express.json()); @@ -127,6 +136,14 @@ export async function createRouter( }); } + if (locationAnalyzer) { + router.post('/analyze-location', async (req, res) => { + const input = await validateRequestBody(req, analyzeLocationSchema); + const output = await locationAnalyzer.analyzeLocation(input); + res.status(200).send(output); + }); + } + router.use(errorHandler()); return router; } diff --git a/plugins/catalog-backend/src/util/runPeriodically.ts b/plugins/catalog-backend/src/util/runPeriodically.ts index 2f65d0e6f3..c6d478fcbe 100644 --- a/plugins/catalog-backend/src/util/runPeriodically.ts +++ b/plugins/catalog-backend/src/util/runPeriodically.ts @@ -27,7 +27,7 @@ export function runPeriodically(fn: () => any, delayMs: number): () => void { let cancel: () => void; let cancelled = false; - const cancellationPromise = new Promise(resolve => { + const cancellationPromise = new Promise(resolve => { cancel = () => { resolve(); cancelled = true; diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index 30414d6998..7cef0b765a 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,50 @@ # @backstage/plugin-catalog-graphql +## 0.2.4 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.2.0 ### Minor Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index aaa5faac36..fa6bd7c4d4 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.2.0", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-graphql" + }, + "keywords": [ + "backstage", + "graphql" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.4.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", "cross-fetch": "^3.0.6", @@ -32,8 +42,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-import/.eslintrc.js b/plugins/catalog-import/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-import/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md new file mode 100644 index 0000000000..bc86a6ac0c --- /dev/null +++ b/plugins/catalog-import/CHANGELOG.md @@ -0,0 +1,62 @@ +# @backstage/plugin-catalog-import + +## 0.3.1 + +### Patch Changes + +- 79418ddb6: Align plugin ID and fix variable typo +- d2938af4c: Add register existing component instructions +- Updated dependencies [6e8bb3ac0] +- Updated dependencies [2527628e1] +- Updated dependencies [e708679d7] +- Updated dependencies [047c018c9] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [38d63fbe1] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/plugin-catalog-backend@0.4.0 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.0 + +### Minor Changes + +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [e42402b47] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/plugin-catalog-backend@0.3.0 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md new file mode 100644 index 0000000000..1dc0d7cf89 --- /dev/null +++ b/plugins/catalog-import/README.md @@ -0,0 +1,21 @@ +# Catalog import plugin + +Welcome to the catalog-import plugin! + +This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it. + +When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import). + + + +## Running + +Just run the backstage. + +``` +yarn start && yarn --cwd packages/backend start +``` + +## Usage + +Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL. diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/catalog-import/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/catalog-import/package.json b/plugins/catalog-import/package.json new file mode 100644 index 0000000000..6eb744cea4 --- /dev/null +++ b/plugins/catalog-import/package.json @@ -0,0 +1,67 @@ +{ + "name": "@backstage/plugin-catalog-import", + "version": "0.3.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-import" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/plugin-catalog-backend": "^0.4.0", + "@backstage/integration": "^0.1.3", + "@backstage/theme": "^0.2.2", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@octokit/rest": "^18.0.6", + "git-url-parse": "^11.4.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-hook-form": "^6.6.0", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "yaml": "^1.10.0" + }, + "devDependencies": { + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts new file mode 100644 index 0000000000..99526a3eaa --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { PartialEntity } from '../util/types'; +import { GitHubIntegrationConfig } from '@backstage/integration'; + +export const catalogImportApiRef = createApiRef({ + id: 'plugin.catalog-import.service', + description: 'Used by the catalog import plugin to make requests', +}); + +export interface CatalogImportApi { + submitPrToRepo(options: { + owner: string; + repo: string; + fileContent: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ link: string; location: string }>; + createRepositoryLocation(options: { location: string }): Promise; + generateEntityDefinitions(options: { + repo: string; + }): Promise; +} diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts new file mode 100644 index 0000000000..4ac015ffbf --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -0,0 +1,196 @@ +/* + * 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 { Octokit } from '@octokit/rest'; +import { DiscoveryApi, OAuthApi } from '@backstage/core'; +import { CatalogImportApi } from './CatalogImportApi'; +import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend'; +import { PartialEntity } from '../util/types'; +import { GitHubIntegrationConfig } from '@backstage/integration'; + +export class CatalogImportClient implements CatalogImportApi { + private readonly discoveryApi: DiscoveryApi; + private readonly githubAuthApi: OAuthApi; + + constructor(options: { + discoveryApi: DiscoveryApi; + githubAuthApi: OAuthApi; + }) { + this.discoveryApi = options.discoveryApi; + this.githubAuthApi = options.githubAuthApi; + } + + async generateEntityDefinitions({ + repo, + }: { + repo: string; + }): Promise { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`, + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + location: { type: 'github', target: repo }, + }), + }, + ).catch(e => { + throw new Error(`Failed to generate entity definitions, ${e.message}`); + }); + if (!response.ok) { + throw new Error( + `Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`, + ); + } + + const payload = (await response.json()) as AnalyzeLocationResponse; + return payload.generateEntities.map(x => x.entity); + } + + async createRepositoryLocation({ + location, + }: { + location: string; + }): Promise { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + type: 'github', + target: location, + presence: 'optional', + }), + }, + ); + if (!response.ok) { + throw new Error( + `Received http response ${response.status}: ${response.statusText}`, + ); + } + } + + async submitPrToRepo({ + owner, + repo, + fileContent, + githubIntegrationConfig, + }: { + owner: string; + repo: string; + fileContent: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ link: string; location: string }> { + const token = await this.githubAuthApi.getAccessToken(['repo']); + + const octo = new Octokit({ + auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, + }); + + const branchName = 'backstage-integration'; + const fileName = 'catalog-info.yaml'; + + const repoData = await octo.repos + .get({ + owner, + repo, + }) + .catch(e => { + throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e)); + }); + + const parentRef = await octo.git + .getRef({ + owner, + repo, + ref: `heads/${repoData.data.default_branch}`, + }) + .catch(e => { + throw new Error( + formatHttpErrorMessage("Couldn't fetch default branch data", e), + ); + }); + + await octo.git + .createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: parentRef.data.object.sha, + }) + .catch(e => { + throw new Error( + formatHttpErrorMessage( + `Couldn't create a new branch with name '${branchName}'`, + e, + ), + ); + }); + + await octo.repos + .createOrUpdateFileContents({ + owner, + repo, + path: fileName, + message: `Add ${fileName} config file`, + content: btoa(fileContent), + branch: branchName, + }) + .catch(e => { + throw new Error( + formatHttpErrorMessage( + `Couldn't create a commit with ${fileName} file added`, + e, + ), + ); + }); + + const pullRequestResponse = await octo.pulls + .create({ + owner, + repo, + title: `Add ${fileName} config file`, + head: branchName, + base: repoData.data.default_branch, + }) + .catch(e => { + throw new Error( + formatHttpErrorMessage( + `Couldn't create a pull request for ${branchName} branch`, + e, + ), + ); + }); + + return { + link: pullRequestResponse.data.html_url, + location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, + }; + } +} + +function formatHttpErrorMessage( + message: string, + error: { status: number; message: string }, +) { + return `${message}, received http response status code ${error.status}: ${error.message}`; +} diff --git a/plugins/catalog-import/src/api/index.ts b/plugins/catalog-import/src/api/index.ts new file mode 100644 index 0000000000..078342d284 --- /dev/null +++ b/plugins/catalog-import/src/api/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 './CatalogImportApi'; +export * from './CatalogImportClient'; diff --git a/plugins/catalog-import/src/assets/catalog-import-screenshot.png b/plugins/catalog-import/src/assets/catalog-import-screenshot.png new file mode 100644 index 0000000000..7ba8ae9eb5 Binary files /dev/null and b/plugins/catalog-import/src/assets/catalog-import-screenshot.png differ diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx new file mode 100644 index 0000000000..ab5c71c7d3 --- /dev/null +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -0,0 +1,197 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useState } from 'react'; +import { + Button, + CircularProgress, + Grid, + Link, + List, + ListItem, + Typography, + Divider, +} from '@material-ui/core'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ConfigSpec } from './ImportComponentPage'; +import { + errorApiRef, + RouteRef, + StructuredMetadataTable, + useApi, +} from '@backstage/core'; +import parseGitUri from 'git-url-parse'; +import { PartialEntity } from '../util/types'; +import { generatePath, resolvePath } from 'react-router'; +import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { Link as RouterLink } from 'react-router-dom'; +import * as YAML from 'yaml'; + +const getEntityCatalogPath = ({ + entity, + catalogRouteRef, +}: { + entity: PartialEntity; + catalogRouteRef: RouteRef; +}) => { + const relativeEntityPathInsideCatalog = generatePath( + entityRoute.path, + entityRouteParams(entity as Entity), + ); + + const resolvedAbsolutePath = resolvePath( + relativeEntityPathInsideCatalog, + catalogRouteRef.path, + )?.pathname; + + return resolvedAbsolutePath; +}; + +type Props = { + nextStep: (options?: { reset: boolean }) => void; + configFile: ConfigSpec; + savePRLink: (PRLink: string) => void; + catalogRouteRef: RouteRef; +}; + +const ComponentConfigDisplay = ({ + nextStep, + configFile, + savePRLink, + catalogRouteRef, +}: Props) => { + const [errorOccurred, setErrorOccurred] = useState(false); + const [submitting, setSubmitting] = useState(false); + const errorApi = useApi(errorApiRef); + const { submitPrToRepo, addLocation } = useGithubRepos(); + const onNext = useCallback(async () => { + try { + setSubmitting(true); + if (!parseGitUri(configFile.location).filepathtype) { + const result = await submitPrToRepo(configFile); + savePRLink(result.link); + setSubmitting(false); + nextStep(); + } else { + addLocation(configFile.location); + setSubmitting(false); + nextStep(); + } + } catch (e) { + setErrorOccurred(true); + setSubmitting(false); + errorApi.post(e); + } + }, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]); + + return ( + + {!parseGitUri(configFile.location).filepathtype ? ( + + Following config object will be submitted in a pull request to the + repository{' '} + + {configFile.location} + {' '} + and added as a new location to the backend + + ) : ( + + Following config object will be added as a new location to the backend{' '} + + {configFile.location} + + + )} + + + {!parseGitUri(configFile.location).filepathtype ? ( +
{YAML.stringify(configFile.config)}
+ ) : ( + + {configFile.config.map((entity: any, index: number) => { + const entityPath = getEntityCatalogPath({ + entity, + catalogRouteRef, + }); + return ( + + + + {entityPath} + + ), + }} + /> + + {index < configFile.config.length - 1 && ( + + )} + + ); + })} + + )} +
+ + {submitting ? ( + + + + ) : null} + + + {errorOccurred ? ( + + ) : null} + + +
+ ); +}; + +export default ComponentConfigDisplay; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx new file mode 100644 index 0000000000..dab5ccbea2 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -0,0 +1,131 @@ +/* + * 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 { errorApiRef, useApi } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; +import { + Button, + FormControl, + FormHelperText, + TextField, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { useMountedState } from 'react-use'; +import parseGitUri from 'git-url-parse'; +import { ComponentIdValidators } from '../util/validate'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ConfigSpec } from './ImportComponentPage'; +import { catalogApiRef } from '@backstage/plugin-catalog'; + +const useStyles = makeStyles(theme => ({ + form: { + alignItems: 'flex-start', + display: 'flex', + flexFlow: 'column nowrap', + }, + submit: { + marginTop: theme.spacing(1), + }, +})); + +type Props = { + nextStep: () => void; + saveConfig: (configFile: ConfigSpec) => void; +}; + +export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { + const { register, handleSubmit, errors, formState } = useForm({ + mode: 'onChange', + }); + const classes = useStyles(); + const hasErrors = !!errors.componentLocation; + const dirty = formState?.isDirty; + const catalogApi = useApi(catalogApiRef); + + const isMounted = useMountedState(); + const errorApi = useApi(errorApiRef); + const { generateEntityDefinitions } = useGithubRepos(); + + const onSubmit = async (formData: Record) => { + const { componentLocation: target } = formData; + try { + if (!isMounted()) return; + const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; + + if (type === 'repo') { + saveConfig({ + type, + location: target, + config: await generateEntityDefinitions(target), + }); + } else { + const data = await catalogApi.addLocation({ target }); + saveConfig({ + type, + location: data.location.target, + config: data.entities, + }); + } + nextStep(); + } catch (e) { + errorApi.post(e); + } + }; + + return ( +
+ + + + {errors.componentLocation && ( + + {errors.componentLocation.message} + + )} + + + +
+ ); +}; diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx new file mode 100644 index 0000000000..9fdb465dd1 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import { + InfoCard, + Page, + Content, + Header, + SupportButton, + ContentHeader, + RouteRef, +} from '@backstage/core'; +import { RegisterComponentForm } from './ImportComponentForm'; +import ImportStepper from './ImportStepper'; +import ComponentConfigDisplay from './ComponentConfigDisplay'; +import { ImportFinished } from './ImportFinished'; +import { PartialEntity } from '../util/types'; + +export type ConfigSpec = { + type: 'repo' | 'file'; + location: string; + config: PartialEntity[]; +}; + +export const ImportComponentPage = ({ + catalogRouteRef, +}: { + catalogRouteRef: RouteRef; +}) => { + const [activeStep, setActiveStep] = useState(0); + const [configFile, setConfigFile] = useState({ + type: 'repo', + location: '', + config: [], + }); + const [endLink, setEndLink] = useState(''); + const nextStep = (options?: { reset: boolean }) => { + setActiveStep(step => (options?.reset ? 0 : step + 1)); + }; + + return ( + +
+ + + + Start tracking your component in Backstage by adding it to the + software catalog. + + + + + + + There are two ways to register an existing component. + + GitHub Repo + + If you already have code in a GitHub repository, enter the full + URL to your repo and a new pull request with a sample Backstage + metadata Entity File (catalog-info.yaml) will be + opened for you. + + + GitHub Repo & Entity File + + + If you've already created a Backstage metadata file and put it + in your repo, you can enter the full URL to that Entity File. + + + + + + + ), + }, + { + step: 'Review', + content: ( + + ), + }, + { + step: 'Finish', + content: ( + + ), + }, + ]} + activeStep={activeStep} + nextStep={nextStep} + /> + + + + + + ); +}; diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx new file mode 100644 index 0000000000..caba678999 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportFinished.tsx @@ -0,0 +1,58 @@ +/* + * 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 } from '@material-ui/lab'; +import { Button, Grid, Link } from '@material-ui/core'; + +type Props = { + type: 'repo' | 'file'; + nextStep: (options?: { reset: boolean }) => void; + PRLink: string; +}; + +export const ImportFinished = ({ nextStep, PRLink, type }: Props) => { + return ( + + + + {type === 'repo' + ? 'Pull requests have been successfully opened. You can start again to import more repositories' + : 'Entity added to catalog successfully'} + + + + {type === 'repo' ? ( + + View pull request on GitHub + + ) : null} + + + + ); +}; diff --git a/plugins/catalog-import/src/components/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper.tsx new file mode 100644 index 0000000000..03752fa7d4 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import Stepper from '@material-ui/core/Stepper'; +import Step from '@material-ui/core/Step'; +import StepLabel from '@material-ui/core/StepLabel'; +import { StepContent } from '@material-ui/core'; + +type Props = { + steps: { step: string; content: React.ReactNode }[]; + activeStep: number; + nextStep: (options?: { reset: boolean }) => void; +}; + +export default function ImportStepper({ steps, activeStep }: Props) { + return ( + + {steps.map(({ step }) => { + const stepProps: { completed?: boolean } = {}; + return ( + + {step} + {steps[activeStep].content} + + ); + })} + + ); +} diff --git a/plugins/catalog-import/src/components/Router.tsx b/plugins/catalog-import/src/components/Router.tsx new file mode 100644 index 0000000000..5175f8f077 --- /dev/null +++ b/plugins/catalog-import/src/components/Router.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouteRef } from '@backstage/core'; +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { ImportComponentPage } from './ImportComponentPage'; + +export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => ( + + } + /> + +); diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts new file mode 100644 index 0000000000..ff7857cacd --- /dev/null +++ b/plugins/catalog-import/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 { plugin } from './plugin'; +export { Router } from './components/Router'; diff --git a/plugins/catalog-import/src/plugin.test.ts b/plugins/catalog-import/src/plugin.test.ts new file mode 100644 index 0000000000..2f5964946c --- /dev/null +++ b/plugins/catalog-import/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('catalog-import', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts new file mode 100644 index 0000000000..3ce4918553 --- /dev/null +++ b/plugins/catalog-import/src/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + githubAuthApiRef, +} from '@backstage/core'; +import { catalogImportApiRef } from './api/CatalogImportApi'; +import { CatalogImportClient } from './api/CatalogImportClient'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'catalog-import', +}); + +export const plugin = createPlugin({ + id: 'catalog-import', + apis: [ + createApiFactory({ + api: catalogImportApiRef, + deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef }, + factory: ({ discoveryApi, githubAuthApi }) => + new CatalogImportClient({ discoveryApi, githubAuthApi }), + }), + ], +}); diff --git a/plugins/catalog-import/src/setupTests.ts b/plugins/catalog-import/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/catalog-import/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'; diff --git a/plugins/catalog-import/src/util/types.ts b/plugins/catalog-import/src/util/types.ts new file mode 100644 index 0000000000..f23649a2a9 --- /dev/null +++ b/plugins/catalog-import/src/util/types.ts @@ -0,0 +1,27 @@ +/* + * 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 type RecursivePartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object + ? RecursivePartial + : T[P]; +}; + +export type PartialEntity = RecursivePartial; diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts new file mode 100644 index 0000000000..30e77e18a8 --- /dev/null +++ b/plugins/catalog-import/src/util/useGithubRepos.ts @@ -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 * as YAML from 'yaml'; +import { useApi, configApiRef } from '@backstage/core'; +import { catalogImportApiRef } from '../api/CatalogImportApi'; +import { ConfigSpec } from '../components/ImportComponentPage'; +import parseGitUri from 'git-url-parse'; + +// TODO: (O5ten) Refactor into a core API instead of direct usage like this +// https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430 +import { readGitHubIntegrationConfigs } from '@backstage/integration'; + +export function useGithubRepos() { + const api = useApi(catalogImportApiRef); + const config = useApi(configApiRef); + + const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const { + name: repoName, + owner: ownerName, + resource: hostname, + } = parseGitUri(selectedRepo.location); + + const configs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + const githubIntegrationConfig = configs.find(v => v.host === hostname); + if (!githubIntegrationConfig) { + throw new Error( + `Unable to locate github-integration for repo-location: ${selectedRepo.location}`, + ); + } + const submitPRResponse = await api + .submitPrToRepo({ + owner: ownerName, + repo: repoName, + fileContent: selectedRepo.config + .map(entity => `---\n${YAML.stringify(entity)}`) + .join('\n'), + githubIntegrationConfig, + }) + .catch(e => { + throw new Error(`Failed to submit PR to repo:\n${e.message}`); + }); + + await api + .createRepositoryLocation({ + location: submitPRResponse.location, + }) + .catch(e => { + throw new Error(`Failed to create repository location:\n${e.message}`); + }); + + return submitPRResponse; + }; + + return { + submitPrToRepo, + generateEntityDefinitions: (repo: string) => + api.generateEntityDefinitions({ repo }), + addLocation: (location: string) => + api.createRepositoryLocation({ location }), + }; +} diff --git a/plugins/catalog-import/src/util/validate.test.ts b/plugins/catalog-import/src/util/validate.test.ts new file mode 100644 index 0000000000..139c7fb0f8 --- /dev/null +++ b/plugins/catalog-import/src/util/validate.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { ComponentIdValidators } from './validate'; + +describe('ComponentIdValidators', () => { + describe('httpsValidator validator', () => { + const errorMessage = 'Must start with https://.'; + test.each([ + [true, 'https://example.com'], + [errorMessage, 'http://example.com'], + [errorMessage, 'example.com'], + [errorMessage, 'www.example.com'], + [errorMessage, ''], + [errorMessage, undefined], + ])('should return %p for %s', (expected: string | boolean, arg: any) => { + expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected); + }); + }); +}); diff --git a/plugins/techdocs/src/types.ts b/plugins/catalog-import/src/util/validate.ts similarity index 76% rename from plugins/techdocs/src/types.ts rename to plugins/catalog-import/src/util/validate.ts index ec907cc6fa..056131aaae 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/catalog-import/src/util/validate.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -export type ParsedEntityId = { - kind: string; - namespace?: string; - name: string; +export const ComponentIdValidators = { + httpsValidator: (value: any) => + (typeof value === 'string' && value.match(/^https:\/\//) !== null) || + 'Must start with https://.', }; diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 7fe67ffba4..e47808bfa0 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,94 @@ # @backstage/plugin-catalog +## 0.2.6 + +### Patch Changes + +- 6011b7d3e: Added pagerduty plugin to example app +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [87a33d2fe] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/plugin-techdocs@0.4.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-scaffolder@0.3.3 + - @backstage/catalog-client@0.3.3 + +## 0.2.5 + +### Patch Changes + +- ebf37bbae: Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity. +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [da2ad65cb] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-scaffolder@0.3.2 + - @backstage/plugin-techdocs@0.3.1 + - @backstage/catalog-client@0.3.2 + +## 0.2.4 + +### Patch Changes + +- 6f70ed7a9: Replace usage of implementsApis with relations +- Updated dependencies [4b53294a6] +- Updated dependencies [ab94c9542] +- Updated dependencies [2daf18e80] +- Updated dependencies [069cda35f] + - @backstage/plugin-techdocs@0.3.0 + - @backstage/catalog-model@0.3.1 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [ef2831dde] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-scaffolder@0.3.1 + - @backstage/catalog-client@0.3.1 + - @backstage/plugin-techdocs@0.2.3 + +## 0.2.2 + +### Patch Changes + +- 8b7737d0b: Add About Card tooltips +- Updated dependencies [1722cb53c] +- Updated dependencies [717e43de1] + - @backstage/core@0.3.1 + - @backstage/plugin-techdocs@0.2.2 + - @backstage/catalog-client@0.3.0 + +## 0.2.1 + +### Patch Changes + +- 2d0bd1be7: Improved the edit link to open the component yaml in edit mode in corresponding SCM. Broke out logic for createEditLink to be reused. +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [59166e5ec] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-scaffolder@0.3.0 + - @backstage/plugin-techdocs@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 045230d3f9..ad9cff8ed8 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.0", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.2.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-scaffolder": "^0.2.0", - "@backstage/plugin-techdocs": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-client": "^0.3.3", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.3.3", + "@backstage/plugin-techdocs": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,9 +52,10 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", + "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 8da67d7493..49653fe981 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -18,6 +18,7 @@ import { Entity, ENTITY_DEFAULT_NAMESPACE, RELATION_OWNED_BY, + RELATION_PROVIDES_API, serializeEntityRef, } from '@backstage/catalog-model'; import { @@ -111,6 +112,8 @@ type AboutCardProps = { export function AboutCard({ entity, variant }: AboutCardProps) { const classes = useStyles(); const codeLink = getCodeLinkInfo(entity); + // TODO: Also support RELATION_CONSUMES_API here + const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API); return ( @@ -119,6 +122,7 @@ export function AboutCard({ entity, variant }: AboutCardProps) { action={ { window.open(codeLink.edithref || '#', '_blank'); }} @@ -133,15 +137,21 @@ export function AboutCard({ entity, variant }: AboutCardProps) { disabled={ !entity.metadata.annotations?.['backstage.io/techdocs-ref'] } - label="View Techdocs" + label="View TechDocs" + title={ + !entity.metadata.annotations?.['backstage.io/techdocs-ref'] + ? 'No TechDocs available' + : '' + } icon={} href={`/docs/${ entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE }/${entity.kind}/${entity.metadata.name}`} /> } href="api" /> diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx b/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx index c054bc0d4e..dd267dde35 100644 --- a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx +++ b/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx @@ -23,6 +23,7 @@ export type IconLinkVerticalProps = { icon?: React.ReactNode; href?: string; disabled?: boolean; + title?: string; label: string; }; @@ -57,6 +58,7 @@ export function IconLinkVertical({ {icon} diff --git a/plugins/catalog/src/components/AboutCard/index.ts b/plugins/catalog/src/components/AboutCard/index.ts index 93765ff942..0491d467f8 100644 --- a/plugins/catalog/src/components/AboutCard/index.ts +++ b/plugins/catalog/src/components/AboutCard/index.ts @@ -15,3 +15,4 @@ */ export { AboutCard } from './AboutCard'; +export { IconLinkVertical } from './IconLinkVertical'; diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index a4ede7c2f2..b92fc3d4bc 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -33,30 +33,32 @@ import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { const catalogApi: Partial = { getEntities: () => - Promise.resolve([ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity1', + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, }, - spec: { - owner: 'tools@example.com', - type: 'service', + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity2', - }, - spec: { - owner: 'not-tools@example.com', - type: 'service', - }, - }, - ] as Entity[]), + ] as Entity[], + }), }; const identityApi: Partial = { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 0d645f0f35..28fb1b08c7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -15,7 +15,11 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import { + Entity, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, @@ -34,37 +38,68 @@ import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { const catalogApi: Partial = { getEntities: () => - Promise.resolve([ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity1', + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'Group', name: 'tools', namespace: 'default' }, + }, + ], }, - spec: { - owner: 'tools@example.com', - type: 'service', + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'not-tools', + namespace: 'default', + }, + }, + ], }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity2', - }, - spec: { - owner: 'not-tools@example.com', - type: 'service', - }, - }, - ] as Entity[]), + ] as Entity[], + }), getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), + getEntityByName: async entityName => { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: entityName.name }, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { namespace: 'default', kind: 'Group', name: 'tools' }, + }, + ], + }; + }, }; const testProfile: Partial = { displayName: 'Display Name', }; - const indentityApi: Partial = { + const identityApi: Partial = { getUserId: () => 'tools@example.com', getProfile: () => testProfile, }; @@ -75,7 +110,7 @@ describe('CatalogPage', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index e73a789380..077e1f092f 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -19,7 +19,6 @@ import { Content, ContentHeader, errorApiRef, - identityApiRef, SupportButton, useApi, } from '@backstage/core'; @@ -38,6 +37,8 @@ import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; import { WelcomeBanner } from './WelcomeBanner'; +import { useOwnUser } from '../useOwnUser'; +import { isOwnerOf } from '../isOwnerOf'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -65,7 +66,6 @@ const CatalogPageContents = () => { const catalogApi = useApi(catalogApiRef); const errorApi = useApi(errorApiRef); const { isStarredEntity } = useStarredEntities(); - const userId = useApi(identityApiRef).getUserId(); const [selectedTab, setSelectedTab] = useState(); const [selectedSidebarItem, setSelectedSidebarItem] = useState(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; @@ -112,6 +112,8 @@ const CatalogPageContents = () => { [], ); + const { value: user } = useOwnUser(); + const filterGroups = useMemo( () => [ { @@ -121,7 +123,7 @@ const CatalogPageContents = () => { id: 'owned', label: 'Owned', icon: SettingsIcon, - filterFn: entity => entity.spec?.owner === userId, + filterFn: entity => user !== undefined && isOwnerOf(user, entity), }, { id: 'starred', @@ -142,7 +144,7 @@ const CatalogPageContents = () => { ], }, ], - [isStarredEntity, userId, orgName], + [isStarredEntity, orgName, user], ); const showAddExampleEntities = diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index fd52b97cb1..e30f54a275 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -26,7 +26,7 @@ import { makeStyles } from '@material-ui/core/styles'; import Cancel from '@material-ui/icons/Cancel'; import MoreVert from '@material-ui/icons/MoreVert'; import SwapHoriz from '@material-ui/icons/SwapHoriz'; -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; // TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead const useStyles = makeStyles({ @@ -39,7 +39,7 @@ type Props = { onUnregisterEntity: () => void; }; -export const EntityContextMenu: FC = ({ onUnregisterEntity }) => { +export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 8c48d69413..eb75593ffc 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -39,12 +39,12 @@ const EntityPageTitle = ({ ); -function headerProps( +const headerProps = ( kind: string, namespace: string | undefined, name: string, entity: Entity | undefined, -): { headerTitle: string; headerType: string } { +): { headerTitle: string; headerType: string } => { return { headerTitle: `${name}${ namespace && namespace !== ENTITY_DEFAULT_NAMESPACE @@ -60,7 +60,7 @@ function headerProps( return t; })(), }; -} +}; export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { const { kind, namespace, name } = useEntityCompoundName(); @@ -88,7 +88,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { pageTitleOverride={headerTitle} type={headerType} > - {entity && ( + {/* TODO: fix after catalog page customization is added */} + {entity && kind !== 'user' && ( <> * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities * @param props MaterialUI IconButton props extended by required `entity` prop */ -export const FavouriteEntity: React.FC = props => { +export const FavouriteEntity = (props: Props) => { const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); const isStarred = isStarredEntity(props.entity); return ( diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx index 39837d2ca1..d105979600 100644 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -33,46 +33,48 @@ import { ResultsFilter } from './ResultsFilter'; describe('Results Filter', () => { const catalogApi: Partial = { getEntities: () => - Promise.resolve([ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity1', - tags: ['java'], + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + tags: ['java'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, }, - spec: { - owner: 'tools@example.com', - type: 'service', + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity2', + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity3', + tags: ['java', 'test'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, }, - spec: { - owner: 'not-tools@example.com', - type: 'service', - }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'Entity3', - tags: ['java', 'test'], - }, - spec: { - owner: 'tools@example.com', - type: 'service', - }, - }, - ] as Entity[]), + ] as Entity[], + }), }; - const indentityApi: Partial = { + const identityApi: Partial = { getUserId: () => 'tools@example.com', }; @@ -82,7 +84,7 @@ describe('Results Filter', () => { diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index fe1ba3a593..298aa28d52 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -50,7 +50,7 @@ const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { const { entity, loading, error } = useEntity(); // Loading and error states if (loading) return ; - if (error || (!loading && !entity)) return ; + if (error || !entity) return ; // Otherwise EntityPage provided from the App // Note that EntityPage will include EntityPageLayout already diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 218cc45e78..ce6e63da1b 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -28,7 +28,7 @@ import { useTheme, } from '@material-ui/core'; import Alert from '@material-ui/lab/Alert'; -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../plugin'; @@ -44,18 +44,22 @@ function useColocatedEntities(entity: Entity): AsyncState { const catalogApi = useApi(catalogApiRef); return useAsync(async () => { const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - return myLocation - ? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation }) - : []; + if (!myLocation) { + return []; + } + const response = await catalogApi.getEntities({ + filter: { [LOCATION_ANNOTATION]: myLocation }, + }); + return response.items; }, [catalogApi, entity]); } -export const UnregisterEntityDialog: FC = ({ +export const UnregisterEntityDialog = ({ open, onConfirm, onClose, entity, -}) => { +}: Props) => { const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog/src/components/getEntityRelations.test.ts new file mode 100644 index 0000000000..5b997d646b --- /dev/null +++ b/plugins/catalog/src/components/getEntityRelations.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { getEntityRelations } from './getEntityRelations'; +import { + Entity, + RELATION_CHILD_OF, + RELATION_MEMBER_OF, +} from '@backstage/catalog-model'; + +describe('getEntityRelations', () => { + it('should handle undefined value', () => { + expect(getEntityRelations(undefined, RELATION_MEMBER_OF)).toEqual([]); + }); + + it('should extract correct relation', () => { + const entity = { + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', name: 'member' }, + }, + { + type: RELATION_CHILD_OF, + target: { kind: 'group', name: 'child' }, + }, + ], + } as Entity; + + expect(getEntityRelations(entity, RELATION_MEMBER_OF)).toEqual([ + { kind: 'group', name: 'member' }, + ]); + }); + + it('should filter relation by type', () => { + const entity = { + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', name: 'member' }, + }, + { + type: RELATION_MEMBER_OF, + target: { kind: 'user', name: 'child' }, + }, + ], + } as Entity; + + expect( + getEntityRelations(entity, RELATION_MEMBER_OF, { kind: 'Group' }), + ).toEqual([{ kind: 'group', name: 'member' }]); + }); +}); diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/plugins/catalog/src/components/getEntityRelations.ts similarity index 53% rename from plugins/cost-insights/src/utils/graphs.tsx rename to plugins/catalog/src/components/getEntityRelations.ts index 167132cb36..f9f526698f 100644 --- a/plugins/cost-insights/src/utils/graphs.tsx +++ b/plugins/catalog/src/components/getEntityRelations.ts @@ -14,23 +14,26 @@ * limitations under the License. */ -import { - currencyFormatter, - dateFormatter, - lengthyCurrencyFormatter, -} from './formatters'; +import { Entity, EntityName } from '@backstage/catalog-model'; -export function formatGraphValue(value: number, format?: string) { - if (format === 'number') { - return value.toLocaleString(); +/** + * Get the related entity references. + */ +export function getEntityRelations( + entity: Entity | undefined, + relationType: string, + filter?: { kind: string }, +): EntityName[] { + let entityNames = + entity?.relations + ?.filter(r => r.type === relationType) + ?.map(r => r.target) || []; + + if (filter?.kind) { + entityNames = entityNames?.filter( + e => e.kind.toLowerCase() === filter.kind.toLowerCase(), + ); } - if (value < 1) { - return lengthyCurrencyFormatter.format(value); - } - - return currencyFormatter.format(value); + return entityNames; } - -export const overviewGraphTickFormatter = (millis: string | number) => - typeof millis === 'number' ? dateFormatter.format(millis) : millis; diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog/src/components/isOwnerOf.test.ts new file mode 100644 index 0000000000..dc1f2ab1a8 --- /dev/null +++ b/plugins/catalog/src/components/isOwnerOf.test.ts @@ -0,0 +1,64 @@ +/* + * 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, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { isOwnerOf } from './isOwnerOf'; + +describe('isOwnerOf', () => { + it('should be owned by user', () => { + const ownerEntity = { + kind: 'User', + metadata: { name: 'User', namespace: 'Default' }, + } as Entity; + const ownedEntity = { + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'user', namespace: 'default', name: 'user' }, + }, + ], + } as Entity; + + expect(isOwnerOf(ownerEntity, ownedEntity)).toBe(true); + }); + + it('should be owned by group', () => { + const ownerEntity = { + kind: 'User', + metadata: { name: 'user' }, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', namespace: 'default', name: 'group' }, + }, + ], + } as Entity; + const ownedEntity = { + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'group', namespace: 'default', name: 'group' }, + }, + ], + } as Entity; + + expect(isOwnerOf(ownerEntity, ownedEntity)).toBe(true); + }); +}); diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog/src/components/isOwnerOf.ts new file mode 100644 index 0000000000..455e06dc13 --- /dev/null +++ b/plugins/catalog/src/components/isOwnerOf.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + EntityName, + getEntityName, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { getEntityRelations } from './getEntityRelations'; + +/** + * Get the related entity references. + */ +export function isOwnerOf(owner: Entity, owned: Entity) { + const possibleOwners: EntityName[] = [ + ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), + ...(owner ? [getEntityName(owner)] : []), + ]; + + const owners = getEntityRelations(owned, RELATION_OWNED_BY); + + for (const owner of owners) { + if ( + possibleOwners.find( + o => + owner.kind.toLowerCase() === o.kind.toLowerCase() && + owner.namespace.toLowerCase() === o.namespace.toLowerCase() && + owner.name.toLowerCase() === o.name.toLowerCase(), + ) !== undefined + ) { + return true; + } + } + + return false; +} diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts new file mode 100644 index 0000000000..9aa4bda99c --- /dev/null +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserEntity } from '@backstage/catalog-model'; +import { useAsync } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { identityApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '../plugin'; + +/** + * Get the catalog User entity (if any) that matches the logged-in user. + */ +export function useOwnUser(): AsyncState { + const catalogApi = useApi(catalogApiRef); + const identityApi = useApi(identityApiRef); + + // TODO: get the full entity (or at least the full entity name) from the + // identityApi + return useAsync( + () => + catalogApi.getEntityByName({ + kind: 'User', + namespace: 'default', + name: identityApi.getUserId(), + }) as Promise, + [catalogApi, identityApi], + ); +} diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index 36cc0c56df..edda8eecb5 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -46,9 +46,12 @@ export const EntityFilterGroupsProvider = ({ // The hook that implements the actual context building function useProvideEntityFilters(): FilterGroupsContext { const catalogApi = useApi(catalogApiRef); - const [{ value: entities, error }, doReload] = useAsyncFn(() => - catalogApi.getEntities({ kind: 'Component' }), - ); + const [{ value: entities, error }, doReload] = useAsyncFn(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Component' }, + }); + return response.items; + }); const filterGroups = useRef<{ [filterGroupId: string]: FilterGroup; diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx index 6b573e3721..e9af063160 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -49,7 +49,7 @@ describe('useEntityFilterGroup', () => { }); it('works for an empty set of filters', async () => { - catalogApi.getEntities.mockResolvedValue([]); + catalogApi.getEntities.mockResolvedValue({ items: [] }); const group: FilterGroup = { filters: {} }; const { result, waitFor } = renderHook( () => useEntityFilterGroup('g1', group), @@ -60,13 +60,15 @@ describe('useEntityFilterGroup', () => { }); it('works for a single group', async () => { - catalogApi.getEntities.mockResolvedValue([ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { name: 'n' }, - }, - ]); + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ], + }); const group: FilterGroup = { filters: { f1: e => e.metadata.name === 'n', diff --git a/plugins/catalog/src/hooks/useStarredEntities.test.tsx b/plugins/catalog/src/hooks/useStarredEntities.test.tsx index 345bc9f04f..78d2c4a58f 100644 --- a/plugins/catalog/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog/src/hooks/useStarredEntities.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useStarredEntities } from './useStarredEntities'; import { @@ -47,7 +47,7 @@ describe('useStarredEntities', () => { }, }; - const wrapper: React.FC<{}> = ({ children }) => { + const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( {children} diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 606ad9028d..5949209128 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -15,7 +15,7 @@ */ export * from '@backstage/catalog-client'; -export { AboutCard } from './components/AboutCard'; +export { AboutCard, IconLinkVertical } from './components/AboutCard'; export { EntityPageLayout } from './components/EntityPageLayout'; export { Router } from './components/Router'; export { useEntityCompoundName } from './components/useEntityCompoundName'; diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 065d2e6526..dc3bf0d5ca 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,65 @@ # @backstage/plugin-circleci +## 0.2.4 + +### Patch Changes + +- 04efbbdd2: Refactor to support ADR004 module exporting. + + For more information, see https://backstage.io/docs/architecture-decisions/adrs-adr004. + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.2 + +### Patch Changes + +- a8de7f554: Improved CircleCI builds table to show more information and relevant links +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 00a39ce6c8..35fed49827 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -7,25 +7,12 @@ Website: [https://circleci.com/](https://circleci.com/) ## Setup -0. If you have standalone app (you didn't clone this repo), then do +1. If you have standalone app (you didn't clone this repo), then do ```bash yarn add @backstage/plugin-circleci ``` -1. Add plugin API to your Backstage instance: - -```js -// packages/app/src/api.ts -import { ApiHolder } from '@backstage/core'; -import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci'; - -const builder = ApiRegistry.builder(); -builder.add(circleCIApiRef, new CircleCIApi(/* optional custom url for your own CircleCI instance */)); - -export default builder.build() as ApiHolder; -``` - 2. Add plugin itself: ```js @@ -61,7 +48,7 @@ proxy: ``` 5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) -6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) +6. Add `circleci.com/project-slug` annotation to your catalog-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) ```yaml apiVersion: backstage.io/v1alpha1 @@ -88,4 +75,4 @@ spec: ## Limitations - CircleCI has pretty strict rate limits per token, be careful with opened tabs -- CircelCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356) +- CircleCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356) diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index cd849cbd31..76fc13df52 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.0", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/circleci" + }, + "keywords": [ + "backstage", + "circleci" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,14 +31,16 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "circleci-api": "^4.0.0", + "dayjs": "^1.9.4", + "lodash": "^4.17.15", "moment": "^2.25.3", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -38,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/circleci/src/api/CircleCIApi.ts b/plugins/circleci/src/api/CircleCIApi.ts new file mode 100644 index 0000000000..c71be6b781 --- /dev/null +++ b/plugins/circleci/src/api/CircleCIApi.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CircleCIOptions, + getMe, + getBuildSummaries, + getFullBuild, + postBuildActions, + BuildAction, + BuildWithSteps, + BuildStepAction, + BuildSummary, + GitType, +} from 'circleci-api'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; + +export { GitType }; +export type { BuildWithSteps, BuildStepAction, BuildSummary }; + +export const circleCIApiRef = createApiRef({ + id: 'plugin.circleci.service', + description: 'Used by the CircleCI plugin to make requests', +}); + +const DEFAULT_PROXY_PATH = '/circleci/api'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /circleci/api + */ + proxyPath?: string; +}; + +export class CircleCIApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; + } + + async retry(buildNumber: number, options: Partial) { + return postBuildActions('', buildNumber, BuildAction.RETRY, { + circleHost: await this.getApiUrl(), + ...options.vcs, + }); + } + + async getBuilds( + { limit = 10, offset = 0 }: { limit: number; offset: number }, + options: Partial, + ) { + return getBuildSummaries('', { + options: { + limit, + offset, + }, + vcs: {}, + circleHost: await this.getApiUrl(), + ...options, + }); + } + + async getUser(options: Partial) { + return getMe('', { circleHost: await this.getApiUrl(), ...options }); + } + + async getBuild(buildNumber: number, options: Partial) { + return getFullBuild('', buildNumber, { + circleHost: await this.getApiUrl(), + ...options.vcs, + }); + } + + private async getApiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } +} diff --git a/plugins/circleci/src/api/index.ts b/plugins/circleci/src/api/index.ts index c71be6b781..1853008099 100644 --- a/plugins/circleci/src/api/index.ts +++ b/plugins/circleci/src/api/index.ts @@ -14,82 +14,9 @@ * limitations under the License. */ -import { - CircleCIOptions, - getMe, - getBuildSummaries, - getFullBuild, - postBuildActions, - BuildAction, +export { CircleCIApi, circleCIApiRef, GitType } from './CircleCIApi'; +export type { BuildWithSteps, BuildStepAction, BuildSummary, - GitType, -} from 'circleci-api'; -import { createApiRef, DiscoveryApi } from '@backstage/core'; - -export { GitType }; -export type { BuildWithSteps, BuildStepAction, BuildSummary }; - -export const circleCIApiRef = createApiRef({ - id: 'plugin.circleci.service', - description: 'Used by the CircleCI plugin to make requests', -}); - -const DEFAULT_PROXY_PATH = '/circleci/api'; - -type Options = { - discoveryApi: DiscoveryApi; - /** - * Path to use for requests via the proxy, defaults to /circleci/api - */ - proxyPath?: string; -}; - -export class CircleCIApi { - private readonly discoveryApi: DiscoveryApi; - private readonly proxyPath: string; - - constructor(options: Options) { - this.discoveryApi = options.discoveryApi; - this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; - } - - async retry(buildNumber: number, options: Partial) { - return postBuildActions('', buildNumber, BuildAction.RETRY, { - circleHost: await this.getApiUrl(), - ...options.vcs, - }); - } - - async getBuilds( - { limit = 10, offset = 0 }: { limit: number; offset: number }, - options: Partial, - ) { - return getBuildSummaries('', { - options: { - limit, - offset, - }, - vcs: {}, - circleHost: await this.getApiUrl(), - ...options, - }); - } - - async getUser(options: Partial) { - return getMe('', { circleHost: await this.getApiUrl(), ...options }); - } - - async getBuild(buildNumber: number, options: Partial) { - return getFullBuild('', buildNumber, { - circleHost: await this.getApiUrl(), - ...options.vcs, - }); - } - - private async getApiUrl() { - const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); - return proxyUrl + this.proxyPath; - } -} +} from './CircleCIApi'; diff --git a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 30994f5ce3..18ae5a2d04 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useEffect } from 'react'; + +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { InfoCard, Progress, Link } from '@backstage/core'; import { BuildWithSteps, BuildStepAction } from '../../api'; @@ -31,7 +32,8 @@ import LaunchIcon from '@material-ui/icons/Launch'; import { useBuildWithSteps } from '../../state/useBuildWithSteps'; const IconLink = (IconButton as any) as typeof MaterialLink; -const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( + +const BuildName = ({ build }: { build?: BuildWithSteps }) => ( #{build?.build_num} - {build?.subject} @@ -39,6 +41,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); + const useStyles = makeStyles(theme => ({ neutral: {}, failed: { @@ -96,7 +99,7 @@ const pickClassName = ( return classes.neutral; }; -const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( +const BuildsList = ({ build }: { build?: BuildWithSteps }) => ( {build && build.steps && @@ -108,8 +111,11 @@ const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); -const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({ +const ActionsList = ({ actions, +}: { + actions: BuildStepAction[]; + name: string; }) => { const classes = useStyles(); return ( diff --git a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index 65e4537ad7..e446c002f6 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -25,7 +25,7 @@ import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { BuildStepAction } from 'circleci-api'; import moment from 'moment'; -import React, { FC, Suspense, useEffect, useState } from 'react'; +import React, { Suspense, useEffect, useState } from 'react'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); moment.relativeTimeThreshold('ss', 0); @@ -40,12 +40,17 @@ const useStyles = makeStyles({ }, }); -export const ActionOutput: FC<{ +export const ActionOutput = ({ + url, + name, + className, + action, +}: { url: string; name: string; className?: string; action: BuildStepAction; -}> = ({ url, name, className, action }) => { +}) => { const classes = useStyles(); const [messages, setMessages] = useState([]); diff --git a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx index bed96bb24c..685e61a5af 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { CITable } from '../CITable'; import { useBuilds } from '../../../../state'; -export const Builds: FC<{}> = () => { +export const Builds = () => { const [ { total, loading, value, projectName, page, pageSize }, { setPage, retry, setPageSize }, diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index 8e693429c2..e5e8b951be 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,10 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton } from '@material-ui/core'; + +import React from 'react'; +import { + Avatar, + Link, + Typography, + Box, + IconButton, + makeStyles, +} from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; +import LaunchIcon from '@material-ui/icons/Launch'; import { Link as RouterLink, generatePath } from 'react-router-dom'; import { StatusError, @@ -27,17 +36,22 @@ import { Table, TableColumn, } from '@backstage/core'; +import { durationHumanized, relativeTimeTo } from '../../../../util'; import { circleCIBuildRouteRef } from '../../../../route-refs'; export type CITableBuildInfo = { id: string; buildName: string; buildUrl?: string; + startTime?: string; + stopTime?: string; source: { branchName: string; commit: { hash: string; + shortHash: string; url: string; + committerName?: string; }; }; status: string; @@ -48,6 +62,18 @@ export type CITableBuildInfo = { failed: number; testUrl: string; // fixme better name }; + workflow: { + id: string; + url: string; + name?: string; + jobName?: string; + }; + user: { + isUser: boolean; + login: string; + name?: string; + avatarUrl?: string; + }; onRestartClick: () => void; }; @@ -69,6 +95,43 @@ const getStatusComponent = (status: string | undefined = '') => { } }; +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + '& > *': { + margin: theme.spacing(1), + verticalAlign: 'center', + }, + }, + small: { + width: theme.spacing(3), + height: theme.spacing(3), + }, +})); + +const SourceInfo = ({ build }: { build: CITableBuildInfo }) => { + const classes = useStyles(); + const { user, source } = build; + + return ( + + + + {source?.branchName} + + {source?.commit?.url !== undefined ? ( + + {source?.commit.shortHash} + + ) : ( + source?.commit.shortHash + )} + + + + ); +}; + const generatedColumns: TableColumn[] = [ { title: 'ID', @@ -80,26 +143,43 @@ const generatedColumns: TableColumn[] = [ title: 'Build', field: 'buildName', highlight: true, + width: '20%', render: (row: Partial) => ( - {row.buildName} + {row.buildName ? row.buildName : row?.workflow?.name} + + ), + }, + { + title: 'Job', + field: 'buildName', + highlight: true, + render: (row: Partial) => ( + + + + + {row?.workflow?.jobName} + ), }, { title: 'Source', + field: 'source.commit.hash', + highlight: true, render: (row: Partial) => ( - <> -

{row.source?.branchName}

-

{row.source?.commit.hash}

- + ), }, { title: 'Status', + field: 'status', render: (row: Partial) => ( {getStatusComponent(row.status)} @@ -108,14 +188,32 @@ const generatedColumns: TableColumn[] = [ ), }, + { + title: 'Time', + field: 'startTime', + render: (row: Partial) => ( + <> + + run {relativeTimeTo(row?.startTime)} + + + took {durationHumanized(row?.startTime, row?.stopTime)} + + + ), + }, + { + title: 'Workflow', + field: 'workflow.name', + }, { title: 'Actions', + width: '10%', render: (row: Partial) => ( ), - width: '10%', }, ]; @@ -130,7 +228,8 @@ type Props = { pageSize: number; onChangePageSize: (pageSize: number) => void; }; -export const CITable: FC = ({ + +export const CITable = ({ projectName, loading, pageSize, @@ -140,11 +239,16 @@ export const CITable: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return ( = props => ( +const CircleCIIcon = (props: SvgIconProps) => ( { if (!status) return ''; @@ -41,6 +43,39 @@ const makeReadableStatus = (status: string | undefined) => { } as Record)[status]; }; +const mapWorkflowDetails = (buildData: BuildSummary) => { + // Workflows should be an object: fixed in https://github.com/worldturtlemedia/circleci-api/pull/787 + const { workflows } = (buildData as any) ?? {}; + + return { + id: workflows?.workflow_id, + url: `${buildData.build_url}/workflows/${workflows?.workflow_id}`, + jobName: workflows?.job_name, + name: workflows?.workflow_name, + }; +}; + +const mapSourceDetails = (buildData: BuildSummary) => { + const commitDetails = getOr({}, 'all_commit_details[0]', buildData); + + return { + branchName: String(buildData.branch), + commit: { + hash: String(buildData.vcs_revision), + shortHash: String(buildData.vcs_revision).substr(0, 7), + committerName: buildData.committer_name, + url: commitDetails.commit_url, + }, + }; +}; + +const mapUser = (buildData: BuildSummary) => ({ + isUser: buildData?.user?.is_user || false, + login: buildData?.user?.login || 'none', + name: (buildData?.user as any)?.name, + avatarUrl: (buildData?.user as any)?.avatar_url, +}); + export const transform = ( buildsData: BuildSummary[], restartBuild: { (buildId: number): Promise }, @@ -52,16 +87,14 @@ export const transform = ( ? buildData.subject + (buildData.retry_of ? ` (retry of #${buildData.retry_of})` : '') : '', + startTime: buildData.start_time, + stopTime: buildData.stop_time, onRestartClick: () => typeof buildData.build_num !== 'undefined' && restartBuild(buildData.build_num), - source: { - branchName: String(buildData.branch), - commit: { - hash: String(buildData.vcs_revision), - url: 'todo', - }, - }, + source: mapSourceDetails(buildData), + workflow: mapWorkflowDetails(buildData), + user: mapUser(buildData), status: makeReadableStatus(buildData.status), buildUrl: buildData.build_url, }; @@ -79,6 +112,7 @@ export const useProjectSlugFromEntity = () => { export function mapVcsType(vcs: string): GitType { switch (vcs) { + case 'gh': case 'github': return GitType.GITHUB; default: @@ -93,7 +127,7 @@ export function useBuilds() { const [total, setTotal] = useState(0); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(5); + const [pageSize, setPageSize] = useState(10); const getBuilds = useCallback( async ({ limit, offset }: { limit: number; offset: number }) => { diff --git a/plugins/techdocs-backend/src/techdocs/index.ts b/plugins/circleci/src/util/index.ts similarity index 95% rename from plugins/techdocs-backend/src/techdocs/index.ts rename to plugins/circleci/src/util/index.ts index 7113525bb8..55bb001ae4 100644 --- a/plugins/techdocs-backend/src/techdocs/index.ts +++ b/plugins/circleci/src/util/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './stages'; + +export * from './time'; diff --git a/plugins/circleci/src/util/time.test.ts b/plugins/circleci/src/util/time.test.ts new file mode 100644 index 0000000000..dae029c8d2 --- /dev/null +++ b/plugins/circleci/src/util/time.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { durationHumanized, relativeTimeTo } from './time'; + +describe('times utils', () => { + describe('toRelativeTime', () => { + it('should give a relative time of x from today', () => { + expect(relativeTimeTo('2020-01-01')).toEqual(expect.any(String)); + }); + }); + + describe('durationHumanized', () => { + it('should give a humanized duration', () => { + expect(durationHumanized('2020-11-01', '2020-11-03')).toBe('2 days'); + }); + }); +}); diff --git a/plugins/circleci/src/util/time.ts b/plugins/circleci/src/util/time.ts new file mode 100644 index 0000000000..8756333a87 --- /dev/null +++ b/plugins/circleci/src/util/time.ts @@ -0,0 +1,35 @@ +/* + * 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 dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import relativeTimePlugin from 'dayjs/plugin/relativeTime'; + +dayjs.extend(durationPlugin); +dayjs.extend(relativeTimePlugin); + +type DateTimeObject = Date | string | number | undefined; + +export function relativeTimeTo(time: DateTimeObject, withoutSuffix = false) { + return dayjs().to(dayjs(time), withoutSuffix); +} + +export function durationHumanized( + startTime: DateTimeObject, + endTime: DateTimeObject, +) { + return dayjs.duration(dayjs(startTime).diff(dayjs(endTime))).humanize(); +} diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index a9df03e8e2..f2a636cc4c 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,60 @@ # @backstage/plugin-cloudbuild +## 0.2.4 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 14a7c5091c..dd040c6cea 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.0", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/cloudbuild" + }, + "keywords": [ + "backstage", + "google cloud" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -39,9 +49,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 254e6f89ed..43cbbefe24 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GoogleIcon from '@material-ui/icons/CloudCircle'; @@ -124,7 +124,7 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -export const WorkflowRunsTableView: FC = ({ +export const WorkflowRunsTableView = ({ projectName, loading, pageSize, @@ -134,7 +134,7 @@ export const WorkflowRunsTableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
1000% +- Updated dependencies [475fc0aaa] + - @backstage/core@0.3.2 + +## 0.4.0 + +### Minor Changes + +- 4040d4fcb: remove cost insights currency feature flag + +### Patch Changes + +- 1722cb53c: Added configuration schema +- 17a9f48f6: remove excessive margin from cost overview banner +- f360395d0: UI improvements: Increase width of first column in product entity dialog table + UI improvement: Display full cost amount in product entity dialog table +- 259d848ee: Fix savings/excess display calculation +- Updated dependencies [1722cb53c] + - @backstage/core@0.3.1 + - @backstage/test-utils@0.1.3 + +## 0.3.0 + +### Minor Changes + +- 0703edee0: rename: Tooltip -> BarChartTooltip + rename: TooltipItem -> BarChartTooltipItem + Deprecate BarChartData in favor of BarChartOptions + Export BarChartLegend component + Update BarChart props to accept options prop + Deprecate ProductCost type in favor of Entity. Update CostInsightsApi + +### Patch Changes + +- 9a294574c: Fix styling issue on Cost Insights product panels with no cost +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/cost-insights/config.d.ts b/plugins/cost-insights/config.d.ts new file mode 100644 index 0000000000..5099be43cb --- /dev/null +++ b/plugins/cost-insights/config.d.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface Config { + costInsights: { + /** + * @visibility frontend + */ + engineerCost: number; + + products: { + [kind: string]: { + /** + * @visibility frontend + */ + name: string; + + /** + * @visibility frontend + */ + icon?: 'compute' | 'data' | 'database' | 'storage' | 'search' | 'ml'; + }; + }; + + metrics?: { + [kind: string]: { + /** + * @visibility frontend + */ + name: string; + + /** + * @visibility frontend + */ + default?: boolean; + }; + }; + }; +} diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 944dbf587f..2ff9ab7d1d 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.2.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/cost-insights" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.2.0", - "@backstage/test-utils": "^0.1.2", - "@backstage/theme": "^0.2.0", + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.4.0", + "@backstage/test-utils": "^0.1.5", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,6 +45,7 @@ "dayjs": "^1.9.4", "history": "^5.0.0", "moment": "^2.27.0", + "pluralize": "^8.0.0", "qs": "^6.9.4", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -46,14 +56,15 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/pluralize": "^0.0.29", "@types/recharts": "^1.8.14", "@types/regression": "^2.0.0", "@types/yup": "^0.29.8", @@ -61,6 +72,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index bedbf8319d..26676be8b7 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -18,10 +18,9 @@ import { createApiRef } from '@backstage/core'; import { Alert, Cost, - Duration, + Entity, Group, Project, - ProductCost, Maybe, MetricData, } from '../types'; @@ -38,15 +37,9 @@ export type ProductInsightsOptions = { group: string; /** - * A time duration, such as P1M. See the Duration type for a detailed explanation - * of how the durations are interpreted in Cost Insights. + * An ISO 8601 repeating interval string, such as R2/P3M/2020-09-01 */ - duration: Duration; - - /** - * The most current date for which billing data is complete, in YYYY-MM-DD format. - */ - lastCompleteBillingDate: string; + intervals: string; /** * (optional) The project id from getGroupProjects or query parameters @@ -90,7 +83,7 @@ export type CostInsightsApi = { * reduction) and compare it to metrics important to the business. * * @param group The group id from getUserGroups or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getGroupDailyCost(group: string, intervals: string): Promise; @@ -108,7 +101,7 @@ export type CostInsightsApi = { * (or reduction) and compare it to metrics important to the business. * * @param project The project id from getGroupProjects or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getProjectDailyCost(project: string, intervals: string): Promise; @@ -119,7 +112,7 @@ export type CostInsightsApi = { * (or reduction) of a project or group's daily costs. * * @param metric A metric from the cost-insights configuration in app-config.yaml. - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getDailyMetricData(metric: string, intervals: string): Promise; @@ -138,7 +131,8 @@ export type CostInsightsApi = { * * @param options Options to use when fetching insights for a particular cloud product and interval timeframe. */ - getProductInsights(options: ProductInsightsOptions): Promise; + getProductInsights(options: ProductInsightsOptions): Promise; + /** * Get current cost alerts for a given group. These show up as Action Items for the group on the * Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 1fa57c624a..0ba57f76fa 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -16,21 +16,16 @@ /* eslint-disable no-restricted-imports */ import dayjs from 'dayjs'; -import regression, { DataPoint } from 'regression'; import { CostInsightsApi, ProductInsightsOptions } from '../src/api'; import { Alert, - ChangeStatistic, Cost, - DateAggregation, DEFAULT_DATE_FORMAT, - Duration, + Entity, Group, MetricData, - ProductCost, Project, ProjectGrowthData, - Trendline, UnlabeledDataflowData, } from '../src/types'; import { @@ -38,79 +33,12 @@ import { UnlabeledDataflowAlert, } from '../src/utils/alerts'; import { - exclusiveEndDateOf, - inclusiveStartDateOf, -} from '../src/utils/duration'; - -type IntervalFields = { - duration: Duration; - endDate: string; -}; - -function parseIntervals(intervals: string): IntervalFields { - const match = intervals.match( - /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, - ); - if (Object.keys(match?.groups || {}).length !== 2) { - throw new Error(`Invalid intervals: ${intervals}`); - } - const { duration, date } = match!.groups!; - return { - duration: duration as Duration, - endDate: date, - }; -} - -function aggregationFor( - intervals: string, - baseline: number, -): DateAggregation[] { - const { duration, endDate } = parseIntervals(intervals); - const days = dayjs(exclusiveEndDateOf(duration, endDate)).diff( - inclusiveStartDateOf(duration, endDate), - 'day', - ); - - return [...Array(days).keys()].reduce( - (values: DateAggregation[], i: number): DateAggregation[] => { - const last = values.length ? values[values.length - 1].amount : baseline; - values.push({ - date: dayjs(inclusiveStartDateOf(duration, endDate)) - .add(i, 'day') - .format(DEFAULT_DATE_FORMAT), - amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), - }); - return values; - }, - [], - ); -} - -function trendlineOf(aggregation: DateAggregation[]): Trendline { - const data: ReadonlyArray = aggregation.map(a => [ - Date.parse(a.date) / 1000, - a.amount, - ]); - const result = regression.linear(data, { precision: 5 }); - return { - slope: result.equation[0], - intercept: result.equation[1], - }; -} - -function changeOf(aggregation: DateAggregation[]): ChangeStatistic { - const half = Math.ceil(aggregation.length / 2); - const before = aggregation - .slice(0, half) - .reduce((sum, a) => sum + a.amount, 0); - const after = aggregation - .slice(half, aggregation.length) - .reduce((sum, a) => sum + a.amount, 0); - return { - ratio: (after - before) / before, - amount: after - before, - }; -} + trendlineOf, + changeOf, + entityOf, + getGroupedProducts, + aggregationFor, +} from './utils/mockData'; export class ExampleCostInsightsClient implements CostInsightsApi { private request(_: any, res: any): Promise { @@ -171,6 +99,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), + // Optional field on Cost which needs to be supplied in order to see + // the product breakdown view in the top panel. + groupedCosts: getGroupedProducts(intervals), }, ); @@ -186,92 +117,22 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), + // Optional field on Cost which needs to be supplied in order to see + // the product breakdown view in the top panel. + groupedCosts: getGroupedProducts(intervals), }, ); return projectDailyCost; } - async getProductInsights( - productInsightsOptions: ProductInsightsOptions, - ): Promise { - const projectProductInsights = await this.request(productInsightsOptions, { - aggregation: [80_000, 110_000], - change: { - ratio: 0.375, - amount: 30_000, - }, - entities: [ - { - id: null, // entities with null ids will be appear as "Unlabeled" in product panels - aggregation: [45_000, 50_000], - }, - { - id: 'entity-a', - aggregation: [15_000, 20_000], - }, - { - id: 'entity-b', - aggregation: [20_000, 30_000], - }, - { - id: 'entity-e', - aggregation: [0, 10_000], - }, - ], - }); - const productInsights: ProductCost = await this.request( - productInsightsOptions, - { - aggregation: [200_000, 250_000], - change: { - ratio: 0.2, - amount: 50_000, - }, - entities: [ - { - id: null, // entities with null ids will be appear as "Unlabeled" in product panels - aggregation: [15_000, 30_000], - }, - { - id: 'entity-a', - aggregation: [15_000, 20_000], - }, - { - id: 'entity-b', - aggregation: [20_000, 30_000], - }, - { - id: 'entity-c', - aggregation: [18_000, 25_000], - }, - { - id: 'entity-d', - aggregation: [36_000, 42_000], - }, - { - id: 'entity-e', - aggregation: [0, 10_000], - }, - { - id: 'entity-f', - aggregation: [17_000, 19_000], - }, - { - id: 'entity-g', - aggregation: [49_000, 30_000], - }, - { - id: 'entity-h', - aggregation: [0, 34_000], - }, - ], - }, + async getProductInsights(options: ProductInsightsOptions): Promise { + const productInsights: Entity = await this.request( + options, + entityOf(options.product), ); - return productInsightsOptions.project - ? projectProductInsights - : productInsights; + return productInsights; } async getAlerts(group: string): Promise { @@ -282,7 +143,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: [60_000, 120_000], change: { ratio: 1, - amount: 60000, + amount: 60_000, }, products: [ { id: 'Compute Engine', aggregation: [58_000, 118_000] }, diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx index f28989d745..aaadbed09d 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, Fragment } from 'react'; +import React, { Fragment } from 'react'; import { Paper, Divider } from '@material-ui/core'; import { AlertActionCard } from './AlertActionCard'; import { Alert } from '../../types'; @@ -22,7 +22,7 @@ type AlertActionCardList = { alerts: Array; }; -export const AlertActionCardList: FC = ({ alerts }) => ( +export const AlertActionCardList = ({ alerts }: AlertActionCardList) => ( {alerts.map((alert, index) => ( diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx index a095324e6e..4e40a5ddda 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -15,56 +15,40 @@ */ import React from 'react'; -import { TooltipPayload } from 'recharts'; import { fireEvent } from '@testing-library/react'; import { BarChart, BarChartProps } from './BarChart'; -import { BarChartData, ResourceData } from '../../types'; +import { ResourceData } from '../../types'; import { createMockEntity } from '../../utils/mockData'; import { resourceSort } from '../../utils/sort'; import { renderInTestApp } from '@backstage/test-utils'; -import { TooltipItemProps } from '../Tooltip'; -import { costInsightsLightTheme } from '../../utils/styles'; + +// Disable responsive container console warnings generated by recharts; doesn't disable React warnings. +jest.spyOn(console, 'warn').mockImplementation(() => {}); const MockEntities = [...Array(10)].map((_, index) => - createMockEntity(() => ({ + createMockEntity(mock => ({ + ...mock, id: `test-id-${index + 1}`, // grow resource costs linearly for testing aggregation: [index * 1000, (index + 1) * 1000], })), ); -const MockBarChartData: BarChartData = { - previousFill: costInsightsLightTheme.palette.yellow, - currentFill: costInsightsLightTheme.palette.darkBlue, - previousName: 'Last Period', - currentName: 'Current Period', -}; - const MockResources: ResourceData[] = MockEntities.map(entity => ({ name: entity.id, previous: entity.aggregation[0], current: entity.aggregation[1], })); -const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({ - label: payload.name, - value: payload.value as string, - fill: payload.fill as string, -}); - const renderWithProps = ({ responsive = false, displayAmount = 6, - barChartData = MockBarChartData, - getTooltipItem = MockTooltipItem, resources = MockResources, }: BarChartProps) => { return renderInTestApp( , ); diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.tsx index aa1fd5c601..93f738a38b 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChart.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChart.tsx @@ -20,22 +20,21 @@ import { BarChart as RechartsBarChart, CartesianGrid, ContentRenderer, - TooltipProps, + TooltipProps as RechartsTooltipProps, + RechartsFunction, ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis, - TooltipPayload, } from 'recharts'; import { Box, useTheme } from '@material-ui/core'; import { BarChartTick } from './BarChartTick'; import { BarChartStepper } from './BarChartStepper'; -import { Tooltip, TooltipItemProps } from '../Tooltip'; - +import { BarChartTooltip } from './BarChartTooltip'; +import { BarChartTooltipItem } from './BarChartTooltipItem'; import { currencyFormatter } from '../../utils/formatters'; import { BarChartData, - Maybe, ResourceData, DataKey, CostInsightsTheme, @@ -43,28 +42,58 @@ import { import { notEmpty } from '../../utils/assert'; import { useBarChartStyles } from '../../utils/styles'; import { resourceSort } from '../../utils/sort'; +import { isInvalid, titleOf, tooltipItemOf } from '../../utils/graphs'; + +export const defaultTooltip: ContentRenderer = ({ + label, + payload = [], +}) => { + if (isInvalid({ label, payload })) return null; + + const title = titleOf(label); + const items = payload.map(tooltipItemOf).filter(notEmpty); + return ( + + {items.map((item, index) => ( + + ))} + + ); +}; export type BarChartProps = { + resources: ResourceData[]; responsive?: boolean; displayAmount?: number; - barChartData: BarChartData; - getTooltipItem: (payload: TooltipPayload) => Maybe; - resources: ResourceData[]; + options?: Partial; + tooltip?: ContentRenderer; + onClick?: RechartsFunction; + onMouseMove?: RechartsFunction; }; export const BarChart = ({ + resources, responsive = true, displayAmount = 6, - barChartData, - getTooltipItem, - resources, + options = {}, + tooltip = defaultTooltip, + onClick, + onMouseMove, }: BarChartProps) => { const theme = useTheme(); const styles = useBarChartStyles(theme); const [activeChart, setActiveChart] = useState(false); const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]); - const { previousFill, currentFill, previousName, currentName } = barChartData; + const data = Object.assign( + { + previousFill: theme.palette.lightBlue, + currentFill: theme.palette.darkBlue, + previousName: 'Previous', + currentName: 'Current', + }, + options, + ); const [stepStart, stepEnd] = stepWindow; const steps = Math.ceil(resources.length / displayAmount); @@ -95,24 +124,11 @@ export const BarChart = ({ [setStepWindow, resources, displayAmount], ); - const handleChartEnter = () => setActiveChart(true); - - const handleChartLeave = () => setActiveChart(false); - - const renderTooltipContent: ContentRenderer = ({ - label, - payload, - }) => { - if (!(payload && typeof label === 'string')) return [null, null]; - const items = payload.map(getTooltipItem).filter(notEmpty); - return ; - }; - return ( setActiveChart(false)} + onMouseEnter={() => setActiveChart(true)} data-testid="bar-chart-wrapper" > {/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */} @@ -121,16 +137,22 @@ export const BarChart = ({ width={responsive ? '100%' : styles.container.width} > - + {tooltip && ( + + )} 0, globalResourcesMax]} - tick={{ fill: styles.axis.fill }} + tick={styles.axis} /> diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index 0a21f1ed46..c4402893f3 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -15,7 +15,7 @@ */ import React, { PropsWithChildren } from 'react'; -import { Box } from '@material-ui/core'; +import { Box, Typography } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; type BarChartLabel = { @@ -23,6 +23,7 @@ type BarChartLabel = { y: number; height: number; width: number; + details?: JSX.Element; }; export const BarChartLabel = ({ @@ -30,11 +31,11 @@ export const BarChartLabel = ({ y, height, width, + details, children, }: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; - const childArray = React.Children.toArray(children); return ( - {childArray[0]} - {childArray.slice(1)} + + {children} + + {details} ); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx similarity index 56% rename from plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.test.tsx rename to plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx index ab38ba8d44..3074af716e 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx @@ -15,20 +15,15 @@ */ import React from 'react'; -import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend'; import { renderInTestApp } from '@backstage/test-utils'; +import { BarChartLegend } from './BarChartLegend'; -describe('', () => { - it('Displays the correct text', async () => { +describe('', () => { + it(`Should display the correct cost start and end`, async () => { const rendered = await renderInTestApp( - , + , ); - expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument(); - expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument(); - expect(rendered.getByText('$9,843')).toBeInTheDocument(); - expect(rendered.getByText('$2,309')).toBeInTheDocument(); + expect(rendered.getByText(/\$1,000/)).toBeInTheDocument(); + expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx similarity index 51% rename from plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx rename to plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx index 6b5ce17c95..7ed6854d35 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx @@ -14,47 +14,65 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, useTheme } from '@material-ui/core'; import { LegendItem } from '../LegendItem'; -import { CostGrowth } from '../CostGrowth'; import { currencyFormatter } from '../../utils/formatters'; -import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; +import { CostInsightsTheme } from '../../types'; +import { useBarChartLayoutStyles as useStyles } from '../../utils/styles'; -export type ResourceGrowthBarChartLegendProps = { - change: ChangeStatistic; - duration: Duration; +export type BarChartLegendOptions = { previousName: string; + previousFill: string; currentName: string; - costStart: number; - costEnd: number; + currentFill: string; + hideMarker?: boolean; }; -export const ResourceGrowthBarChartLegend = ({ - change, - duration, - previousName, - currentName, +export type BarChartLegendProps = { + costStart: number; + costEnd: number; + options?: Partial; +}; + +export const BarChartLegend = ({ costStart, costEnd, -}: ResourceGrowthBarChartLegendProps) => { + options = {}, + children, +}: PropsWithChildren) => { const theme = useTheme(); + const classes = useStyles(); + + const data = Object.assign( + { + previousName: 'Previous', + previousFill: theme.palette.lightBlue, + currentName: 'Current', + currentFill: theme.palette.darkBlue, + }, + options, + ); return ( - + - + {currencyFormatter.format(costStart)} - + {currencyFormatter.format(costEnd)} - - - + {children} ); }; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx index 005753fce6..145e5f805b 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx @@ -26,6 +26,7 @@ type BarChartTickProps = { value: any; }; visibleTicksCount: number; + details?: JSX.Element; }; export const BarChartTick = ({ @@ -35,11 +36,18 @@ export const BarChartTick = ({ width, payload, visibleTicksCount, + details, }: BarChartTickProps) => { const gutterWidth = 5; const labelWidth = width / visibleTicksCount - gutterWidth * 2; return ( - + {!payload.value ? 'Unlabeled' : payload.value} ); diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx similarity index 74% rename from plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx rename to plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx index 2f1f8fe7e1..b30714ca58 100644 --- a/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx @@ -16,10 +16,11 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { Tooltip } from './Tooltip'; +import { BarChartTooltip } from './BarChartTooltip'; +import { BarChartTooltipItem } from './BarChartTooltipItem'; import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; -const mockTooltipItems = [ +const items = [ { label: 'Cost', value: '$1,000,000', @@ -32,22 +33,14 @@ const mockTooltipItems = [ }, ]; -describe('', () => { - it('renders without exploding', async () => { - const rendered = await renderInTestApp( - - - , - ); - expect( - rendered.container.querySelector('.tooltip-content'), - ).toBeInTheDocument(); - }); +const tooltipItems = () => + items.map(item => ); +describe('', () => { it('formats label and tooltip item text correctly', async () => { const rendered = await renderInTestApp( - + {tooltipItems} , ); expect(rendered.getByText('05/16/2020')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.tsx new file mode 100644 index 0000000000..64a35e2658 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import { Box, Divider, Typography } from '@material-ui/core'; +import { useTooltipStyles as useStyles } from '../../utils/styles'; + +export type BarChartTooltipProps = { + title: string; + content?: ReactNode | string; + subtitle?: ReactNode; + topRight?: ReactNode; + actions?: ReactNode; +}; + +export const BarChartTooltip = ({ + title, + content, + subtitle, + topRight, + actions, + children, +}: PropsWithChildren) => { + const classes = useStyles(); + const titleClassName = classnames(classes.truncate, { + [classes.maxWidth]: topRight === undefined, + }); + + return ( + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {topRight && {topRight}} + + {content && ( + + + {content} + + + )} + + {children} + + {actions && ( + <> + + + {actions} + + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltipItem.tsx similarity index 76% rename from plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx rename to plugins/cost-insights/src/components/BarChart/BarChartTooltipItem.tsx index a084a0fd1d..153182560f 100644 --- a/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltipItem.tsx @@ -19,15 +19,18 @@ import { Box, Typography } from '@material-ui/core'; import LensIcon from '@material-ui/icons/Lens'; import { useTooltipStyles as useStyles } from '../../utils/styles'; -export type TooltipItemProps = { - value: string; - label: string; +export type TooltipItem = { fill: string; + label: string; + value: string; }; -export const TooltipItem = ({ fill, label, value }: TooltipItemProps) => { +export type BarChartTooltipItemProps = { + item: TooltipItem; +}; + +export const BarChartTooltipItem = ({ item }: BarChartTooltipItemProps) => { const classes = useStyles(); - const style = { fill: fill }; return ( { > - + - {label} + {item.label} - {value} + {item.value} ); }; diff --git a/plugins/cost-insights/src/components/BarChart/index.ts b/plugins/cost-insights/src/components/BarChart/index.ts index 4525bd24f3..73676dfa81 100644 --- a/plugins/cost-insights/src/components/BarChart/index.ts +++ b/plugins/cost-insights/src/components/BarChart/index.ts @@ -16,3 +16,15 @@ export { BarChart } from './BarChart'; export type { BarChartProps } from './BarChart'; +export { BarChartLegend } from './BarChartLegend'; +export type { + BarChartLegendProps, + BarChartLegendOptions, +} from './BarChartLegend'; +export { BarChartTooltip } from './BarChartTooltip'; +export type { BarChartTooltipProps } from './BarChartTooltip'; +export { BarChartTooltipItem } from './BarChartTooltipItem'; +export type { + TooltipItem, + BarChartTooltipItemProps, +} from './BarChartTooltipItem'; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 9c76d6d998..12668140b6 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -54,7 +54,7 @@ describe.each` it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -73,7 +73,7 @@ describe.each` it(`formats ${usd.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -92,7 +92,7 @@ describe.each` it(`formats ${carbon.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index 89e44ee185..01a0874636 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -49,7 +49,7 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => { const converted = amount / (currency.rate ?? rate); // Determine if growth is significant enough to highlight - const growth = growthOf(engineers, change.ratio); + const growth = growthOf(change.ratio, engineers); const classes = classnames({ [styles.excess]: growth === GrowthType.Excess, [styles.savings]: growth === GrowthType.Savings, diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx new file mode 100644 index 0000000000..8be5e238cc --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CostGrowthIndicator } from './CostGrowthIndicator'; +import { ChangeThreshold, EngineerThreshold } from '../../types'; + +describe.each` + ratio | amount | ariaLabel + ${-0.1} | ${undefined} | ${'savings'} + ${0.01} | ${undefined} | ${'excess'} + ${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'} + ${ChangeThreshold.upper} | ${EngineerThreshold} | ${'excess'} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${'excess'} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${'excess'} +`('growthOf', ({ ratio, amount, ariaLabel }) => { + it(`should display the correct indicator for ${ariaLabel}`, async () => { + const { getByLabelText } = await renderInTestApp( + , + ); + expect(getByLabelText(ariaLabel)).toBeInTheDocument(); + }); +}); + +describe.each` + ratio | amount + ${0} | ${undefined} + ${ChangeThreshold.lower} | ${0} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} +`('growthOf', ({ ratio, amount }) => { + it('should display the correct indicator for negligible growth', async () => { + const { queryByLabelText } = await renderInTestApp( + , + ); + expect(queryByLabelText('savings')).not.toBeInTheDocument(); + expect(queryByLabelText('excess')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx new file mode 100644 index 0000000000..e220fdb8e7 --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import classnames from 'classnames'; +import { Typography, TypographyProps } from '@material-ui/core'; +import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp'; +import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown'; +import { growthOf } from '../../utils/change'; +import { GrowthType } from '../../types'; +import { useCostGrowthStyles as useStyles } from '../../utils/styles'; + +export type CostGrowthIndicatorProps = TypographyProps & { + ratio: number; + amount?: number; + formatter?: (amount: number) => string; +}; + +export const CostGrowthIndicator = ({ + ratio, + amount, + formatter, + className, + ...props +}: CostGrowthIndicatorProps) => { + const classes = useStyles(); + const growth = growthOf(ratio, amount); + + const classNames = classnames(classes.indicator, className, { + [classes.savings]: growth === GrowthType.Savings, + [classes.excess]: growth === GrowthType.Excess, + }); + + // Display cost as a factor of engineer cost growth and percentage growth + if (typeof amount === 'number') { + return ( + + {formatter ? formatter(amount) : amount} + {growth === GrowthType.Savings && ( + + )} + {growth === GrowthType.Excess && } + + ); + } + + // Display cost as a factor of percent change + return ( + + {formatter ? formatter(ratio) : ratio} + {ratio < 0 && } + {ratio > 0 && } + + ); +}; diff --git a/plugins/cost-insights/src/components/CostGrowth/index.ts b/plugins/cost-insights/src/components/CostGrowth/index.ts index da17ae8709..569c343380 100644 --- a/plugins/cost-insights/src/components/CostGrowth/index.ts +++ b/plugins/cost-insights/src/components/CostGrowth/index.ts @@ -16,3 +16,5 @@ export { CostGrowth } from './CostGrowth'; export type { CostGrowthProps } from './CostGrowth'; +export { CostGrowthIndicator } from './CostGrowthIndicator'; +export type { CostGrowthIndicatorProps } from './CostGrowthIndicator'; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx index e2a0dd08b1..f94ab48c6f 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx @@ -46,7 +46,7 @@ const renderWrapped = (children: React.ReactNode) => describe('', () => { it('should render each navigation item', async () => { const { getByText } = await renderWrapped( - , + , ); getDefaultNavigationItems(3) .map(item => item.title) @@ -55,12 +55,16 @@ describe('', () => { }); it('should not display action items navigation if there are no action items', async () => { - const rendered = await renderWrapped(); + const rendered = await renderWrapped( + , + ); expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument(); }); it('should display the correct amount of action items in the badge', async () => { - const rendered = await renderWrapped(); + const rendered = await renderWrapped( + , + ); expect(rendered.getByText(/3/)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index 810fc767a7..a30b6c5a90 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { + Collapse, MenuList, MenuItem, ListItemIcon, @@ -23,7 +24,7 @@ import { Typography, Badge, } from '@material-ui/core'; -import { useNavigationStyles } from '../../utils/styles'; +import { useNavigationStyles as useStyles } from '../../utils/styles'; import { useConfig, useScroll } from '../../hooks'; import { findAlways } from '../../utils/assert'; import { @@ -31,55 +32,79 @@ import { NavigationItem, getDefaultNavigationItems, } from '../../utils/navigation'; +import { Maybe, Product } from '../../types'; type CostInsightsNavigationProps = { alerts: number; + products: Maybe; }; -export const CostInsightsNavigation = ({ - alerts, -}: CostInsightsNavigationProps) => { - const classes = useNavigationStyles(); - const { products, icons } = useConfig(); +export const CostInsightsNavigation = React.memo( + ({ alerts, products }: CostInsightsNavigationProps) => { + const classes = useStyles(); + const { icons } = useConfig(); + const [isOpen, setOpen] = useState(false); - const productNavigationItems: NavigationItem[] = products.map(product => ({ - navigation: product.kind, - icon: findAlways(icons, i => i.kind === product.kind).component, - title: product.name, - })); + const defaultNavigationItems = getDefaultNavigationItems(alerts); + const productNavigationItems: NavigationItem[] = + products?.map(product => ({ + title: product.name, + navigation: product.kind, + icon: findAlways(icons, i => i.kind === product.kind).component, + })) ?? []; - const navigationItems = getDefaultNavigationItems(alerts).concat( - productNavigationItems, - ); + useEffect( + function toggleProductMenuItems() { + if (products?.length) { + setOpen(true); + } else { + setOpen(false); + } + }, + [products], + ); - return ( - - {navigationItems.map((item: NavigationItem) => ( - - {React.cloneElement(item.icon, { + return ( + + {defaultNavigationItems.map(item => ( + + {React.cloneElement(item.icon, { + className: classes.navigationIcon, + })} + + ) : ( + React.cloneElement(item.icon, { className: classes.navigationIcon, - })} - - ) : ( - React.cloneElement(item.icon, { + }) + ) + } + /> + ))} + + {productNavigationItems.map((item: NavigationItem) => ( + - ))} - - ); -}; + })} + title={item.title} + /> + ))} + + + ); + }, +); const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { - const classes = useNavigationStyles(); + const classes = useStyles(); const { scrollIntoView } = useScroll(navigation); return ( { const classes = useSubtleTypographyStyles(); - const featureFlags = useApi(featureFlagsApiRef); const client = useApi(costInsightsApiRef); const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); + const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); const [alerts, setAlerts] = useState>(null); @@ -164,7 +165,7 @@ export const CostInsightsPage = () => { - + {/* */} @@ -191,27 +192,26 @@ export const CostInsightsPage = () => { const CostOverviewBanner = () => ( - + Cost Overview Billing data as of {lastCompleteBillingDate} - - {featureFlags.isActive('cost-insights-currencies') && ( - - - - )} + + + + { - + @@ -237,7 +240,7 @@ export const CostInsightsPage = () => { mb={2} > - + {/* */} @@ -283,7 +286,12 @@ export const CostInsightsPage = () => { {!alerts.length && } - + diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx new file mode 100644 index 0000000000..0b482bd908 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx @@ -0,0 +1,211 @@ +/* + * 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 dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import { useTheme, Box } from '@material-ui/core'; +import { + AreaChart, + ContentRenderer, + TooltipProps, + XAxis, + YAxis, + Tooltip as RechartsTooltip, + Area, + ResponsiveContainer, + CartesianGrid, +} from 'recharts'; +import { Cost, DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types'; +import { + BarChartTooltip as Tooltip, + BarChartTooltipItem as TooltipItem, + BarChartLegend, +} from '../BarChart'; +import { + overviewGraphTickFormatter, + formatGraphValue, + isInvalid, +} from '../../utils/graphs'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { useFilters, useLastCompleteBillingDate } from '../../hooks'; +import { mapFiltersToProps } from './selector'; +import { getPreviousPeriodTotalCost } from '../../utils/change'; +import { formatPeriod } from '../../utils/formatters'; +import { aggregationSum } from '../../utils/sum'; +import { BarChartLegendOptions } from '../BarChart/BarChartLegend'; + +dayjs.extend(utc); + +export type CostOverviewByProductChartProps = { + costsByProduct: Cost[]; +}; + +const LOW_COST_THRESHOLD = 0.1; + +export const CostOverviewByProductChart = ({ + costsByProduct, +}: CostOverviewByProductChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const { duration } = useFilters(mapFiltersToProps); + + if (!costsByProduct) { + return null; + } + + const flattenedAggregation = costsByProduct + .map(cost => cost.aggregation) + .flat(); + + const totalCost = aggregationSum(flattenedAggregation); + + const previousPeriodTotal = getPreviousPeriodTotalCost( + flattenedAggregation, + duration, + lastCompleteBillingDate, + ); + const currentPeriodTotal = totalCost - previousPeriodTotal; + const productsByDate = costsByProduct.reduce((prodByDate, product) => { + const productTotal = aggregationSum(product.aggregation); + // Group products with less than 10% of the total cost into "Other" category + // when there we have >= 5 products. + const isOtherProduct = + costsByProduct.length >= 5 && + productTotal < totalCost * LOW_COST_THRESHOLD; + const productName = isOtherProduct ? 'Other' : product.id; + const updatedProdByDate = { ...prodByDate }; + + product.aggregation.forEach(curAggregation => { + const productCostsForDate = updatedProdByDate[curAggregation.date] || {}; + + updatedProdByDate[curAggregation.date] = { + ...productCostsForDate, + [productName]: + (productCostsForDate[productName] || 0) + curAggregation.amount, + }; + }); + + return updatedProdByDate; + }, {} as Record>); + + const chartData: Record[] = Object.keys(productsByDate).map( + date => { + return { + ...productsByDate[date], + date: Date.parse(date), + }; + }, + ); + + const renderAreas = () => { + const productGroupNames = new Set( + Object.values(productsByDate) + .map(d => Object.keys(d)) + .flat(), + ); + const sortedProducts = costsByProduct + // Check that product is a separate group and hasn't been added to 'Other' + .filter( + product => product.id !== 'Other' && productGroupNames.has(product.id), + ) + .sort( + (a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation), + ) + .map(product => product.id); + // Keep 'Other' category at the bottom of the stack + return ['Other', ...sortedProducts].map((product, i) => ( + + )); + }; + + const tooltipRenderer: ContentRenderer = ({ + label, + payload = [], + }) => { + if (isInvalid({ label, payload })) return null; + + const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT); + const items = payload.map(p => ({ + label: p.dataKey as string, + value: formatGraphValue(p.value as number), + fill: p.fill!, + })); + + return ( + + {items.reverse().map((item, index) => ( + + ))} + + ); + }; + + const options: Partial = { + previousName: formatPeriod(duration, lastCompleteBillingDate, false), + currentName: formatPeriod(duration, lastCompleteBillingDate, true), + hideMarker: true, + }; + + return ( + + + + + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + /> + {renderAreas()} + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 7d55026be8..39adc4bbf1 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -14,21 +14,27 @@ * limitations under the License. */ -import React from 'react'; -import { Box, Card, CardContent, Divider, useTheme } from '@material-ui/core'; -import { CostGrowth } from '../CostGrowth'; +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + Divider, + useTheme, + Tab, + Tabs, +} from '@material-ui/core'; import { CostOverviewChart } from './CostOverviewChart'; +import { CostOverviewByProductChart } from './CostOverviewByProductChart'; import { CostOverviewHeader } from './CostOverviewHeader'; -import { LegendItem } from '../LegendItem'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; import { useScroll, useFilters, useConfig } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; -import { formatPercent } from '../../utils/formatters'; import { findAlways } from '../../utils/assert'; -import { getComparedChange } from '../../utils/change'; import { Cost, CostInsightsTheme, MetricData } from '../../types'; +import { useOverviewTabsStyles } from '../../utils/styles'; export type CostOverviewCardProps = { dailyCostData: Cost; @@ -41,6 +47,8 @@ export const CostOverviewCard = ({ }: CostOverviewCardProps) => { const theme = useTheme(); const config = useConfig(); + const [tabIndex, setTabIndex] = useState(0); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, @@ -49,56 +57,66 @@ export const CostOverviewCard = ({ const metric = filters.metric ? findAlways(config.metrics, m => m.kind === filters.metric) : null; - const comparedChange = metricData - ? getComparedChange(dailyCostData, metricData) - : null; + const styles = useOverviewTabsStyles(theme); + + const tabs = [ + { id: 'overview', label: 'Total cost', title: 'Cloud Cost' }, + { + id: 'breakdown', + label: 'Breakdown by product', + title: 'Cloud Cost By Product', + }, + ]; + + const OverviewTabs = () => { + return ( + <> + setTabIndex(index)} + value={tabIndex} + > + {tabs.map((tab, index) => ( + + ))} + + + ); + }; + + // Metrics can only be selected on the total cost graph + const showMetricSelect = config.metrics.length && tabIndex === 0; return ( - + {dailyCostData.groupedCosts && } + - - - - - {formatPercent(dailyCostData.change.ratio)} - - - {metric && metricData && comparedChange && ( - <> - - - {formatPercent(metricData.change.ratio)} - - - - - - - )} - - + + {tabIndex === 0 ? ( + + ) : ( + + )} - {config.metrics.length > 1 && ( + {showMetricSelect && ( ; @@ -84,100 +94,116 @@ export const CostOverviewChart = ({ .sort(aggregationSort) .map(entry => ({ date: Date.parse(entry.date), - trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)), + trend: trendFrom(data.dailyCost.data.trendline!, Date.parse(entry.date)), dailyCost: entry.amount, ...(metric && data.metric.data ? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] } : {}), })); - function tooltipFormatter(payload: TooltipPayload): TooltipItemProps { - return { - label: - payload.dataKey === data.dailyCost.dataKey - ? data.dailyCost.name - : data.metric.name, - value: - payload.dataKey === data.dailyCost.dataKey - ? formatGraphValue(payload.value as number, data.dailyCost.format) - : formatGraphValue(payload.value as number, data.metric.format), - fill: - payload.dataKey === data.dailyCost.dataKey - ? theme.palette.blue - : theme.palette.magenta, - }; - } + const tooltipRenderer: ContentRenderer = ({ + label, + payload = [], + }) => { + if (isInvalid({ label, payload })) return null; + + const dataKeys = [data.dailyCost.dataKey, data.metric.dataKey]; + const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT); + const items = payload + .filter(p => dataKeys.includes(p.dataKey as string)) + .map(p => ({ + label: + p.dataKey === data.dailyCost.dataKey + ? data.dailyCost.name + : data.metric.name, + value: + p.dataKey === data.dailyCost.dataKey + ? formatGraphValue(p.value as number, data.dailyCost.format) + : formatGraphValue(p.value as number, data.metric.format), + fill: + p.dataKey === data.dailyCost.dataKey + ? theme.palette.blue + : theme.palette.magenta, + })); + + return ( + + {items.map((item, index) => ( + + ))} + + ); + }; return ( - - - - - 0, 'dataMax']} - tick={{ fill: styles.axis.fill }} - tickFormatter={formatGraphValue} - width={styles.yAxis.width} - yAxisId={data.dailyCost.dataKey} - /> - {metric && ( - 0, toDataMax(data.metric.dataKey, chartData)]} - width={styles.yAxis.width} - yAxisId={data.metric.dataKey} + + + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={data.dailyCost.dataKey} + /> + {metric && ( + 0, toDataMax(data.metric.dataKey, chartData)]} + width={styles.yAxis.width} + yAxisId={data.metric.dataKey} + /> + )} + - )} - - - {metric && ( - )} - - } - animationDuration={100} - /> - - + )} + + + + ); }; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx index e46b0c3a78..b23dd2041d 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -27,7 +27,9 @@ export const CostOverviewHeader = ({ children, }: PropsWithChildren) => ( ; + metricData: Maybe; + dailyCostData: Cost; +}; + +export const CostOverviewLegend = ({ + dailyCostData, + metric, + metricData, +}: PropsWithChildren) => { + const theme = useTheme(); + + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const { duration } = useFilters(mapFiltersToProps); + + const comparedChange = metricData + ? getComparedChange( + dailyCostData, + metricData, + duration, + lastCompleteBillingDate, + ) + : null; + + return ( + + + + {formatPercent(dailyCostData.change!.ratio)} + + + {metric && metricData && comparedChange && ( + <> + + + {formatPercent(metricData.change.ratio)} + + + + + + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx deleted file mode 100644 index e0a9b9a9ea..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewTooltip.tsx +++ /dev/null @@ -1,38 +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. - */ -import React from 'react'; -import moment from 'moment'; -import { TooltipPayload, TooltipProps } from 'recharts'; -import { Tooltip, TooltipItemProps } from '../../components/Tooltip'; -import { DEFAULT_DATE_FORMAT } from '../../types'; - -export type CostOverviewTooltipProps = TooltipProps & { - dataKeys: Array; - format: (payload: TooltipPayload) => TooltipItemProps; -}; - -export const CostOverviewTooltip = ({ - label, - payload, - dataKeys, - format, -}: CostOverviewTooltipProps) => { - const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT); - const items = payload - ?.filter((p: TooltipPayload) => dataKeys.includes(p.dataKey as string)) - .map(p => format(p)); - return ; -}; diff --git a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx index 1b5920ffcb..2db1b8ee38 100644 --- a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx +++ b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx @@ -15,7 +15,13 @@ */ import React from 'react'; -import { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { + InputLabel, + FormControl, + MenuItem, + Select, + SelectProps, +} from '@material-ui/core'; import { Currency, CurrencyType } from '../../types'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; @@ -51,24 +57,28 @@ export const CurrencySelect = ({ }; return ( - + + Convert to: + + ); }; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index a3519ff61b..ae513c04e0 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -66,7 +66,6 @@ describe('', () => { describe.each` duration - ${Duration.P1M} ${Duration.P3M} ${Duration.P90D} ${Duration.P30D} @@ -74,8 +73,9 @@ describe('', () => { it(`Should select ${duration}`, async () => { const mockOnSelect = jest.fn(); const mockAggregation = + // Can't select an option that's already the default DefaultPageFilters.duration === duration - ? Duration.P1M + ? Duration.P30D : DefaultPageFilters.duration; const rendered = await renderInTestApp( @@ -89,7 +89,6 @@ describe('', () => { const button = getByRole(periodSelect, 'button'); UserEvent.click(button); - await waitFor(() => rendered.getByText('Past 60 Days')); UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); expect(mockOnSelect).toHaveBeenLastCalledWith(duration); }); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 4908641d6f..459de68921 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -17,10 +17,7 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; import { Duration } from '../../types'; -import { - formatLastTwoLookaheadQuarters, - formatLastTwoMonths, -} from '../../utils/formatters'; +import { formatLastTwoLookaheadQuarters } from '../../utils/formatters'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; import { useLastCompleteBillingDate } from '../../hooks'; @@ -42,10 +39,6 @@ export function getDefaultOptions( value: Duration.P30D, label: 'Past 60 Days', }, - { - value: Duration.P1M, - label: formatLastTwoMonths(lastCompleteBillingDate), - }, { value: Duration.P3M, label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx new file mode 100644 index 0000000000..45d0946f86 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ProductInsights } from './ProductInsights'; +import { ProductInsightsOptions } from '../../api'; +import { mockDefaultLoadingState } from '../../utils/mockData'; +import { + MockConfigProvider, + MockCostInsightsApiProvider, + MockCurrencyProvider, + MockFilterProvider, + MockBillingDateProvider, + MockScrollProvider, + MockLoadingProvider, +} from '../../utils/tests'; +import { Entity, Product } from '../../types'; + +// suppress recharts componentDidUpdate warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + +const MockComputeEngine: Product = { + kind: 'compute-engine', + name: 'Compute Engine', +}; + +const MockComputeEngineInsights: Entity = { + id: 'compute-engine', + entities: {}, + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0, + }, +}; + +const MockCloudDataflow: Product = { + kind: 'cloud-dataflow', + name: 'Cloud Dataflow', +}; + +const MockCloudDataflowInsights: Entity = { + id: MockCloudDataflow.kind, + entities: {}, + aggregation: [1_000, 2_000], + change: { + ratio: 1, + amount: 1_000, + }, +}; + +const MockCloudStorage: Product = { + kind: 'cloud-storage', + name: 'Cloud Storage', +}; + +const MockCloudStorageInsights: Entity = { + id: MockCloudStorage.kind, + entities: {}, + aggregation: [2_000, 4_000], + change: { + ratio: 1, + amount: 2_000, + }, +}; + +const MockBigQuery: Product = { + kind: 'big-query', + name: 'BigQuery', +}; + +const MockBigQueryInsights: Entity = { + id: MockBigQuery.kind, + entities: {}, + aggregation: [8_000, 16_000], + change: { + ratio: 1, + amount: 8_000, + }, +}; + +const MockBigTable: Product = { + kind: 'big-table', + name: 'BigTable', +}; + +const MockBigTableInsights: Entity = { + id: MockBigTable.kind, + entities: {}, + aggregation: [16_000, 32_000], + change: { + ratio: 1, + amount: 16_000, + }, +}; + +const MockCloudPubSub: Product = { + kind: 'cloud-pub-sub', + name: 'Cloud Pub/Sub', +}; + +const MockCloudPubSubInsights: Entity = { + id: MockCloudPubSub.kind, + entities: {}, + aggregation: [32_000, 64_000], + change: { + ratio: 1, + amount: 32_000, + }, +}; + +const ProductEntityMap = { + [MockBigQueryInsights.id!]: MockBigQueryInsights, + [MockBigTableInsights.id!]: MockBigTableInsights, + [MockCloudPubSubInsights.id!]: MockCloudPubSubInsights, + [MockCloudStorageInsights.id!]: MockCloudStorageInsights, + [MockCloudDataflowInsights.id!]: MockCloudDataflowInsights, + [MockComputeEngineInsights.id!]: MockComputeEngineInsights, +}; + +const costInsightsApi = { + getProductInsights: ({ product }: ProductInsightsOptions): Promise => + Promise.resolve(ProductEntityMap[product]), +}; + +function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + + + + + {children} + + + + + + , + ); +} + +describe('', () => { + const MockProducts: Product[] = [ + MockComputeEngine, + MockCloudDataflow, + MockCloudStorage, + MockBigQuery, + MockBigTable, + MockCloudPubSub, + ]; + + function reverse(products: Product[]): Product[] { + return products.slice().reverse(); + } + + it('should render each product panel', async () => { + const noComputeEngineCostsRgx = /There are no Compute Engine costs within this timeframe for your team's projects./; + const { getByText } = await renderInContext( + , + ); + + expect(getByText(noComputeEngineCostsRgx)).toBeInTheDocument(); + MockProducts.forEach(product => + expect(getByText(product.name)).toBeInTheDocument(), + ); + }); + + it('product panels should be sorted by total aggregated cost', async () => { + const { queryAllByTestId } = await renderInContext( + , + ); + + const productPanels = queryAllByTestId(/^product-list-item/); + const expectedOrder = reverse(MockProducts).map( + product => `product-list-item-${product.kind}`, + ); + + expect(productPanels.length).toBe(MockProducts.length); + Array.from(productPanels) + .map(el => el.getAttribute('data-testid')) + .forEach((id, i) => { + expect(id).toBe(expectedOrder[i]); + }); + }); + + it('should call onLoaded with the correct sorted order', async () => { + const mockOnLoaded = jest.fn(); + const expectedOrder = reverse(MockProducts); + + await renderInContext( + , + ); + + expect(mockOnLoaded).toHaveBeenCalledTimes(1); + expect(mockOnLoaded).toHaveBeenCalledWith(expectedOrder); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index 16146cb045..0ef35bf04c 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -14,27 +14,141 @@ * limitations under the License. */ -import React from 'react'; -import { Box, Typography, Grid } from '@material-ui/core'; -import { ProductInsightsCard } from '../ProductInsightsCard'; -import { useConfig } from '../../hooks'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Typography } from '@material-ui/core'; +import { default as Alert } from '@material-ui/lab/Alert'; +import { useApi } from '@backstage/core'; +import { costInsightsApiRef } from '../../api'; +import { ProductInsightsCardList } from '../ProductInsightsCard/ProductInsightsCardList'; +import { Duration, Entity, Maybe, Product } from '../../types'; +import { intervalsOf, DEFAULT_DURATION } from '../../utils/duration'; +import { + DefaultLoadingAction, + initialStatesOf, + settledResponseOf, + ProductState, +} from '../../utils/loading'; +import { totalAggregationSort } from '../../utils/sort'; +import { + useLoading, + useLastCompleteBillingDate, + MapLoadingToProps, +} from '../../hooks'; + +type LoadingProps = (isLoading: boolean) => void; + +const mapLoadingToProps: MapLoadingToProps = ({ dispatch }) => ( + isLoading: boolean, +) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading }); + +type ProductInsightsProps = { + group: string; + project: Maybe; + products: Product[]; + onLoaded: (entities: Product[]) => void; +}; + +export const ProductInsights = ({ + group, + project, + products, + onLoaded, +}: ProductInsightsProps) => { + const client = useApi(costInsightsApiRef); + const onceRef = useRef(false); + const [initialStates, setStates] = useState([]); + const [error, setError] = useState>(null); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const dispatchLoading = useLoading(mapLoadingToProps); + + /* eslint-disable react-hooks/exhaustive-deps */ + // See @CostInsightsPage + const dispatchLoadingProducts = useCallback(dispatchLoading, []); + /* eslint-enable react-hooks/exhaustive-deps */ + + const onSelectAsyncMemo = useCallback( + async function onSelectAsync( + product: Product, + duration: Duration, + ): Promise { + return client.getProductInsights({ + group: group, + project: project, + product: product.kind, + intervals: intervalsOf(duration, lastCompleteBillingDate), + }); + }, + [client, group, project, lastCompleteBillingDate], + ); + + useEffect(() => { + async function getAllProductInsights( + group: string, + project: Maybe, + products: Product[], + lastCompleteBillingDate: string, + ) { + try { + dispatchLoadingProducts(true); + const responses = await Promise.allSettled( + products.map(product => + client.getProductInsights({ + group: group, + project: project, + product: product.kind, + intervals: intervalsOf(DEFAULT_DURATION, lastCompleteBillingDate), + }), + ), + ).then(settledResponseOf); + + const initialStates = initialStatesOf(products, responses).sort( + totalAggregationSort, + ); + setStates(initialStates); + } catch (e) { + setError(e); + } finally { + dispatchLoadingProducts(false); + } + } + + getAllProductInsights(group, project, products, lastCompleteBillingDate); + }, [ + client, + group, + project, + products, + lastCompleteBillingDate, + dispatchLoadingProducts, + ]); + + useEffect( + function handleOnLoaded() { + if (onceRef.current) { + const products = initialStates.map(state => state.product); + onLoaded(products); + } else { + onceRef.current = true; + } + }, + [initialStates, onLoaded], + ); -export const ProductInsights = ({}) => { - const config = useConfig(); return ( - <> + Your team's product usage - - {config.products.map(product => ( - - - - ))} - - + {error ? ( + {error.message} + ) : ( + + )} + ); }; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx new file mode 100644 index 0000000000..99b6c8ae26 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ProductEntityDialog } from './ProductEntityDialog'; +import { render } from '@testing-library/react'; +import { Entity } from '../../types'; + +const atomicEntity: Entity = { + id: null, + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, +}; + +const singleBreakdownEntity = { + ...atomicEntity, + entities: { + SKU: [ + { + id: 'sku-1', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + { + id: 'sku-2', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + ] as Entity[], + }, +}; + +const multiBreakdownEntity = { + ...singleBreakdownEntity, + entities: { + ...singleBreakdownEntity.entities, + deployment: [ + { + id: 'd-1', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + { + id: 'd-2', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + ] as Entity[], + }, +}; + +describe('', () => { + it('Should error if no sub-entities exist', () => { + expect(() => + render( + wrapInTestApp( + , + ), + ), + ).toThrow(); + }); + + it('Should show a tab for a single sub-entity type', () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + expect(getByText('Breakdown by SKU')).toBeInTheDocument(); + }); + + it('Should show tabs when multiple sub-entity types exist', () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + expect(getByText('Breakdown by SKU')).toBeInTheDocument(); + expect(getByText('Breakdown by deployment')).toBeInTheDocument(); + expect(getByText('sku-1')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx new file mode 100644 index 0000000000..a98e5dc87a --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { HeaderTabs } from '@backstage/core'; +import { Dialog, IconButton } from '@material-ui/core'; +import { default as CloseButton } from '@material-ui/icons/Close'; +import { useEntityDialogStyles as useStyles } from '../../utils/styles'; +import { Entity } from '../../types'; +import { + ProductEntityTable, + ProductEntityTableOptions, +} from './ProductEntityTable'; +import { findAlways } from '../../utils/assert'; + +type ProductEntityDialogProps = { + open: boolean; + entity: Entity; + options?: ProductEntityTableOptions; + onClose: () => void; +}; + +export const ProductEntityDialog = ({ + open, + entity, + options = {}, + onClose, +}: ProductEntityDialogProps) => { + const classes = useStyles(); + const labels = Object.keys(entity.entities); + const [selectedLabel, setSelectedLabel] = useState( + findAlways(labels, _ => true), + ); + + const tabs = labels.map((label, index) => ({ + id: index.toString(), + label: `Breakdown by ${label}`, + })); + + return ( + + + + + setSelectedLabel(labels[index])} + /> + + + ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx new file mode 100644 index 0000000000..45576bda65 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import classnames from 'classnames'; +import { Table, TableColumn } from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { costFormatter, formatPercent } from '../../utils/formatters'; +import { useEntityDialogStyles as useStyles } from '../../utils/styles'; +import { CostGrowthIndicator } from '../CostGrowth'; +import { BarChartOptions, Entity } from '../../types'; + +export type ProductEntityTableOptions = Partial< + Pick +>; + +type RowData = { + id: string; + label: string; + previous: number; + current: number; + ratio: number; +}; + +function createRenderer(col: keyof RowData, classes: Record) { + return function render(rowData: {}): JSX.Element { + const row = rowData as RowData; + const rowStyles = classnames(classes.row, { + [classes.rowTotal]: row.id === 'total', + [classes.colFirst]: col === 'label', + [classes.colLast]: col === 'ratio', + }); + + switch (col) { + case 'previous': + case 'current': + return ( + + {costFormatter.format(row[col])} + + ); + case 'ratio': + return ( + formatPercent(Math.abs(amount))} + /> + ); + default: + return {row.label}; + } + }; +} + +// material-table does not support fixed rows. Override the sorting algorithm +// to force Total row to bottom by default or when a user sort toggles a column. +function createSorter(field?: keyof Omit) { + return function rowSort(data1: {}, data2: {}): number { + const a = data1 as RowData; + const b = data2 as RowData; + if (a.id === 'total') return 1; + if (b.id === 'total') return 1; + if (field === 'label') return a.label.localeCompare(b.label); + + return field + ? a[field] - b[field] + : b.previous + b.current - (a.previous + a.current); + }; +} + +type ProductEntityTableProps = { + entityLabel: string; + entity: Entity; + options: ProductEntityTableOptions; +}; + +export const ProductEntityTable = ({ + entityLabel, + entity, + options, +}: ProductEntityTableProps) => { + const classes = useStyles(); + const entities = entity.entities[entityLabel]; + + const data = Object.assign( + { + previousName: 'Previous', + currentName: 'Current', + }, + options, + ); + + const firstColClasses = classnames(classes.column, classes.colFirst); + const lastColClasses = classnames(classes.column, classes.colLast); + + const columns: TableColumn[] = [ + { + field: 'label', + title: {entityLabel}, + render: createRenderer('label', classes), + customSort: createSorter('label'), + width: '33.33%', + }, + { + field: 'previous', + title: ( + {data.previousName} + ), + align: 'right', + render: createRenderer('previous', classes), + customSort: createSorter('previous'), + }, + { + field: 'current', + title: ( + {data.currentName} + ), + align: 'right', + render: createRenderer('current', classes), + customSort: createSorter('current'), + }, + { + field: 'ratio', + title: Change, + align: 'right', + render: createRenderer('ratio', classes), + customSort: createSorter('ratio'), + }, + ]; + + const rowData: RowData[] = entities + .map(e => ({ + id: e.id || 'Unknown', + label: e.id || 'Unknown', + previous: e.aggregation[0], + current: e.aggregation[1], + ratio: e.change.ratio, + })) + .concat({ + id: 'total', + label: 'Total', + previous: entity.aggregation[0], + current: entity.aggregation[1], + ratio: entity.change.ratio, + }) + .sort(createSorter()); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index 9d1610e298..fc37bc5dd6 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -20,32 +20,29 @@ import { ProductInsightsCard } from './ProductInsightsCard'; import { CostInsightsApi } from '../../api'; import { createMockEntity, - createMockProductCost, mockDefaultLoadingState, MockComputeEngine, - MockProductFilters, } from '../../utils/mockData'; import { - MockCostInsightsApiProvider, - MockBillingDateProvider, MockConfigProvider, + MockCostInsightsApiProvider, MockCurrencyProvider, - MockFilterProvider, - MockGroupsProvider, + MockBillingDateProvider, MockScrollProvider, MockLoadingProvider, } from '../../utils/tests'; -import { Duration, Product, ProductCost, ProductPeriod } from '../../types'; +import { Duration, Entity, Product } from '../../types'; -const costInsightsApi = ( - productCost: ProductCost, -): Partial => ({ - getProductInsights: () => - Promise.resolve(productCost) as Promise, +// suppress recharts componentDidUpdate warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + +const costInsightsApi = (entity: Entity): Partial => ({ + getProductInsights: () => Promise.resolve(entity), }); -const mockProductCost = createMockProductCost(() => ({ - entities: [], +const mockProductCost = createMockEntity(() => ({ + id: 'test-id', + entities: {}, aggregation: [3000, 4000], change: { ratio: 0.23, @@ -54,31 +51,27 @@ const mockProductCost = createMockProductCost(() => ({ })); const renderProductInsightsCardInTestApp = async ( - productCost: ProductCost, + entity: Entity, product: Product, - duration: Duration, + duration = Duration.P30D, + onSelectAsync = jest.fn(() => Promise.resolve(mockProductCost)), ) => await renderInTestApp( - + - - + + - ({ - ...p, - duration: duration, - }))} - > - - - - - - + + + - - + + , ); @@ -88,7 +81,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( mockProductCost, MockComputeEngine, - Duration.P1M, + Duration.P30D, ); expect( rendered.queryByTestId(`scroll-test-compute-engine`), @@ -96,29 +89,31 @@ describe('', () => { }); it('Should render the right subheader for products with cost data', async () => { - const productCost = { + const entity = { ...mockProductCost, - entities: [...Array(1000)].map(createMockEntity), + entities: { entity: [...Array(1000)].map(createMockEntity) }, }; const rendered = await renderProductInsightsCardInTestApp( - productCost, + entity, MockComputeEngine, - Duration.P1M, ); - const subheader = 'entities, sorted by cost'; - const subheaderRgx = new RegExp( - `${productCost.entities.length} ${subheader}`, - ); - expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect( + rendered.getByText(/1000 entities, sorted by cost/), + ).toBeInTheDocument(); }); it('Should render the right subheader if there is no cost data or change data', async () => { - const productCost = { entities: [], aggregation: [0, 0] } as ProductCost; + const entity: Entity = { + id: 'test-id', + entities: {}, + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + }; const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; const rendered = await renderProductInsightsCardInTestApp( - productCost, + entity, MockComputeEngine, - Duration.P1M, + Duration.P30D, ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); @@ -138,12 +133,12 @@ describe('', () => { 'Should display the correct relative time', ({ duration, periodStartText, periodEndText }) => { it(`Should display the correct relative time for ${duration}`, async () => { - const productCost = { + const entity = { ...mockProductCost, - entities: [...Array(3)].map(createMockEntity), + entities: { entity: [...Array(3)].map(createMockEntity) }, }; const rendered = await renderProductInsightsCardInTestApp( - productCost, + entity, MockComputeEngine, duration, ); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index b78053ab5b..534ef490c3 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -14,154 +14,122 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { InfoCard, useApi } from '@backstage/core'; -import { Box, Typography } from '@material-ui/core'; -import Alert from '@material-ui/lab/Alert'; -import { costInsightsApiRef } from '../../api'; +import React, { + PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import pluralize from 'pluralize'; +import { InfoCard } from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { default as Alert } from '@material-ui/lab/Alert'; import { PeriodSelect } from '../PeriodSelect'; -import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart'; -import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend'; +import { ProductInsightsChart } from './ProductInsightsChart'; +import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; +import { DefaultLoadingAction } from '../../utils/loading'; +import { Duration, Entity, Maybe, Product } from '../../types'; import { - useFilters, + MapLoadingToProps, useLastCompleteBillingDate, useLoading, useScroll, } from '../../hooks'; -import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; -import { mapFiltersToProps, mapLoadingToProps } from './selector'; -import { Duration, Maybe, Product, ProductCost } from '../../types'; -import { pluralOf } from '../../utils/grammar'; -import { formatPeriod } from '../../utils/formatters'; +import { findAnyKey } from '../../utils/assert'; -type ProductInsightsCardProps = { +type LoadingProps = (isLoading: boolean) => void; + +export type ProductInsightsCardProps = { product: Product; + initialState: { + entity: Maybe; + duration: Duration; + }; + onSelectAsync: (product: Product, duration: Duration) => Promise; }; -export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { - const client = useApi(costInsightsApiRef); +const mapLoadingToProps: MapLoadingToProps = ({ dispatch }) => ( + isLoading: boolean, +) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading }); + +export const ProductInsightsCard = ({ + initialState, + product, + onSelectAsync, +}: PropsWithChildren) => { const classes = useStyles(); + const mountedRef = useRef(false); const { ScrollAnchor } = useScroll(product.kind); - const lastCompleteBillingDate = useLastCompleteBillingDate(); - const [resource, setResource] = useState>(null); const [error, setError] = useState>(null); + const dispatchLoading = useLoading(mapLoadingToProps); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [entity, setEntity] = useState>(initialState.entity); + const [duration, setDuration] = useState(initialState.duration); - const { group, product: productFilter, setProduct, project } = useFilters( - mapFiltersToProps(product.kind), - ); - const { loadingProduct, dispatchLoading } = useLoading( - mapLoadingToProps(product.kind), - ); - - // @see CostInsightsPage - // eslint-disable-next-line react-hooks/exhaustive-deps - const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); - - const amount = resource?.entities?.length || 0; - const hasCostsWithinTimeframe = !!(resource?.change && amount); - - const previousName = formatPeriod( - productFilter.duration, - lastCompleteBillingDate, - false, - ); - const currentName = formatPeriod( - productFilter.duration, - lastCompleteBillingDate, - true, - ); - - const subheader = hasCostsWithinTimeframe - ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` - : null; - - const costStart = resource?.aggregation[0] || 0; - const costEnd = resource?.aggregation[1] || 0; + /* eslint-disable react-hooks/exhaustive-deps */ + const dispatchLoadingProduct = useCallback(dispatchLoading, []); + /* eslint-enable react-hooks/exhaustive-deps */ useEffect(() => { - async function load() { - if (loadingProduct) { - try { - const p: ProductCost = await client.getProductInsights({ - product: product.kind, - group: group!, - duration: productFilter!.duration, - lastCompleteBillingDate, - project, - }); - setResource(p); - } catch (e) { - setError(e); - } finally { - dispatchLoadingProduct(false); - } + async function handleOnSelectAsync() { + dispatchLoadingProduct(true); + try { + const e = await onSelectAsync(product, duration); + setEntity(e); + } catch (e) { + setEntity(null); + setError(e); + } finally { + dispatchLoadingProduct(false); } } - load(); - }, [ - client, - product, - setResource, - loadingProduct, - dispatchLoadingProduct, - productFilter, - group, - product.kind, - project, - lastCompleteBillingDate, - ]); - const onPeriodSelect = (duration: Duration) => { - dispatchLoadingProduct(true); - setProduct(duration); + if (mountedRef.current) { + handleOnSelectAsync(); + } else { + mountedRef.current = true; + } + }, [product, duration, onSelectAsync, dispatchLoadingProduct]); + + // Only a single entities Record for the root product entity is supported + const entityKey = findAnyKey(entity?.entities); + const entities = entityKey ? entity!.entities[entityKey] : []; + + const subheader = entityKey + ? `${pluralize(entityKey, entities.length, true)}, sorted by cost` + : null; + const headerProps = { + classes: classes, + action: , }; - const infoCardProps = { - headerProps: { - classes: classes, - action: ( - - ), - }, - }; - - if (error) { + if (error || !entity) { return ( - + - {`Error: Could not fetch product insights for ${product.name}`} + + {error + ? error.message + : `Error: Could not fetch product insights for ${product.name}`} + ); } - if (!resource) { - return null; - } - return ( - + - {hasCostsWithinTimeframe ? ( - - - - - - + {entities.length ? ( + ) : ( There are no {product.name} costs within this timeframe for your diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx new file mode 100644 index 0000000000..2dfd5bfc75 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCardList.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, CircularProgress, Collapse } from '@material-ui/core'; +import { ProductInsightsCard } from './ProductInsightsCard'; +import { Duration, Entity, Product } from '../../types'; +import { ProductState } from '../../utils/loading'; + +type ProductInsightsCardListProps = { + initialStates: ProductState[]; + onSelectAsync: (product: Product, duration: Duration) => Promise; +}; + +export const ProductInsightsCardList = ({ + initialStates, + onSelectAsync, +}: ProductInsightsCardListProps) => { + if (!initialStates.length) { + return ( + + + + ); + } + + return ( + + {initialStates.map(({ product, entity, duration }) => ( + + + + ))} + + ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx new file mode 100644 index 0000000000..ace2766277 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -0,0 +1,221 @@ +/* + * 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, { useMemo, useState } from 'react'; +import { + ContentRenderer, + TooltipProps as RechartsTooltipProps, + RechartsFunction, +} from 'recharts'; +import pluralize from 'pluralize'; +import { Box, Typography } from '@material-ui/core'; +import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen'; +import { LegendItem } from '../LegendItem'; +import { ProductEntityDialog } from './ProductEntityDialog'; +import { CostGrowth, CostGrowthIndicator } from '../CostGrowth'; +import { + BarChart, + BarChartLegend, + BarChartTooltip, + BarChartTooltipItem, + BarChartLegendOptions, +} from '../BarChart'; +import { + findAlways, + notEmpty, + isUndefined, + findAnyKey, + assertAlways, +} from '../../utils/assert'; +import { formatPeriod, formatPercent } from '../../utils/formatters'; +import { + titleOf, + tooltipItemOf, + resourceOf, + isInvalid, + isLabeled, + isUnlabeled, +} from '../../utils/graphs'; +import { + useProductInsightsChartStyles as useStyles, + useBarChartLayoutStyles as useLayoutStyles, +} from '../../utils/styles'; +import { Duration, Entity, Maybe } from '../../types'; + +export type ProductInsightsChartProps = { + billingDate: string; + entity: Entity; + duration: Duration; +}; + +export const ProductInsightsChart = ({ + billingDate, + entity, + duration, +}: ProductInsightsChartProps) => { + const classes = useStyles(); + const layoutClasses = useLayoutStyles(); + + // Only a single entities Record for the root product entity is supported + const entities = useMemo(() => { + const entityLabel = assertAlways(findAnyKey(entity.entities)); + return entity.entities[entityLabel] ?? []; + }, [entity]); + + const [activeLabel, setActive] = useState>(); + const [selectLabel, setSelected] = useState>(); + const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]); + + const isClickable = useMemo(() => { + const breakdowns = Object.keys( + entities.find(e => e.id === activeLabel)?.entities ?? {}, + ); + return breakdowns.length > 0; + }, [entities, activeLabel]); + + const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`; + const costStart = entity.aggregation[0]; + const costEnd = entity.aggregation[1]; + const resources = entities.map(resourceOf); + + const options: Partial = { + previousName: formatPeriod(duration, billingDate, false), + currentName: formatPeriod(duration, billingDate, true), + }; + + const onMouseMove: RechartsFunction = ( + data: Record<'activeLabel', string | undefined>, + ) => { + if (isLabeled(data)) { + setActive(data.activeLabel!); + } else if (isUnlabeled(data)) { + setActive(null); + } else { + setActive(undefined); + } + }; + + const onClick: RechartsFunction = (data: Record<'activeLabel', string>) => { + if (isLabeled(data)) { + setSelected(data.activeLabel); + } else if (isUnlabeled(data)) { + setSelected(null); + } else { + setSelected(undefined); + } + }; + + const renderProductInsightsTooltip: ContentRenderer = ({ + label, + payload = [], + }) => { + /* Labels and payloads may be undefined or empty */ + if (isInvalid({ label, payload })) return null; + + /* + * recharts coerces null values to strings + * entity -> resource -> payload + * { id: null } -> { name: null } -> { label: '' } + */ + const id = label === '' ? null : label; + + const title = titleOf(label); + const items = payload.map(tooltipItemOf).filter(notEmpty); + + const activeEntity = findAlways(entities, e => e.id === id); + const ratio = activeEntity.change.ratio; + const breakdowns = Object.keys(activeEntity.entities); + + if (breakdowns.length) { + const subtitle = breakdowns + .map(b => pluralize(b, activeEntity.entities[b].length, true)) + .join(', '); + return ( + + } + actions={ + + + Click for breakdown + + } + > + {items.map((item, index) => ( + + ))} + + ); + } + + // If an entity doesn't have any sub-entities, there aren't any costs to break down. + return ( + + } + content={ + id + ? null + : "This product has costs that are not labeled and therefore can't be attributed to a specific entity." + } + > + {items.map((item, index) => ( + + ))} + + ); + }; + + const barChartProps = isClickable ? { onClick } : {}; + + return ( + + + + + + + + {isSelected && entities.length && ( + setSelected(undefined)} + entity={findAlways(entities, e => e.id === selectLabel)} + options={options} + /> + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts index 61222f9466..33a402234c 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts +++ b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts @@ -15,3 +15,4 @@ */ export { ProductInsightsCard } from './ProductInsightsCard'; +export { ProductInsightsChart } from './ProductInsightsChart'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index db3f2f9bf6..0e24f042bc 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -27,6 +27,9 @@ import { AlertCost } from '../../types'; import { defaultCurrencies } from '../../utils/currency'; import { findAlways } from '../../utils/assert'; +// suppress recharts componentDidUpdate deprecation warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + const engineers = findAlways(defaultCurrencies, c => c.kind === null); const MockProject = 'test-project-1'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index 4311bcc1bf..a3df76088c 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -15,52 +15,27 @@ */ import React from 'react'; -import moment from 'moment'; -import { Box } from '@material-ui/core'; +import pluralize from 'pluralize'; import { InfoCard } from '@backstage/core'; -import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart'; -import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend'; -import { Duration, ProjectGrowthData } from '../../types'; -import { pluralOf } from '../../utils/grammar'; +import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart'; +import { ProjectGrowthData } from '../../types'; type ProjectGrowthAlertProps = { alert: ProjectGrowthData; }; export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { - const [costStart, costEnd] = alert.aggregation; - const subheader = ` - ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + ${pluralize('product', alert.products.length, true)}${ alert.products.length > 1 ? ', sorted by cost' : '' }`; - const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format( - '[Q]Q YYYY', - ); - const currentName = moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'); return ( - - - - - - + ); }; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx new file mode 100644 index 0000000000..f541719e4a --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import moment from 'moment'; +import { Box } from '@material-ui/core'; +import { BarChart, BarChartLegend, BarChartLegendOptions } from '../BarChart'; +import { LegendItem } from '../LegendItem'; +import { CostGrowth } from '../CostGrowth'; +import { Duration, ProjectGrowthData } from '../../types'; +import { useBarChartLayoutStyles as useStyles } from '../../utils/styles'; +import { resourceOf } from '../../utils/graphs'; + +type ProjectGrowthAlertChartProps = { + alert: ProjectGrowthData; +}; + +export const ProjectGrowthAlertChart = ({ + alert, +}: ProjectGrowthAlertChartProps) => { + const classes = useStyles(); + + const costStart = alert.aggregation[0]; + const costEnd = alert.aggregation[1]; + const resourceData = alert.products.map(resourceOf); + + const options: Partial = { + previousName: moment(alert.periodStart, 'YYYY-[Q]Q').format('[Q]Q YYYY'), + currentName: moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'), + }; + + return ( + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index f2083388ae..4fcc34c87f 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -15,19 +15,22 @@ */ import React from 'react'; +import moment from 'moment'; import { Box, Typography } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; +import { ProductInsightsChart } from '../ProductInsightsCard'; import { Alert, + DEFAULT_DATE_FORMAT, Duration, Entity, Product, ProjectGrowthData, } from '../../types'; import { ProjectGrowthAlert } from '../../utils/alerts'; -import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend'; -import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart'; + +const today = moment().format(DEFAULT_DATE_FORMAT); export const ProjectGrowthInstructionsPage = () => { const alertData: ProjectGrowthData = { @@ -54,6 +57,7 @@ export const ProjectGrowthInstructionsPage = () => { }, ], }; + const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData); const product: Product = { @@ -61,20 +65,36 @@ export const ProjectGrowthInstructionsPage = () => { name: 'Compute Engine', }; - const entities: Entity[] = [ - { - id: 'service-one', - aggregation: [18200, 58500], + const entity: Entity = { + id: 'example-id', + aggregation: [20_000, 60_000], + change: { + ratio: 3, + amount: 40_000, }, - { - id: 'service-two', - aggregation: [1200, 1300], + entities: { + service: [ + { + id: 'service-one', + aggregation: [18_200, 58_500], + entities: {}, + change: { ratio: 2.21, amount: 40_300 }, + }, + { + id: 'service-two', + aggregation: [1200, 1300], + entities: {}, + change: { ratio: 0.083, amount: 100 }, + }, + { + id: 'service-three', + aggregation: [600, 200], + entities: {}, + change: { ratio: -0.666, amount: -400 }, + }, + ], }, - { - id: 'service-three', - aggregation: [600, 200], - }, - ]; + }; return ( @@ -153,27 +173,12 @@ export const ProjectGrowthInstructionsPage = () => { ) that has grown in cost: - {/* ProductInsightsCard without API query / PeriodSelect */} - - - - - - - - + diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index ee4641a4eb..bc34eb9c8d 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -31,7 +31,7 @@ export const ProjectSelect = ({ }: ProjectSelectProps) => { const classes = useStyles(); - const projectOptions = [{ id: 'all' } as Project, ...projects] + const projectOptions = projects .filter(p => p.id) .sort((a, b) => (a.id as string).localeCompare(b.id as string)); @@ -57,7 +57,7 @@ export const ProjectSelect = ({ onChange={handleOnChange} data-testid="project-filter-select" > - {projectOptions.map(proj => ( + {[{ id: 'all' }, ...projectOptions].map(proj => ( - createMockEntity(() => ({ - id: `test-id-${index + 1}`, - // grow resource costs linearly for testing - aggregation: [index * 1000, (index + 1) * 1000], - })), -); - -describe('', () => { - it('Pre-renders without exploding', async () => { - const rendered = await renderInTestApp( - , - ); - expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); - }); -}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx deleted file mode 100644 index b4014bf00f..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx +++ /dev/null @@ -1,87 +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. - */ - -import React from 'react'; -import { TooltipPayload } from 'recharts'; -import { currencyFormatter } from '../../utils/formatters'; -import { - AlertCost, - BarChartData, - CostInsightsTheme, - DataKey, - Entity, - Maybe, - ResourceData, -} from '../../types'; -import { BarChart } from '../BarChart'; -import { TooltipItemProps } from '../Tooltip'; -import { useTheme } from '@material-ui/core'; - -export type ResourceGrowthBarChartProps = { - resources: Array; - previousName: string; - currentName: string; -}; - -export const ResourceGrowthBarChart = ({ - resources, - previousName, - currentName, -}: ResourceGrowthBarChartProps) => { - const theme = useTheme(); - const getTooltipItem = (payload: TooltipPayload): Maybe => { - const value = - typeof payload.value === 'number' - ? currencyFormatter.format(payload.value) - : (payload.value as string); - const fill = payload.fill as string; - - switch (payload.dataKey) { - case DataKey.Current: - case DataKey.Previous: - return { - label: payload.name, - value: value, - fill: fill, - }; - default: - return null; - } - }; - - const barChartData: BarChartData = { - previousFill: theme.palette.lightBlue, - currentFill: theme.palette.darkBlue, - previousName: previousName, - currentName: currentName, - }; - - const resourceData: ResourceData[] = resources.map(resource => { - return { - name: resource.id, - previous: resource.aggregation[0], - current: resource.aggregation[1], - }; - }); - - return ( - - ); -}; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx deleted file mode 100644 index 47285a2363..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx +++ /dev/null @@ -1,59 +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. - */ - -import React, { PropsWithChildren } from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend'; -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); - -const MockContext = ({ children }: PropsWithChildren<{}>) => ( - - {children} - -); - -describe('', () => { - describe.each` - ratio | amount | costText | engineerTest - ${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/} - ${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/} - `( - 'Should display the cost text', - ({ ratio, amount, costText, engineerTest }) => { - it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => { - const rendered = await renderInTestApp( - - - , - ); - expect(rendered.getByText(costText)).toBeInTheDocument(); - expect(rendered.queryByText(engineerTest)).toBeInTheDocument(); - }); - }, - ); -}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts deleted file mode 100644 index ee77a483e6..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts +++ /dev/null @@ -1,17 +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 { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend'; diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx deleted file mode 100644 index e0ee93c669..0000000000 --- a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx +++ /dev/null @@ -1,52 +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. - */ - -import React from 'react'; -import { Box, Typography } from '@material-ui/core'; -import { TooltipItem, TooltipItemProps } from './TooltipItem'; -import { useTooltipStyles } from '../../utils/styles'; - -export type TooltipProps = { - label?: string; - items?: Array; -}; - -export const Tooltip = ({ label, items }: TooltipProps) => { - const classes = useTooltipStyles(); - return ( - - {label && ( - - {label} - - )} - {items && - items.map((item, index) => ( - - ))} - - ); -}; diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/plugins/cost-insights/src/components/Tooltip/index.ts deleted file mode 100644 index b26409480e..0000000000 --- a/plugins/cost-insights/src/components/Tooltip/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 { Tooltip } from './Tooltip'; -export type { TooltipProps } from './Tooltip'; -export { TooltipItem } from './TooltipItem'; -export type { TooltipItemProps } from './TooltipItem'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx index 8bc5ebfb6e..f5715bdb0d 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; -import { Box } from '@material-ui/core'; +import pluralize from 'pluralize'; import { InfoCard } from '@backstage/core'; -import { UnlabeledDataflowBarChart } from './UnlabeledDataflowBarChart'; -import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend'; -import { UnlabeledDataflowData } from '../../types'; -import { pluralOf } from '../../utils/grammar'; +import { Box } from '@material-ui/core'; +import { BarChart, BarChartLegend } from '../BarChart'; +import { UnlabeledDataflowData, ResourceData } from '../../types'; +import { useBarChartLayoutStyles as useStyles } from '../../utils/styles'; type UnlabeledDataflowAlertProps = { alert: UnlabeledDataflowData; @@ -29,22 +29,31 @@ type UnlabeledDataflowAlertProps = { export const UnlabeledDataflowAlertCard = ({ alert, }: UnlabeledDataflowAlertProps) => { - const projects = pluralOf(alert.projects.length, 'project'); + const classes = useStyles(); + const projects = pluralize('project', alert.projects.length, true); const subheader = ` - Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days. + Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days. `; + const options = { + previousName: 'Unlabeled Cost', + currentName: 'Labeled Cost', + }; + + const resources: ResourceData[] = alert.projects.map(project => ({ + name: project.id, + previous: project.unlabeledCost, + current: project.labeledCost, + })); + return ( - - - - - - - + + + ); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChart.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChart.tsx deleted file mode 100644 index faf27ba04f..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChart.tsx +++ /dev/null @@ -1,71 +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. - */ - -import React from 'react'; -import { TooltipPayload } from 'recharts'; -import { BarChart } from '../BarChart'; -import { TooltipItemProps } from '../Tooltip'; -import { - BarChartData, - CostInsightsTheme, - ResourceData, - UnlabeledDataflowAlertProject, -} from '../../types'; -import { currencyFormatter } from '../../utils/formatters'; -import { useTheme } from '@material-ui/core'; - -type UnlabeledDataflowBarChartProps = { - projects: Array; -}; - -export const UnlabeledDataflowBarChart = ({ - projects, -}: UnlabeledDataflowBarChartProps) => { - const theme = useTheme(); - const barChartData: BarChartData = { - previousFill: theme.palette.lightBlue, - currentFill: theme.palette.darkBlue, - previousName: 'Unlabeled Cost', - currentName: 'Labeled Cost', - }; - - const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => { - return { - label: payload.name, - value: - typeof payload.value === 'number' - ? currencyFormatter.format(payload.value) - : (payload.value as string), - fill: payload.fill as string, - }; - }; - - const resources: ResourceData[] = projects.map(project => { - return { - name: project.id, - previous: project.unlabeledCost || 0, - current: project.labeledCost || 0, - }; - }); - - return ( - - ); -}; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.tsx deleted file mode 100644 index aac37c799d..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowBarChartLegend.tsx +++ /dev/null @@ -1,57 +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. - */ - -import React from 'react'; -import { Box, useTheme } from '@material-ui/core'; -import { LegendItem } from '../LegendItem'; -import { currencyFormatter } from '../../utils/formatters'; -import { CostInsightsTheme } from '../../types'; - -type UnlabeledDataflowBarChartLegendProps = { - labeledCost: number; - unlabeledCost: number; -}; - -export const UnlabeledDataflowBarChartLegend = ({ - unlabeledCost, - labeledCost, -}: UnlabeledDataflowBarChartLegendProps) => { - const theme = useTheme(); - return ( - - - - {currencyFormatter.format(unlabeledCost)} - - - - - {currencyFormatter.format(labeledCost)} - - - - ); -}; diff --git a/plugins/cost-insights/src/components/index.ts b/plugins/cost-insights/src/components/index.ts index 5b918144d9..c390666419 100644 --- a/plugins/cost-insights/src/components/index.ts +++ b/plugins/cost-insights/src/components/index.ts @@ -17,4 +17,3 @@ export * from './BarChart'; export * from './CostGrowth'; export * from './LegendItem'; -export * from './Tooltip'; diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx index 420ac7ed56..07f8a0f4e7 100644 --- a/plugins/cost-insights/src/hooks/useLoading.tsx +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -21,7 +21,6 @@ import React, { SetStateAction, useContext, useEffect, - useMemo, useReducer, useState, } from 'react'; @@ -30,10 +29,9 @@ import { Loading } from '../types'; import { DefaultLoadingAction, getDefaultState, - getLoadingActions, + INITIAL_LOADING_ACTIONS, } from '../utils/loading'; import { useBackdropStyles as useStyles } from '../utils/styles'; -import { useConfig } from './useConfig'; export type LoadingContextProps = { state: Loading; @@ -56,10 +54,7 @@ function reducer(prevState: Loading, action: Partial): Loading { export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => { const classes = useStyles(); - const { products } = useConfig(); - const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ - products, - ]); + const actions = INITIAL_LOADING_ACTIONS; const [state, dispatch] = useReducer(reducer, getDefaultState(actions)); const [isBackdropVisible, setBackdropVisible] = useState(false); diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 28bdb1a8b3..7cd0456ad1 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -21,5 +21,4 @@ export * from './components'; export { useCurrency } from './hooks'; export * from './types'; export * from './utils/tests'; -export * from './utils/duration'; export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index d6046e3241..c5c0463d99 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -41,13 +41,16 @@ export interface ResourceData { name: Maybe; } -export interface BarChartData { +export interface BarChartOptions { previousFill: string; currentFill: string; previousName: string; currentName: string; } +/** deprecated use BarChartOptions instead */ +export interface BarChartData extends BarChartOptions {} + export enum DataKey { Previous = 'previous', Current = 'current', diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index 84a8bfae51..dd28bba6b0 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -21,6 +21,7 @@ import { Trendline } from './Trendline'; export interface Cost { id: string; aggregation: DateAggregation[]; - change: ChangeStatistic; - trendline: Trendline; + change?: ChangeStatistic; + trendline?: Trendline; + groupedCosts?: Cost[]; } diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index acf707dd1e..c0f03d5c27 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -15,15 +15,14 @@ */ /** - * 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 - + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P3M to mean + * 'last completed quarter', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P3M represents costs for Q2 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/Entity.ts b/plugins/cost-insights/src/types/Entity.ts index f5ac620289..b49bb596ae 100644 --- a/plugins/cost-insights/src/types/Entity.ts +++ b/plugins/cost-insights/src/types/Entity.ts @@ -20,5 +20,99 @@ import { Maybe } from './Maybe'; export interface Entity { id: Maybe; aggregation: [number, number]; - change?: Maybe; + entities: Record; + change: ChangeStatistic; } + +/* + An entity is a tree-like structure that represents any unique + product or service that generates cost over a fixed period of time. + An entity could be atomic or composite. An atomic entity is indivisible + and cannot be broken into sub-entities. + + A composite entity is divided into sub-entities that account for portions + of the total cost **over the same time period**. The root entity is + expected to only have _one_ Record consisting of the sub-entities to display + in the product panel (keyed by the entity type, such as "service" for + compute entities). + + The root sub-entities may have multiple breakdowns - for example, a + breakdown of an entity cost by SKU vs deployment environment. The sum + aggregated cost of each keyed breakdown should equal the sub-entity's cost. + + Entities with null ids are considered "unlabeled" - costs without attribution. + If an entity is a composite, it may only have one (1) null child but may have any number of + null grandchildren. + + { + id: 'product', + aggregation: [0, 200], + change: { + ratio: 2000, + amount: 200 + }, + entities: { + service: [ + { + id: 'service-a', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 + }, + entities: {} + }, + { + id: 'service-b', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 + }, + entities: { + SKU: [ + { + id: 'service-b-sku-a', + aggregation: [0, 25], + change: { + ratio: 25, + amount: 25 + }, + entities: {} + }, + { + id: null, // Unlabeled cost for service-b + aggregation: [0, 75], + change: { + ratio: 75, + amount: 75 + }, + entities: {} + }, + ], + deployment: [ + { + id: 'service-b-env-a', + aggregation: [0, 50], + change: { + ratio: 50, + amount: 50 + }, + entities: {} + }, + { + id: 'service-b-env-b', + aggregation: [0, 50], + change: { + ratio: 50, + amount: 50 + }, + entities: {} + }, + ] + } + }, + ] + } + } +*/ diff --git a/plugins/cost-insights/src/types/Product.ts b/plugins/cost-insights/src/types/Product.ts index d2087644cb..77df579a11 100644 --- a/plugins/cost-insights/src/types/Product.ts +++ b/plugins/cost-insights/src/types/Product.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -import { Entity } from './Entity'; -import { ChangeStatistic } from './ChangeStatistic'; -import { Maybe } from './Maybe'; - export interface Product { kind: string; name: string; } - -export interface ProductCost { - entities?: Array; - aggregation: [number, number]; - change?: Maybe; -} diff --git a/plugins/cost-insights/src/types/Theme.ts b/plugins/cost-insights/src/types/Theme.ts index 9053283351..0f4096adc3 100644 --- a/plugins/cost-insights/src/types/Theme.ts +++ b/plugins/cost-insights/src/types/Theme.ts @@ -30,6 +30,7 @@ type CostInsightsPaletteAdditions = { tooltip: CostInsightsTooltipOptions; navigationText: string; alertBackground: string; + dataViz: string[]; }; export type CostInsightsPalette = BackstagePalette & diff --git a/plugins/cost-insights/src/utils/assert.ts b/plugins/cost-insights/src/utils/assert.ts index 8f7caef0fa..05ce65197b 100644 --- a/plugins/cost-insights/src/utils/assert.ts +++ b/plugins/cost-insights/src/utils/assert.ts @@ -17,7 +17,15 @@ export function notEmpty( value: TValue | null | undefined, ): value is TValue { - return value !== null && value !== undefined; + return !isNull(value) && !isUndefined(value); +} + +export function isUndefined(value: any): boolean { + return value === undefined; +} + +export function isNull(value: any): boolean { + return value === null; } // Utility for exhaustiveness checking in switch statements @@ -42,3 +50,9 @@ export function findAlways( ): T { return assertAlways(collection.find(callback)); } + +export function findAnyKey( + record: Record | undefined, +): string | undefined { + return Object.keys(record ?? {}).find(_ => true); +} diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts new file mode 100644 index 0000000000..7cc03caa0b --- /dev/null +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { growthOf, getPreviousPeriodTotalCost } from './change'; +import { + GrowthType, + ChangeThreshold, + EngineerThreshold, + Duration, + Cost, +} from '../types'; +import { MockAggregatedDailyCosts, trendlineOf, changeOf } from './mockData'; + +const GrowthMap = { + [GrowthType.Negligible]: 'negligible growth', + [GrowthType.Savings]: 'cost savings', + [GrowthType.Excess]: 'cost excess', +}; + +describe.each` + ratio | amount | expected + ${0.0} | ${undefined} | ${GrowthType.Negligible} + ${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible} + ${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings} + ${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings} + ${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess} + ${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess} +`( + 'growthOf', + ({ + ratio, + amount, + expected, + }: { + ratio: number; + amount: number; + expected: GrowthType; + }) => { + it(`should display ${GrowthMap[expected]}`, () => { + expect(growthOf(ratio, amount)).toBe(expected); + }); + }, +); + +describe('getPreviousPeriodTotalCost', () => { + it('Correctly returns the total cost for the previous period given daily costs', () => { + const mockGroupDailyCost: Cost = { + id: 'test-group', + aggregation: MockAggregatedDailyCosts, + change: changeOf(MockAggregatedDailyCosts), + trendline: trendlineOf(MockAggregatedDailyCosts), + }; + const exclusiveEndDate = '2020-09-30'; + expect( + getPreviousPeriodTotalCost( + mockGroupDailyCost.aggregation, + Duration.P30D, + exclusiveEndDate, + ), + ).toEqual(100_000); + }); +}); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index 1fe3662537..fc50439aad 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -21,17 +21,28 @@ import { EngineerThreshold, GrowthType, MetricData, + Duration, + DEFAULT_DATE_FORMAT, + DateAggregation, } from '../types'; -import { aggregationSort } from '../utils/sort'; +import dayjs, { OpUnitType } from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { inclusiveStartDateOf } from './duration'; -// Used by for displaying status colors -export function growthOf(amount: number, ratio: number) { - if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { - return GrowthType.Excess; - } +dayjs.extend(duration); - if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { - return GrowthType.Savings; +// Used for displaying status colors +export function growthOf(ratio: number, amount?: number) { + if (typeof amount === 'number') { + if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { + return GrowthType.Excess; + } + if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { + return GrowthType.Savings; + } + } else { + if (ratio >= ChangeThreshold.upper) return GrowthType.Excess; + if (ratio <= ChangeThreshold.lower) return GrowthType.Savings; } return GrowthType.Negligible; @@ -41,11 +52,41 @@ export function growthOf(amount: number, ratio: number) { export function getComparedChange( dailyCost: Cost, metricData: MetricData, + duration: Duration, + lastCompleteBillingDate: string, // YYYY-MM-DD, ): ChangeStatistic { - const ratio = dailyCost.change.ratio - metricData.change.ratio; - const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount; + const ratio = dailyCost.change!.ratio - metricData.change.ratio; + const previousPeriodTotal = getPreviousPeriodTotalCost( + dailyCost.aggregation, + duration, + lastCompleteBillingDate, + ); return { ratio: ratio, - amount: amount * ratio, + amount: previousPeriodTotal * ratio, }; } + +export function getPreviousPeriodTotalCost( + aggregation: DateAggregation[], + duration: Duration, + inclusiveEndDate: string, +): number { + const dayjsDuration = dayjs.duration(duration); + const startDate = inclusiveStartDateOf( + duration, + dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT), + ); + // dayjs doesn't allow adding an ISO 8601 period to dates. + const [amount, type]: [number, OpUnitType] = dayjsDuration.days() + ? [dayjsDuration.days(), 'day'] + : [dayjsDuration.months(), 'month']; + const nextPeriodStart = dayjs(startDate).add(amount, type); + + // Add up costs that incurred before the start of the next period. + return aggregation.reduce((acc, costByDate) => { + return dayjs(costByDate.date).isBefore(nextPeriodStart) + ? acc + costByDate.amount + : acc; + }, 0); +} diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index 663a29a112..f1d67a14e4 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -18,7 +18,6 @@ import { assertNever } from '../utils/assert'; export const rateOf = (cost: number, duration: Duration) => { switch (duration) { - case Duration.P1M: case Duration.P30D: return cost / 12; case Duration.P90D: diff --git a/plugins/cost-insights/src/utils/duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts index a5509eda07..47769a45f1 100644 --- a/plugins/cost-insights/src/utils/duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -15,7 +15,11 @@ */ import { Duration } from '../types'; -import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; +import { + inclusiveEndDateOf, + inclusiveStartDateOf, + quarterEndDate, +} from './duration'; const lastCompleteBillingDate = '2020-06-05'; @@ -23,7 +27,6 @@ describe.each` duration | startDate | endDate ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} - ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { @@ -33,3 +36,14 @@ describe.each` expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); + +describe.each` + inclusiveEndDate | expectedQuarterEndDate + ${'2020-12-31'} | ${'2020-12-31'} + ${'2020-12-30'} | ${'2020-09-30'} + ${'2021-02-19'} | ${'2020-12-31'} +`('quarterEndDate', ({ inclusiveEndDate, expectedQuarterEndDate }) => { + it(`calculates quarter end date correctly from inclusive end date ${inclusiveEndDate}`, () => { + expect(quarterEndDate(inclusiveEndDate)).toBe(expectedQuarterEndDate); + }); +}); diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 6a880b382c..7a330b6f91 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -18,31 +18,27 @@ import moment from 'moment'; import { Duration, DEFAULT_DATE_FORMAT } from '../types'; import { assertNever } from './assert'; +export const DEFAULT_DURATION = Duration.P30D; + /** * Derive the start date of a given period, assuming two repeating intervals. * * @param duration see comment on Duration enum - * @param endDate from CostInsightsApi.getLastCompleteBillingDate + * @param endDate from CostInsightsApi.getLastCompleteBillingDate + 1 day */ export function inclusiveStartDateOf( duration: Duration, - endDate: string, + exclusiveEndDate: string, ): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment(endDate) + return moment(exclusiveEndDate) .utc() .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment(endDate) - .utc() - .startOf('month') - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(endDate) + return moment(exclusiveEndDate) .utc() .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) @@ -54,18 +50,19 @@ export function inclusiveStartDateOf( export function exclusiveEndDateOf( duration: Duration, - endDate: string, + inclusiveEndDate: string, ): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT); - case Duration.P3M: - return moment(endDate) + return moment(inclusiveEndDate) .utc() - .startOf('quarter') + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment(quarterEndDate(inclusiveEndDate)) + .utc() + .add(1, 'day') .format(DEFAULT_DATE_FORMAT); default: return assertNever(duration); @@ -74,15 +71,27 @@ export function exclusiveEndDateOf( export function inclusiveEndDateOf( duration: Duration, - endDate: string, + inclusiveEndDate: string, ): string { - return moment(exclusiveEndDateOf(duration, endDate)) + return moment(exclusiveEndDateOf(duration, inclusiveEndDate)) .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration, endDate: string) { - return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`; +export function intervalsOf(duration: Duration, inclusiveEndDate: string) { + return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; +} + +export function quarterEndDate(inclusiveEndDate: string): string { + const endDate = moment(inclusiveEndDate).utc(); + const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); + if (endOfQuarter === inclusiveEndDate) { + return endDate.format(DEFAULT_DATE_FORMAT); + } + return endDate + .startOf('quarter') + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); } diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index 69e9086be1..a403f1ebba 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -16,6 +16,7 @@ import { formatPeriod, + formatPercent, lengthyCurrencyFormatter, quarterOf, } from './formatters'; @@ -56,8 +57,6 @@ describe('date formatters', () => { describe.each` duration | date | isEndDate | output - ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} - ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} @@ -69,3 +68,17 @@ describe.each` expect(formatPeriod(duration, date, isEndDate)).toBe(output); }); }); + +describe.each` + ratio | expected + ${0.0} | ${'0%'} + ${0.000000000001} | ${'0%'} + ${-0.00000000001} | ${'0%'} + ${0.123123} | ${'12%'} + ${1.123} | ${'112%'} + ${10.123} | ${'>1000%'} +`('formatPercent', ({ ratio, expected }) => { + it(`correctly formats ${ratio} as ${expected}`, () => { + expect(formatPercent(ratio)).toBe(expected); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index bf8e26ef5f..182c567644 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -15,15 +15,20 @@ */ import moment from 'moment'; -import { Duration } from '../types'; +import pluralize from 'pluralize'; +import { Duration, DEFAULT_DATE_FORMAT } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; -import { pluralOf } from '../utils/grammar'; export type Period = { periodStart: string; periodEnd: string; }; +export const costFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + export const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', @@ -70,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string { const n = Math.round(amount); const numString = numberFormatter.format(n); - return currency ? `${numString} ${pluralOf(n, currency)}` : numString; + return currency ? `${numString} ${pluralize(currency, n)}` : numString; } export function formatPercent(n: number): string { @@ -78,29 +83,24 @@ export function formatPercent(n: number): string { if (isNaN(n) || Math.abs(n) < 0.01) { return '0%'; } - if (Math.abs(n) >= 1e19) { - return '∞%'; + + if (Math.abs(n) > 10) { + return `>1000%`; } + return `${(n * 100).toFixed(0)}%`; } -export function formatLastTwoLookaheadQuarters(endDate: string) { - const start = moment(inclusiveStartDateOf(Duration.P3M, endDate)).format( +export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { + const exclusiveEndDate = moment(inclusiveEndDate) + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); + const start = moment( + inclusiveStartDateOf(Duration.P3M, exclusiveEndDate), + ).format('[Q]Q YYYY'); + const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format( '[Q]Q YYYY', ); - const end = moment(inclusiveEndDateOf(Duration.P3M, endDate)).format( - '[Q]Q YYYY', - ); - return `${start} vs ${end}`; -} - -export function formatLastTwoMonths(endDate: string) { - const start = moment(inclusiveStartDateOf(Duration.P1M, endDate)) - .utc() - .format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M, endDate)) - .utc() - .format('MMMM'); return `${start} vs ${end}`; } @@ -124,12 +124,6 @@ export function formatPeriod( isEndDate: boolean, ) { switch (duration) { - case Duration.P1M: - return monthOf( - isEndDate - ? inclusiveEndDateOf(duration, date) - : inclusiveStartDateOf(duration, date), - ); case Duration.P3M: return quarterOf( isEndDate diff --git a/plugins/cost-insights/src/utils/grammar.ts b/plugins/cost-insights/src/utils/grammar.ts index 653c567d0a..b82520dd53 100644 --- a/plugins/cost-insights/src/utils/grammar.ts +++ b/plugins/cost-insights/src/utils/grammar.ts @@ -22,20 +22,6 @@ const vowels = { u: 'U', }; -export const pluralOf = ( - n: number, - string: string, - plural?: string, -): string => { - if (n !== 1) { - if (plural) { - return plural; - } - return string.concat('s'); - } - return string; -}; - export const indefiniteArticleOf = ( articles: [string, string], word: string, diff --git a/plugins/cost-insights/src/utils/graphs.ts b/plugins/cost-insights/src/utils/graphs.ts new file mode 100644 index 0000000000..bc82b63f89 --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.ts @@ -0,0 +1,83 @@ +/* + * 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 { TooltipPayload, TooltipProps } from 'recharts'; +import { AlertCost, DataKey, Entity, ResourceData } from '../types'; +import { + currencyFormatter, + dateFormatter, + lengthyCurrencyFormatter, +} from './formatters'; + +export function formatGraphValue(value: number, format?: string) { + if (format === 'number') { + return value.toLocaleString(); + } + + if (value < 1) { + return lengthyCurrencyFormatter.format(value); + } + + return currencyFormatter.format(value); +} + +export const overviewGraphTickFormatter = (millis: string | number) => + typeof millis === 'number' ? dateFormatter.format(millis) : millis; + +export const tooltipItemOf = (payload: TooltipPayload) => { + const value = + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string); + const fill = payload.fill as string; + + switch (payload.dataKey) { + case DataKey.Current: + case DataKey.Previous: + return { + label: payload.name, + value: value, + fill: fill, + }; + default: + return null; + } +}; + +export const resourceOf = (entity: Entity | AlertCost): ResourceData => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], +}); + +export const titleOf = (label?: string | number) => { + return label ? String(label) : 'Unlabeled'; +}; + +export const isInvalid = ({ label, payload }: TooltipProps) => { + // null labels are empty strings, which are valid + return label === undefined || !payload || !payload.length; +}; + +export const isLabeled = (data?: Record<'activeLabel', string | undefined>) => { + return data?.activeLabel && data?.activeLabel !== ''; +}; + +export const isUnlabeled = ( + data?: Record<'activeLabel', string | undefined>, +) => { + return data?.activeLabel === ''; +}; diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 4652603926..3328f23cfc 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -14,18 +14,27 @@ * limitations under the License. */ -import { Loading } from '../types'; +import { Duration, Entity, Loading, Maybe, Product } from '../types'; +import { DEFAULT_DURATION } from './duration'; + +export type ProductState = { + product: Product; + entity: Maybe; + duration: Duration; +}; export enum DefaultLoadingAction { UserGroups = 'user-groups', LastCompleteBillingDate = 'billing-date', CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', + CostInsightsProducts = 'cost-insights-products', } export const INITIAL_LOADING_ACTIONS = [ DefaultLoadingAction.UserGroups, DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsProducts, ]; export const getDefaultState = (loadingActions: string[]): Loading => { @@ -55,10 +64,21 @@ export const getResetStateWithoutInitial = ( }, {}); }; -export function getLoadingActions(products: string[]): string[] { - return ([ - DefaultLoadingAction.UserGroups, - DefaultLoadingAction.CostInsightsInitial, - DefaultLoadingAction.CostInsightsPage, - ] as string[]).concat(products); -} +export const settledResponseOf = ( + responses: PromiseSettledResult[], +): Array> => { + return responses.map(response => + response.status === 'fulfilled' ? response.value : null, + ); +}; + +export const initialStatesOf = ( + products: Product[], + responses: Array>, +): ProductState[] => { + return products.map((product, index) => ({ + entity: responses[index], + product: product, + duration: DEFAULT_DURATION, + })); +}; diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 58ba4a4251..ce9e65f1c3 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -14,33 +14,49 @@ * limitations under the License. */ +import dayjs from 'dayjs'; +import regression, { DataPoint } from 'regression'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core'; import { + ChangeStatistic, Duration, Entity, Product, - ProductCost, ProductFilters, ProjectGrowthData, + Trendline, UnlabeledDataflowAlertProject, UnlabeledDataflowData, + DateAggregation, + DEFAULT_DATE_FORMAT, } from '../types'; import { DefaultLoadingAction, getDefaultState as getDefaultLoadingState, } from '../utils/loading'; import { findAlways } from '../utils/assert'; +import { inclusiveStartDateOf } from './duration'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; +type IntervalFields = { + duration: Duration; + endDate: string; +}; + export const createMockEntity = ( callback?: mockEntityRenderer, ): Entity => { const defaultEntity: Entity = { id: 'test-entity', aggregation: [100, 200], + entities: {}, + change: { + ratio: 0, + amount: 0, + }, }; if (typeof callback === 'function') { @@ -62,19 +78,6 @@ export const createMockProduct = ( return { ...defaultProduct }; }; -export const createMockProductCost = ( - callback?: mockEntityRenderer, -): ProductCost => { - const defaultProduct: ProductCost = { - entities: [], - aggregation: [0, 0], - }; - if (typeof callback === 'function') { - return callback({ ...defaultProduct }); - } - return { ...defaultProduct }; -}; - export const createMockProjectGrowthData = ( callback?: mockAlertRenderer, ): ProjectGrowthData => { @@ -140,7 +143,7 @@ export const MockProductTypes: Record = { export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, -).map(productType => ({ duration: Duration.P1M, productType })); +).map(productType => ({ duration: Duration.P30D, productType })); export const MockProducts: Product[] = Object.keys(MockProductTypes).map( productType => @@ -150,14 +153,14 @@ export const MockProducts: Product[] = Object.keys(MockProductTypes).map( })), ); -export const MockLoadingActions = ([ +export const MockDefaultLoadingActions = ([ DefaultLoadingAction.UserGroups, DefaultLoadingAction.CostInsightsInitial, DefaultLoadingAction.CostInsightsPage, ] as string[]).concat(MockProducts.map(product => product.kind)); export const mockDefaultLoadingState = getDefaultLoadingState( - MockLoadingActions, + MockDefaultLoadingActions, ); export const MockComputeEngine = findAlways( @@ -194,3 +197,861 @@ export const MockCostInsightsConfig: Partial = { getConfig: () => MockProductsConfig as Config, getOptionalConfig: () => MockMetricsConfig as Config, }; + +export function trendlineOf(aggregation: DateAggregation[]): Trendline { + const data: ReadonlyArray = aggregation.map(a => [ + Date.parse(a.date) / 1000, + a.amount, + ]); + const result = regression.linear(data, { precision: 5 }); + return { + slope: result.equation[0], + intercept: result.equation[1], + }; +} + +export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { + const half = Math.ceil(aggregation.length / 2); + const before = aggregation + .slice(0, half) + .reduce((sum, a) => sum + a.amount, 0); + const after = aggregation + .slice(half, aggregation.length) + .reduce((sum, a) => sum + a.amount, 0); + return { + ratio: (after - before) / before, + amount: after - before, + }; +} + +export function aggregationFor( + intervals: string, + baseline: number, +): DateAggregation[] { + const { duration, endDate } = parseIntervals(intervals); + const days = dayjs(endDate).diff( + inclusiveStartDateOf(duration, endDate), + 'day', + ); + + return [...Array(days).keys()].reduce( + (values: DateAggregation[], i: number): DateAggregation[] => { + const last = values.length ? values[values.length - 1].amount : baseline; + values.push({ + date: dayjs(inclusiveStartDateOf(duration, endDate)) + .add(i, 'day') + .format(DEFAULT_DATE_FORMAT), + amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), + }); + return values; + }, + [], + ); +} + +function parseIntervals(intervals: string): IntervalFields { + const match = intervals.match( + /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, + ); + if (Object.keys(match?.groups || {}).length !== 2) { + throw new Error(`Invalid intervals: ${intervals}`); + } + const { duration, date } = match!.groups!; + return { + duration: duration as Duration, + endDate: date, + }; +} + +export const MockAggregatedDailyCosts: DateAggregation[] = [ + { + date: '2020-08-07', + amount: 3500, + }, + { + date: '2020-08-06', + amount: 2500, + }, + { + date: '2020-08-05', + amount: 1400, + }, + { + date: '2020-08-04', + amount: 3800, + }, + { + date: '2020-08-09', + amount: 1900, + }, + { + date: '2020-08-08', + amount: 2400, + }, + { + date: '2020-08-03', + amount: 4000, + }, + { + date: '2020-08-02', + amount: 3700, + }, + { + date: '2020-08-01', + amount: 2500, + }, + { + date: '2020-08-18', + amount: 4300, + }, + { + date: '2020-08-17', + amount: 1500, + }, + { + date: '2020-08-16', + amount: 3600, + }, + { + date: '2020-08-15', + amount: 2200, + }, + { + date: '2020-08-19', + amount: 3900, + }, + { + date: '2020-08-10', + amount: 4100, + }, + { + date: '2020-08-14', + amount: 3600, + }, + { + date: '2020-08-13', + amount: 2900, + }, + { + date: '2020-08-12', + amount: 2700, + }, + { + date: '2020-08-11', + amount: 5100, + }, + { + date: '2020-09-19', + amount: 1200, + }, + { + date: '2020-09-18', + amount: 6500, + }, + { + date: '2020-09-17', + amount: 2500, + }, + { + date: '2020-09-16', + amount: 1400, + }, + { + date: '2020-09-11', + amount: 2300, + }, + { + date: '2020-09-10', + amount: 1900, + }, + { + date: '2020-09-15', + amount: 3100, + }, + { + date: '2020-09-14', + amount: 4500, + }, + { + date: '2020-09-13', + amount: 3300, + }, + { + date: '2020-09-12', + amount: 2800, + }, + { + date: '2020-09-29', + amount: 2600, + }, + { + date: '2020-09-28', + amount: 4100, + }, + { + date: '2020-09-27', + amount: 3800, + }, + { + date: '2020-09-22', + amount: 3700, + }, + { + date: '2020-09-21', + amount: 2700, + }, + { + date: '2020-09-20', + amount: 2200, + }, + { + date: '2020-09-26', + amount: 3300, + }, + { + date: '2020-09-25', + amount: 4000, + }, + { + date: '2020-09-24', + amount: 3800, + }, + { + date: '2020-09-23', + amount: 4100, + }, + { + date: '2020-08-29', + amount: 4400, + }, + { + date: '2020-08-28', + amount: 5000, + }, + { + date: '2020-08-27', + amount: 4900, + }, + { + date: '2020-08-26', + amount: 4100, + }, + { + date: '2020-08-21', + amount: 3700, + }, + { + date: '2020-08-20', + amount: 2200, + }, + { + date: '2020-08-25', + amount: 1700, + }, + { + date: '2020-08-24', + amount: 2100, + }, + { + date: '2020-08-23', + amount: 3100, + }, + { + date: '2020-08-22', + amount: 1500, + }, + { + date: '2020-09-08', + amount: 2900, + }, + { + date: '2020-09-07', + amount: 4100, + }, + { + date: '2020-09-06', + amount: 3600, + }, + { + date: '2020-09-05', + amount: 3300, + }, + { + date: '2020-09-09', + amount: 2800, + }, + { + date: '2020-08-31', + amount: 3400, + }, + { + date: '2020-08-30', + amount: 4300, + }, + { + date: '2020-09-04', + amount: 6100, + }, + { + date: '2020-09-03', + amount: 2500, + }, + { + date: '2020-09-02', + amount: 4900, + }, + { + date: '2020-09-01', + amount: 6100, + }, + { + date: '2020-09-30', + amount: 5500, + }, +]; + +export const SampleBigQueryInsights: Entity = { + id: 'bigQuery', + aggregation: [10_000, 30_000], + change: { + ratio: 3, + amount: 20_000, + }, + entities: { + dataset: [ + { + id: 'entity-a', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, + }, + { + id: 'entity-b', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, + }, + { + id: 'entity-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, + }, + entities: {}, + }, + ], + }, +}; + +export const SampleCloudDataflowInsights: Entity = { + id: 'cloudDataflow', + aggregation: [100_000, 158_000], + change: { + ratio: 0.58, + amount: 58_000, + }, + entities: { + pipeline: [ + { + id: null, + aggregation: [10_000, 12_000], + change: { + ratio: 0.2, + amount: 2_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [3_000, 4_000], + change: { + ratio: 0.333333, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [7_000, 8_000], + change: { + ratio: 0.14285714, + amount: 1_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-a', + aggregation: [60_000, 70_000], + change: { + ratio: 0.16666666666666666, + amount: 10_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [20_000, 15_000], + change: { + ratio: -0.25, + amount: -5_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [30_000, 35_000], + change: { + ratio: -0.16666666666666666, + amount: -5_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [10_000, 20_000], + change: { + ratio: 1, + amount: 10_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-b', + aggregation: [12_000, 8_000], + change: { + ratio: -0.33333, + amount: -4_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [4_000, 4_000], + change: { + ratio: 0, + amount: 0, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [8_000, 4_000], + change: { + ratio: -0.5, + amount: -4_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, + }, + entities: {}, + }, + ], + }, +}; + +export const SampleCloudStorageInsights: Entity = { + id: 'cloudStorage', + aggregation: [45_000, 45_000], + change: { + ratio: 0, + amount: 0, + }, + entities: { + bucket: [ + { + id: 'entity-a', + aggregation: [15_000, 20_000], + change: { + ratio: 0.333, + amount: 5_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [10_000, 11_000], + change: { + ratio: 0.1, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [2_000, 5_000], + change: { + ratio: 1.5, + amount: 3_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [3_000, 4_000], + change: { + ratio: 0.3333, + amount: 1_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-b', + aggregation: [30_000, 25_000], + change: { + ratio: -0.16666, + amount: -5_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [12_000, 13_000], + change: { + ratio: 0.08333333333333333, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [16_000, 12_000], + change: { + ratio: -0.25, + amount: -4_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [2_000, 0], + change: { + ratio: -1, + amount: -2000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-c', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0, + }, + entities: {}, + }, + ], + }, +}; + +export const SampleComputeEngineInsights: Entity = { + id: 'computeEngine', + aggregation: [80_000, 90_000], + change: { + ratio: 0.125, + amount: 10_000, + }, + entities: { + service: [ + { + id: 'entity-a', + aggregation: [20_000, 10_000], + change: { + ratio: -0.5, + amount: -10_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [4_000, 2_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [7_000, 6_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [9_000, 2_000], + change: { + ratio: -0.7777777777777778, + amount: -7000, + }, + entities: {}, + }, + ], + deployment: [ + { + id: 'Compute Engine', + aggregation: [7_000, 6_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Kubernetes', + aggregation: [4_000, 2_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-b', + aggregation: [10_000, 20_000], + change: { + ratio: 1, + amount: 10_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [1_000, 2_000], + change: { + ratio: 1, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [4_000, 8_000], + change: { + ratio: 1, + amount: 4_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, + }, + ], + deployment: [ + { + id: 'Compute Engine', + aggregation: [7_000, 6_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Kubernetes', + aggregation: [4_000, 2_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, + }, + entities: {}, + }, + ], + }, +}; + +export const SampleEventsInsights: Entity = { + id: 'events', + aggregation: [20_000, 10_000], + change: { + ratio: -0.5, + amount: -10_000, + }, + entities: { + event: [ + { + id: 'entity-a', + aggregation: [15_000, 7_000], + change: { + ratio: -0.53333333333, + amount: -8_000, + }, + entities: { + product: [ + { + id: 'Sample Product A', + aggregation: [5_000, 2_000], + change: { + ratio: -0.6, + amount: -3_000, + }, + entities: {}, + }, + { + id: 'Sample Product B', + aggregation: [7_000, 2_500], + change: { + ratio: -0.64285714285, + amount: -4_500, + }, + entities: {}, + }, + { + id: 'Sample Product C', + aggregation: [3_000, 2_500], + change: { + ratio: -0.16666666666, + amount: -500, + }, + entities: {}, + }, + ], + }, + }, + { + id: 'entity-b', + aggregation: [5_000, 3_000], + change: { + ratio: -0.4, + amount: -2_000, + }, + entities: { + product: [ + { + id: 'Sample Product A', + aggregation: [2_000, 1_000], + change: { + ratio: -0.5, + amount: -1_000, + }, + entities: {}, + }, + { + id: 'Sample Product B', + aggregation: [1_000, 1_500], + change: { + ratio: 0.5, + amount: 500, + }, + entities: {}, + }, + { + id: 'Sample Product C', + aggregation: [2_000, 500], + change: { + ratio: -0.75, + amount: -1_500, + }, + entities: {}, + }, + ], + }, + }, + ], + }, +}; + +export function entityOf(product: string): Entity { + switch (product) { + case 'computeEngine': + return SampleComputeEngineInsights; + case 'cloudDataflow': + return SampleCloudDataflowInsights; + case 'cloudStorage': + return SampleCloudStorageInsights; + case 'bigQuery': + return SampleBigQueryInsights; + case 'events': + return SampleEventsInsights; + default: + throw new Error( + `Cannot get insights for ${product}. Make sure product matches product property in app-info.yaml`, + ); + } +} + +export const getGroupedProducts = (intervals: string) => [ + { + id: 'Cloud Dataflow', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'Compute Engine', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'Cloud Storage', + aggregation: aggregationFor(intervals, 1_300), + }, + { + id: 'BigQuery', + aggregation: aggregationFor(intervals, 2_000), + }, + { + id: 'Cloud SQL', + aggregation: aggregationFor(intervals, 750), + }, + { + id: 'Cloud Spanner', + aggregation: aggregationFor(intervals, 50), + }, + { + id: 'Cloud Pub/Sub', + aggregation: aggregationFor(intervals, 1_000), + }, + { + id: 'Cloud Bigtable', + aggregation: aggregationFor(intervals, 250), + }, +]; diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts index 944f5fc806..e58d2961b6 100644 --- a/plugins/cost-insights/src/utils/sort.ts +++ b/plugins/cost-insights/src/utils/sort.ts @@ -14,10 +14,18 @@ * limitations under the License. */ import { DateAggregation, ResourceData } from '../types'; +import { ProductState } from './loading'; export const aggregationSort = ( a: DateAggregation, b: DateAggregation, ): number => a.date.localeCompare(b.date); + export const resourceSort = (a: ResourceData, b: ResourceData) => b.previous + b.current - (a.previous + a.current); + +export function totalAggregationSort(a: ProductState, b: ProductState): number { + const [prevA, currA] = a.entity?.aggregation ?? [0, 0]; + const [prevB, currB] = b.entity?.aggregation ?? [0, 0]; + return prevB + currB - (prevA + currA); +} diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index b20c601276..c04ceba8e3 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -15,6 +15,7 @@ */ import { createStyles, + emphasize, darken, getLuminance, lighten, @@ -36,6 +37,18 @@ export const costInsightsLightTheme = { }, navigationText: '#b5b5b5', alertBackground: 'rgba(219, 219, 219, 0.13)', + dataViz: [ + '#509BF5', + '#4B917D', + '#FF6437', + '#F573A0', + '#F59B23', + '#B49BC8', + '#C39687', + '#A0C3D2', + '#FFC864', + '#BABABA', + ], }, } as CostInsightsThemeOptions; @@ -54,6 +67,18 @@ export const costInsightsDarkTheme = { }, navigationText: '#b5b5b5', alertBackground: 'rgba(32, 32, 32, 0.13)', + dataViz: [ + '#8accff', + '#7bc2ac', + '#ff9664', + '#ffa5d1', + '#ffcc57', + '#e6ccfb', + '#f7c7b7', + '#d2f6ff', + '#fffb94', + '#ececec', + ], }, } as CostInsightsThemeOptions; @@ -86,6 +111,20 @@ export const useCostOverviewStyles = (theme: CostInsightsTheme) => ({ }, }); +export const useOverviewTabsStyles = makeStyles( + (theme: CostInsightsTheme) => ({ + default: { + padding: theme.spacing(2), + fontWeight: theme.typography.fontWeightBold, + color: theme.palette.text.secondary, + textTransform: 'uppercase', + }, + selected: { + color: theme.palette.text.primary, + }, + }), +); + export const useBarChartStyles = (theme: CostInsightsTheme) => ({ axis: { fill: theme.palette.text.primary, @@ -116,6 +155,18 @@ export const useBarChartStyles = (theme: CostInsightsTheme) => ({ }, }); +export const useBarChartLayoutStyles = makeStyles(theme => + createStyles({ + wrapper: { + display: 'flex', + flexDirection: 'column', + }, + legend: { + paddingBottom: theme.spacing(2), + }, + }), +); + export const useBarChartStepperButtonStyles = makeStyles( (theme: CostInsightsTheme) => createStyles({ @@ -146,12 +197,13 @@ export const useBarChartStepperButtonStyles = makeStyles( }), ); -export const useBarChartLabelStyles = makeStyles(() => +export const useBarChartLabelStyles = makeStyles(theme => createStyles({ foreignObject: { textAlign: 'center', }, label: { + fontWeight: theme.typography.fontWeightBold, display: 'block', textDecoration: 'none', overflow: 'hidden', @@ -162,19 +214,21 @@ export const useBarChartLabelStyles = makeStyles(() => marginLeft: 2, fontSize: '1.25em', }, + button: { + textTransform: 'none', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.fontSize, + }, }), ); -export const useCostInsightsStyles = makeStyles( - (theme: BackstageTheme) => - createStyles({ - h6Subtle: { - ...theme.typography.h6, - fontWeight: 'normal', - color: theme.palette.textSubtle, - }, - }), -); +export const useCostInsightsStyles = makeStyles(theme => ({ + h6Subtle: { + ...theme.typography.h6, + fontWeight: 'normal', + color: theme.palette.textSubtle, + }, +})); export const useCostInsightsTabsStyles = makeStyles( (theme: BackstageTheme) => ({ @@ -244,43 +298,46 @@ export const useCostGrowthStyles = makeStyles( savings: { color: theme.palette.status.ok, }, + indicator: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, }), ); -export const useCostGrowthLegendStyles = makeStyles( - (theme: BackstageTheme) => - createStyles({ - h5: { - ...theme.typography.h5, - fontWeight: 500, - padding: 0, - }, - marker: { - display: 'inherit', - marginRight: theme.spacing(1), - }, - helpIcon: { - display: 'inherit', - }, - title: { - ...theme.typography.overline, - fontWeight: 500, - lineHeight: 0, - marginRight: theme.spacing(1), - color: theme.palette.textSubtle, - }, - tooltip: { - display: 'block', - padding: theme.spacing(1), - backgroundColor: theme.palette.navigation.background, - }, - tooltipText: { - color: theme.palette.background.default, - fontSize: theme.typography.fontSize, - lineHeight: 1.5, - }, - }), -); +export const useCostGrowthLegendStyles = makeStyles(theme => ({ + h5: { + ...theme.typography.h5, + fontWeight: 500, + padding: 0, + }, + marker: { + display: 'inherit', + marginRight: theme.spacing(1), + }, + helpIcon: { + display: 'inherit', + }, + title: { + ...theme.typography.overline, + fontWeight: 500, + lineHeight: 0, + marginRight: theme.spacing(1), + color: theme.palette.textSubtle, + }, + tooltip: { + display: 'block', + padding: theme.spacing(1), + backgroundColor: theme.palette.navigation.background, + }, + tooltipText: { + color: theme.palette.background.default, + fontSize: theme.typography.fontSize, + lineHeight: 1.5, + }, +})); export const useBarChartStepperStyles = makeStyles( (theme: BackstageTheme) => @@ -328,6 +385,7 @@ export const useNavigationStyles = makeStyles( menuList: { borderRadius: theme.shape.borderRadius, backgroundColor: theme.palette.navigation.background, + minWidth: 250, }, menuItem: { background: 'transparent', @@ -356,16 +414,40 @@ export const useTooltipStyles = makeStyles( (theme: CostInsightsTheme) => createStyles({ tooltip: { - padding: theme.spacing(1.5), backgroundColor: theme.palette.tooltip.background, borderRadius: theme.shape.borderRadius, boxShadow: theme.shadows[1], color: theme.palette.tooltip.color, fontSize: theme.typography.fontSize, + minWidth: 300, + }, + maxWidth: { + maxWidth: 300, + }, + actions: { + padding: theme.spacing(2), + }, + header: { + padding: theme.spacing(2), + }, + content: { + padding: theme.spacing(2), }, lensIcon: { fontSize: `.75rem`, }, + divider: { + backgroundColor: emphasize(theme.palette.divider, 1), + }, + truncate: { + maxWidth: 200, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + subtitle: { + fontStyle: 'italic', + }, }), ); @@ -446,6 +528,21 @@ export const useProductInsightsCardStyles = makeStyles( }), ); +export const useProductInsightsChartStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + indicator: { + fontWeight: theme.typography.fontWeightBold, + fontSize: '1.25rem', + }, + actions: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + }), +); + export const useBackdropStyles = makeStyles( (theme: BackstageTheme) => createStyles({ @@ -463,3 +560,42 @@ export const useSubtleTypographyStyles = makeStyles( }, }), ); + +export const useEntityDialogStyles = makeStyles(theme => + createStyles({ + dialogContent: { + padding: 0, + }, + dialogTitle: { + padding: 0, + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + zIndex: 100, + }, + row: { + fontSize: theme.typography.fontSize * 1.25, + }, + rowTotal: { + fontWeight: theme.typography.fontWeightBold, + }, + colFirst: { + paddingLeft: theme.spacing(2), + }, + colLast: { + paddingRight: theme.spacing(2), + }, + column: { + fontWeight: theme.typography.fontWeightBold, + }, + growth: { + display: 'flex', + flexDirection: 'row', + alignContent: 'center', + justifyContent: 'flex-end', + }, + }), +); diff --git a/plugins/cost-insights/src/utils/sum.ts b/plugins/cost-insights/src/utils/sum.ts new file mode 100644 index 0000000000..6fc181fea5 --- /dev/null +++ b/plugins/cost-insights/src/utils/sum.ts @@ -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 { DateAggregation } from '../types'; + +export const aggregationSum = (aggregation: DateAggregation[]) => + aggregation.reduce((total, curAgg) => total + curAgg.amount, 0); diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index dfa428b62a..a9e22fc5ed 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-explore +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index a1f71de6a2..0b4b35e0cd 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.2.0", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/explore" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,9 +42,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ExploreCard.tsx index 9c61e9bea8..8d1348c116 100644 --- a/plugins/explore/src/components/ExploreCard.tsx +++ b/plugins/explore/src/components/ExploreCard.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { Button, @@ -80,7 +80,7 @@ type Props = { objectFit?: 'cover' | 'contain'; }; -const ExploreCard: FC = ({ card, objectFit }) => { +const ExploreCard = ({ card, objectFit }: Props) => { const classes = useStyles(); const { title, description, url, image, lifecycle, newsTag, tags } = card; diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index 08e1962f3c..3f21b36084 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-gcp-projects +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 812c3b1051..35852458bf 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gcp-projects", - "version": "0.2.0", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/gcp-projects" + }, + "keywords": [ + "backstage", + "google cloud" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -31,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index a4b69a07d4..e9edcf76c3 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,67 @@ # @backstage/plugin-github-actions +## 0.2.4 + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/core-api@0.2.4 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 95146c740c..5bab38b404 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.2.0", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,17 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/github-actions" + }, + "keywords": [ + "backstage", + "github", + "github actions" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/core-api": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/core-api": "^0.2.5", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -40,9 +51,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 78c5239459..184e784249 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -60,7 +60,7 @@ export const RecentWorkflowRunsCard = ({ void; }; -export const WorkflowRunsTableView: FC = ({ +export const WorkflowRunsTableView = ({ projectName, loading, pageSize, @@ -124,7 +124,7 @@ export const WorkflowRunsTableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
= () => { +const ClusterList = () => { const api = useApi(gitOpsApiRef); const githubAuth = useApi(githubAuthApiRef); const [githubUsername, setGithubUsername] = useState(String); diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index 9b6e5bdb08..0d304753c4 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Content, Header, @@ -30,7 +30,7 @@ import { useParams } from 'react-router-dom'; import { gitOpsApiRef, Status } from '../../api'; import { transformRunStatus } from '../ProfileCatalog'; -const ClusterPage: FC<{}> = () => { +const ClusterPage = () => { const params = useParams() as { owner: string; repo: string }; const [pollingLog, setPollingLog] = useState(true); diff --git a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx index 84b1c6b432..a3393a8ddb 100644 --- a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { Link } from '@material-ui/core'; import { ClusterStatus } from '../../api'; @@ -61,7 +61,7 @@ const columns: TableColumn[] = [ type ClusterTableProps = { components: ClusterStatus[]; }; -const ClusterTable: FC = ({ components }) => { +const ClusterTable = ({ components }: ClusterTableProps) => { return (
); diff --git a/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx b/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx index a73afbb04c..1921492cb4 100644 --- a/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import Card from '@material-ui/core/Card'; import CardHeader from '@material-ui/core/CardHeader'; @@ -62,7 +62,7 @@ interface Props { activeIndex: number; } -const ClusterTemplateCard: FC = props => { +const ClusterTemplateCard = (props: Props) => { const classes = useStyles(); const handleSelect = () => { diff --git a/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx b/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx index 20beb6d00a..e4089c61b5 100644 --- a/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import ClusterTemplateCard from '../ClusterTemplateCard'; @@ -27,7 +27,7 @@ interface Props { }[]; } -const ClusterTemplateCardList: FC = props => { +const ClusterTemplateCardList = (props: Props) => { const [activeIndex, setActiveIndex] = React.useState(-1); const handleClicked = (index: number, repository: string) => { diff --git a/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx b/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx index 1c4227a0b0..a06a6df9b8 100644 --- a/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { Avatar, Card, @@ -66,7 +66,7 @@ interface Props { selections: Set; } -const ProfileCard: FC = props => { +const ProfileCard = (props: Props) => { const [selection, setSelection] = useState(false); const handleSelect = () => { diff --git a/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx b/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx index 5aea5e56e4..9e5a9c0c8f 100644 --- a/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { Grid } from '@material-ui/core'; import ProfileCard from '../ProfileCard'; @@ -26,7 +26,7 @@ interface Props { }[]; } -const ProfileCardList: FC = props => { +const ProfileCardList = (props: Props) => { const [selections, setSelections] = useState>(new Set()); const [profiles, setProfiles] = useState>(new Set()); diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx index a70cf97342..394b0126d5 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Header, Page, @@ -79,7 +79,7 @@ export const transformRunStatus = (x: Status[]) => { }); }; -const ProfileCatalog: FC<{}> = () => { +const ProfileCatalog = () => { // TODO: get data from REST API const [clusterTemplates] = React.useState([ { diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index db59e7d369..b14db4e8bb 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-graphiql +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index e346133e3d..367258b7f7 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.0", + "version": "0.2.2", "private": false, "publishConfig": { "access": "public", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,13 +43,13 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/codemirror": "^0.0.97", + "@types/codemirror": "^0.0.100", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "cross-fetch": "^3.0.6", diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index ce56017984..35dcb1eb3b 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState, Suspense } from 'react'; +import React, { useState, Suspense } from 'react'; import { Tabs, Tab, makeStyles, Typography, Divider } from '@material-ui/core'; import 'graphiql/graphiql.css'; import { StorageBucket } from '../../lib/storage'; @@ -47,7 +47,7 @@ type GraphiQLBrowserProps = { endpoints: GraphQLEndpoint[]; }; -export const GraphiQLBrowser: FC = ({ endpoints }) => { +export const GraphiQLBrowser = ({ endpoints }: GraphiQLBrowserProps) => { const classes = useStyles(); const [tabIndex, setTabIndex] = useState(0); diff --git a/plugins/graphql/CHANGELOG.md b/plugins/graphql/CHANGELOG.md index f9a626d543..0b55e37955 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-graphql-backend +## 0.1.4 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/plugin-catalog-graphql@0.2.4 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + - @backstage/plugin-catalog-graphql@0.2.1 + ## 0.1.2 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index f62fecc648..d30e727867 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.2", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/graphql-backend" + }, + "keywords": [ + "backstage", + "graphql" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -19,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/config": "^0.1.1", - "@backstage/plugin-catalog-graphql": "^0.2.0", + "@backstage/backend-common": "^0.4.0", + "@backstage/config": "^0.1.2", + "@backstage/plugin-catalog-graphql": "^0.2.4", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -35,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 010471d9b2..c922be67b2 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,76 @@ # @backstage/plugin-jenkins +## 0.3.3 + +### Patch Changes + +- b2a07d2dc: Avoid loading data from Jenkins twice. Don't load data when navigating through the pages as all data from all pages is already loaded. +- 04efbbdd2: Refactor to support ADR004 module exporting. + + For more information, see https://backstage.io/docs/architecture-decisions/adrs-adr004. + +- 0f8877168: Improve loading speed of the CI/CD page. + Only request the necessary fields from Jenkins to keep the request size low. + In addition everything is loaded in a single request, instead of requesting + each job and build individually. As this (and also the previous behavior) can + lead to a big amount of data, this limits the amount of jobs to 50. + For each job, only the latest build is loaded. Loading the full build history + of a job can lead to excessive load on the Jenkins instance. +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.3.0 + +### Minor Changes + +- a41730c6e: Add tooltip for Jenkins rerun button + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 1d3e7f60e5..5e0a701caf 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -41,7 +41,7 @@ export JENKINS_BASIC_AUTH_HEADER="Basic $HEADER" ``` 5. Run app with `yarn start` -6. Add the Jenkins folder annotation to your `component-info.yaml`, (note: currently this plugin only supports folders and Git SCM) +6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (note: currently this plugin only supports folders and Git SCM) ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index d0304376fd..f6c6466236 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.2.0", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/jenkins" + }, + "keywords": [ + "backstage", + "jenkins" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts new file mode 100644 index 0000000000..8dbde9160d --- /dev/null +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -0,0 +1,255 @@ +/* + * 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 { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; + +const jenkins = require('jenkins'); + +export const jenkinsApiRef = createApiRef({ + id: 'plugin.jenkins.service', + description: 'Used by the Jenkins plugin to make requests', +}); + +const DEFAULT_PROXY_PATH = '/jenkins/api'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /jenkins/api + */ + proxyPath?: string; +}; + +export class JenkinsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; + } + + private async getClient() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true }); + } + + async retry(buildName: string) { + const client = await this.getClient(); + // looks like the current SDK only supports triggering a new build + // can't see any support for replay (re-running the specific build with the same SCM info) + return await client.job.build(buildName); + } + + async getLastBuild(jobName: string) { + const client = await this.getClient(); + const job = await client.job.get(jobName); + + const lastBuild = await client.build.get(jobName, job.lastBuild.number); + return lastBuild; + } + + extractScmDetailsFromJob(jobDetails: any): any { + const scmInfo = jobDetails.actions + .filter( + (action: any) => + action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', + ) + .map((action: any) => { + return { + url: action?.objectUrl, + // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html + // branch name for regular builds, pull request title on pull requests + displayName: action?.objectDisplayName, + }; + }) + .pop(); + + const author = jobDetails.actions + .filter( + (action: any) => + action._class === + 'jenkins.scm.api.metadata.ContributorMetadataAction', + ) + .map((action: any) => { + return action.contributorDisplayName; + }) + .pop(); + + if (author) { + scmInfo.author = author; + } + + return scmInfo; + } + + async getJob(jobName: string) { + const client = await this.getClient(); + return client.job.get({ + name: jobName, + depth: 1, + }); + } + + async getFolder(folderName: string) { + const client = await this.getClient(); + const folder = await client.job.get({ + name: folderName, + // Filter only be the information we need, instead of loading all fields. + // Limit to only show the latest build for each job and only load 50 jobs + // at all. + // Whitespaces are only included for readablity here and stripped out + // before sending to Jenkins + tree: `jobs[ + actions[*], + builds[ + number, + url, + fullDisplayName, + building, + result, + actions[ + *[ + *[ + *[ + * + ] + ] + ] + ] + ]{0,1}, + jobs{0,1}, + name + ]{0,50} + `.replace(/\s/g, ''), + }); + const results = []; + + for (const jobDetails of folder.jobs) { + const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); + if (jobDetails.jobs) { + // skipping folders inside folders for now + } else { + for (const buildDetails of jobDetails.builds) { + const ciTable = this.mapJenkinsBuildToCITable( + buildDetails, + jobScmInfo, + ); + results.push(ciTable); + } + } + } + return results; + } + + private getTestReport( + jenkinsResult: any, + ): { + total: number; + passed: number; + skipped: number; + failed: number; + testUrl: string; + } { + return jenkinsResult.actions + .filter( + (action: any) => + action._class === 'hudson.tasks.junit.TestResultAction', + ) + .map((action: any) => { + return { + total: action.totalCount, + passed: action.totalCount - action.failCount - action.skipCount, + skipped: action.skipCount, + failed: action.failCount, + testUrl: `${jenkinsResult.url}${action.urlName}/`, + }; + }) + .pop(); + } + + mapJenkinsBuildToCITable( + jenkinsResult: any, + jobScmInfo?: any, + ): CITableBuildInfo { + const source = + jenkinsResult.actions + .filter( + (action: any) => + action._class === 'hudson.plugins.git.util.BuildData', + ) + .map((action: any) => { + const [first]: any = Object.values(action.buildsByBranchName); + const branch = first.revision.branch[0]; + return { + branchName: branch.name, + commit: { + hash: branch.SHA1.substring(0, 8), + }, + }; + }) + .pop() || {}; + + if (jobScmInfo) { + source.url = jobScmInfo?.url; + source.displayName = jobScmInfo?.displayName; + source.author = jobScmInfo?.author; + } + + const path = new URL(jenkinsResult.url).pathname; + + return { + id: path, + buildNumber: jenkinsResult.number, + buildUrl: jenkinsResult.url, + buildName: jenkinsResult.fullDisplayName, + status: jenkinsResult.building ? 'running' : jenkinsResult.result, + onRestartClick: () => { + // TODO: this won't handle non root context path, need a better way to get the job name + const { jobName } = this.extractJobDetailsFromBuildName(path); + return this.retry(jobName); + }, + source: source, + tests: this.getTestReport(jenkinsResult), + }; + } + + async getBuild(buildName: string) { + const client = await this.getClient(); + const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( + buildName, + ); + const buildResult = await client.build.get(jobName, buildNumber); + return buildResult; + } + + extractJobDetailsFromBuildName(buildName: string) { + const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, ''); + + const split = trimmedBuild.split('/'); + const buildNumber = parseInt(split[split.length - 1], 10); + const jobName = trimmedBuild.slice( + 0, + trimmedBuild.length - buildNumber.toString(10).length - 1, + ); + + return { + jobName, + buildNumber, + }; + } +} diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 0fa48a1d17..41e0985be2 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -14,220 +14,4 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; -import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; - -const jenkins = require('jenkins'); - -export const jenkinsApiRef = createApiRef({ - id: 'plugin.jenkins.service', - description: 'Used by the Jenkins plugin to make requests', -}); - -const DEFAULT_PROXY_PATH = '/jenkins/api'; - -type Options = { - discoveryApi: DiscoveryApi; - /** - * Path to use for requests via the proxy, defaults to /jenkins/api - */ - proxyPath?: string; -}; - -export class JenkinsApi { - private readonly discoveryApi: DiscoveryApi; - private readonly proxyPath: string; - - constructor(options: Options) { - this.discoveryApi = options.discoveryApi; - this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; - } - - private async getClient() { - const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); - return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true }); - } - - async retry(buildName: string) { - const client = await this.getClient(); - // looks like the current SDK only supports triggering a new build - // can't see any support for replay (re-running the specific build with the same SCM info) - return await client.job.build(buildName); - } - - async getLastBuild(jobName: string) { - const client = await this.getClient(); - const job = await client.job.get(jobName); - - const lastBuild = await client.build.get(jobName, job.lastBuild.number); - return lastBuild; - } - - extractScmDetailsFromJob(jobDetails: any): any { - const scmInfo = jobDetails.actions - .filter( - (action: any) => - action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', - ) - .map((action: any) => { - return { - url: action?.objectUrl, - // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html - // branch name for regular builds, pull request title on pull requests - displayName: action?.objectDisplayName, - }; - }) - .pop(); - - const author = jobDetails.actions - .filter( - (action: any) => - action._class === - 'jenkins.scm.api.metadata.ContributorMetadataAction', - ) - .map((action: any) => { - return action.contributorDisplayName; - }) - .pop(); - - if (author) { - scmInfo.author = author; - } - - return scmInfo; - } - - async getJob(jobName: string) { - const client = await this.getClient(); - return client.job.get({ - name: jobName, - depth: 1, - }); - } - - async getFolder(folderName: string) { - const client = await this.getClient(); - const folder = await client.job.get(folderName); - const results = []; - for (const jobSummary of folder.jobs) { - const jobDetails = await client.job.get({ - name: `${folderName}/${jobSummary.name}`, - depth: 1, - }); - - const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); - if (jobDetails.jobs) { - // skipping folders inside folders for now - } else { - for (const buildDetails of jobDetails.builds) { - const build = await client.build.get({ - name: `${folderName}/${jobSummary.name}`, - number: buildDetails.number, - depth: 1, - }); - - const ciTable = this.mapJenkinsBuildToCITable(build, jobScmInfo); - results.push(ciTable); - } - } - } - return results; - } - - private getTestReport( - jenkinsResult: any, - ): { - total: number; - passed: number; - skipped: number; - failed: number; - testUrl: string; - } { - return jenkinsResult.actions - .filter( - (action: any) => - action._class === 'hudson.tasks.junit.TestResultAction', - ) - .map((action: any) => { - return { - total: action.totalCount, - passed: action.totalCount - action.failCount - action.skipCount, - skipped: action.skipCount, - failed: action.failCount, - testUrl: `${jenkinsResult.url}${action.urlName}/`, - }; - }) - .pop(); - } - - mapJenkinsBuildToCITable( - jenkinsResult: any, - jobScmInfo?: any, - ): CITableBuildInfo { - const source = - jenkinsResult.actions - .filter( - (action: any) => - action._class === 'hudson.plugins.git.util.BuildData', - ) - .map((action: any) => { - const [first]: any = Object.values(action.buildsByBranchName); - const branch = first.revision.branch[0]; - return { - branchName: branch.name, - commit: { - hash: branch.SHA1.substring(0, 8), - }, - }; - }) - .pop() || {}; - - if (jobScmInfo) { - source.url = jobScmInfo?.url; - source.displayName = jobScmInfo?.displayName; - source.author = jobScmInfo?.author; - } - - const path = new URL(jenkinsResult.url).pathname; - - return { - id: path, - buildNumber: jenkinsResult.number, - buildUrl: jenkinsResult.url, - buildName: jenkinsResult.fullDisplayName, - status: jenkinsResult.building ? 'running' : jenkinsResult.result, - onRestartClick: () => { - // TODO: this won't handle non root context path, need a better way to get the job name - const { jobName } = this.extractJobDetailsFromBuildName(path); - return this.retry(jobName); - }, - source: source, - tests: this.getTestReport(jenkinsResult), - }; - } - - async getBuild(buildName: string) { - const client = await this.getClient(); - const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( - buildName, - ); - const buildResult = await client.build.get(jobName, buildNumber); - return buildResult; - } - - extractJobDetailsFromBuildName(buildName: string) { - const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, ''); - - const split = trimmedBuild.split('/'); - const buildNumber = parseInt(split[split.length - 1], 10); - const jobName = trimmedBuild.slice( - 0, - trimmedBuild.length - buildNumber.toString(10).length - 1, - ); - - return { - jobName, - buildNumber, - }; - } -} +export { JenkinsApi, jenkinsApiRef } from './JenkinsApi'; diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index 0dcaeb8948..9e9d432762 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -22,7 +22,7 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; const useStyles = makeStyles({ accordionDetails: { @@ -35,12 +35,14 @@ const useStyles = makeStyles({ }, }); -export const ActionOutput: FC<{ +type ActionOutputProps = { url: string; name: string; className?: string; action: any; -}> = ({ url, name, className }) => { +}; + +export const ActionOutput = ({ url, name, className }: ActionOutputProps) => { const classes = useStyles(); useEffect(() => {}, [url]); diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 5d5d7d9fcd..2d8f43ca80 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; @@ -201,7 +201,7 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -export const CITableView: FC = ({ +export const CITableView = ({ projectName, loading, pageSize, @@ -211,7 +211,7 @@ export const CITableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
{ - try { - let build; - if (branch) { - build = await api.getLastBuild(`${owner}/${repo}/${branch}`); - } else { - build = await api.getFolder(`${owner}/${repo}`); - } - return build; - } catch (e) { - errorApi.post(e); - return Promise.reject(e); - } - }, [api, branch, errorApi, owner, repo]); - const restartBuild = async (buildName: string) => { try { await api.retry(buildName); @@ -49,18 +34,24 @@ export function useBuilds(owner: string, repo: string, branch?: string) { } }; - useEffect(() => { - getBuilds().then(b => { - const size = Array.isArray(b) ? b?.[0].build_num! : 1; - setTotal(size); - }); - }, [repo, getBuilds]); + const { loading, value: builds, retry } = useAsyncRetry(async () => { + try { + let builds; + if (branch) { + builds = await api.getLastBuild(`${owner}/${repo}/${branch}`); + } else { + builds = await api.getFolder(`${owner}/${repo}`); + } - const { loading, value: builds, retry } = useAsyncRetry( - () => - getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild), - [page, pageSize, getBuilds], - ); + const size = Array.isArray(builds) ? builds?.[0].build_num! : 1; + setTotal(size); + + return builds || []; + } catch (e) { + errorApi.post(e); + throw e; + } + }, [api, errorApi, owner, repo, branch]); const projectName = `${owner}/${repo}`; return [ diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 2834082657..b97bc82242 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/plugin-kubernetes-backend +## 0.2.2 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.2.1 + +### Patch Changes + +- bcc211a08: k8s-plugin: refactor approach to use annotation based label-selector +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + +## 0.2.0 + +### Minor Changes + +- 1166fcc36: add kubernetes selector to component model + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.1.2 ### Patch Changes diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index bf3e7d261a..5f541bb61d 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -42,7 +42,7 @@ kubernetes: Mac copy to clipboard: ``` -kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r .secrets[0].name) -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy +kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r '.secrets[0].name') -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy ``` Paste into `app-config.local.yaml` `kubernetes.clusters[0].serviceAccountToken` diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 7ac5f99f4c..2a6f56ef33 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.1.2", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,17 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/kubernetes-backend" + }, + "keywords": [ + "backstage", + "kubernetes" + ], + "configSchema": "schema.d.ts", "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,8 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.4.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", "@kubernetes/client-node": "^0.12.1", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -37,10 +49,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "supertest": "^4.0.2" }, "files": [ - "dist" + "dist", + "schema.d.ts" ] } diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts new file mode 100644 index 0000000000..862cb6d2ce --- /dev/null +++ b/plugins/kubernetes-backend/schema.d.ts @@ -0,0 +1,27 @@ +/* + * 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 Config { + kubernetes?: { + serviceLocatorMethod: 'multiTenant'; + clusterLocatorMethods: 'config'[]; + clusters: { + url: string; + name: string; + serviceAccountToken: string; + authProvider: 'serviceAccount'; + }[]; + }; +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 32a09cb496..5677fa2d0d 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Config, ConfigReader } from '../../../../packages/config/src'; +import { Config, ConfigReader } from '@backstage/config'; import { getCombinedClusterDetails } from './index'; describe('getCombinedClusterDetails', () => { diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index ea418768b4..9e6558510c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,7 +15,7 @@ */ import { ClusterDetails, ClusterLocatorMethod } from '..'; -import { Config } from '../../../../packages/config/src'; +import { Config } from '@backstage/config'; import { ConfigClusterLocator } from './ConfigClusterLocator'; export { ConfigClusterLocator } from './ConfigClusterLocator'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts index 7f9b2f0bae..0dd2a4bcc2 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -15,13 +15,13 @@ */ import { KubernetesAuthTranslator } from './types'; -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export class GoogleKubernetesAuthTranslator implements KubernetesAuthTranslator { async decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise { const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( {}, diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts index ecf2f12b72..6433e41546 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -15,7 +15,7 @@ */ import { KubernetesAuthTranslator } from './types'; -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export class ServiceAccountKubernetesAuthTranslator implements KubernetesAuthTranslator { @@ -23,7 +23,7 @@ export class ServiceAccountKubernetesAuthTranslator clusterDetails: ClusterDetails, // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. // @ts-ignore-start - requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars + requestBody: KubernetesRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars // @ts-ignore-end ): Promise { return clusterDetails; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts index f89e04456f..c01a57889c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export interface KubernetesAuthTranslator { decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise; } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 6294b3a7ed..29233cf7fd 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -16,6 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { ObjectFetchParams } from '..'; describe('KubernetesClientProvider', () => { let clientMock: any; @@ -57,16 +58,17 @@ describe('KubernetesClientProvider', () => { clientMock.listServiceForAllNamespaces.mockRejectedValue(errorResponse); - const result = await sut.fetchObjectsByServiceId( - 'some-service', - { + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['pods', 'services']), - ); + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); expect(result).toStrictEqual({ errors: [expectedResult], @@ -120,16 +122,17 @@ describe('KubernetesClientProvider', () => { }, }); - const result = await sut.fetchObjectsByServiceId( - 'some-service', - { + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['pods', 'services']), - ); + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); expect(result).toStrictEqual({ errors: [], @@ -169,16 +172,17 @@ describe('KubernetesClientProvider', () => { }); it('should throw error on unknown type', () => { expect(() => - sut.fetchObjectsByServiceId( - 'some-service', - { + sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['foo']), - ), + objectTypesToFetch: new Set(['foo']), + labelSelector: '', + }), ).toThrow('unrecognised type=foo'); expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(0); @@ -254,4 +258,46 @@ describe('KubernetesClientProvider', () => { }, ); }); + it('should always add a labelSelector query', async () => { + clientMock.listPodForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + }); + + clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'service-name', + }, + }, + ], + }, + }); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); + + const mockCall = clientMock.listPodForAllNamespaces.mock.calls[0]; + const actualSelector = mockCall[mockCall.length - 1]; + const expectedSelector = 'backstage.io/kubernetes-id=some-service'; + expect(actualSelector).toBe(expectedSelector); + }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index f66a45816a..d9c6ee6d1a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -38,6 +38,7 @@ import { FetchResponseWrapper, KubernetesFetchError, KubernetesErrorTypes, + ObjectFetchParams, } from '..'; import lodash, { Dictionary } from 'lodash'; @@ -105,15 +106,16 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { this.logger = logger; } - fetchObjectsByServiceId( - serviceId: string, - clusterDetails: ClusterDetails, - objectTypesToFetch: Set, + fetchObjectsForService( + params: ObjectFetchParams, ): Promise { - const fetchResults = Array.from(objectTypesToFetch).map(type => { - return this.fetchByObjectType(serviceId, clusterDetails, type).catch( - captureKubernetesErrorsRethrowOthers, - ); + const fetchResults = Array.from(params.objectTypesToFetch).map(type => { + return this.fetchByObjectType( + params.clusterDetails, + type, + params.labelSelector || + `backstage.io/kubernetes-id=${params.serviceId}`, + ).catch(captureKubernetesErrorsRethrowOthers); }); return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); @@ -121,45 +123,47 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { // TODO could probably do with a tidy up private fetchByObjectType( - serviceId: string, clusterDetails: ClusterDetails, type: KubernetesObjectTypes, + labelSelector: string, ): Promise { switch (type) { case 'pods': - return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({ - type: type, - resources: r, - })); + return this.fetchPodsForService(clusterDetails, labelSelector).then( + r => ({ + type: type, + resources: r, + }), + ); case 'configmaps': - return this.fetchConfigMapsByServiceId( - serviceId, + return this.fetchConfigMapsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'deployments': - return this.fetchDeploymentsByServiceId( - serviceId, + return this.fetchDeploymentsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'replicasets': - return this.fetchReplicaSetsByServiceId( - serviceId, + return this.fetchReplicaSetsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'services': - return this.fetchServicesByServiceId( - serviceId, + return this.fetchServicesForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'horizontalpodautoscalers': - return this.fetchHorizontalPodAutoscalersByServiceId( - serviceId, + return this.fetchHorizontalPodAutoscalersForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'ingresses': - return this.fetchIngressesByServiceId( - serviceId, + return this.fetchIngressesForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); default: // unrecognised type @@ -192,79 +196,54 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { }); } - private fetchServicesByServiceId( - serviceId: string, + private fetchServicesForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listServiceForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listServiceForAllNamespaces(false, '', '', labelSelector), ); } - private fetchPodsByServiceId( - serviceId: string, + private fetchPodsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listPodForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listPodForAllNamespaces(false, '', '', labelSelector), ); } - private fetchConfigMapsByServiceId( - serviceId: string, + private fetchConfigMapsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listConfigMapForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listConfigMapForAllNamespaces(false, '', '', labelSelector), ); } - private fetchDeploymentsByServiceId( - serviceId: string, + private fetchDeploymentsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ apps }) => - apps.listDeploymentForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + apps.listDeploymentForAllNamespaces(false, '', '', labelSelector), ); } - private fetchReplicaSetsByServiceId( - serviceId: string, + private fetchReplicaSetsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ apps }) => - apps.listReplicaSetForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + apps.listReplicaSetForAllNamespaces(false, '', '', labelSelector), ); } - private fetchHorizontalPodAutoscalersByServiceId( - serviceId: string, + private fetchHorizontalPodAutoscalersForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch( clusterDetails, @@ -273,14 +252,14 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { false, '', '', - `backstage.io/kubernetes-id=${serviceId}`, + labelSelector, ), ); } - private fetchIngressesByServiceId( - serviceId: string, + private fetchIngressesForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch( clusterDetails, @@ -289,7 +268,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { false, '', '', - `backstage.io/kubernetes-id=${serviceId}`, + labelSelector, ), ); } diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 8c26ded2d4..5c6513e57c 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -14,18 +14,18 @@ * limitations under the License. */ -import { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler'; +import { handleGetKubernetesObjectsForService } from './getKubernetesObjectsForServiceHandler'; import { getVoidLogger } from '@backstage/backend-common'; -import { ClusterDetails } from '..'; +import { ObjectFetchParams } from '..'; const TEST_SERVICE_ID = 'my-service'; -const fetchObjectsByServiceId = jest.fn(); +const fetchObjectsForService = jest.fn(); const getClustersByServiceId = jest.fn(); const mockFetch = (mock: jest.Mock) => { - mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) => + mock.mockImplementation((params: ObjectFetchParams) => Promise.resolve({ errors: [], responses: [ @@ -34,7 +34,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-pods-${serviceId}-${clusterDetails.name}`, + name: `my-pods-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -44,7 +44,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-configmaps-${serviceId}-${clusterDetails.name}`, + name: `my-configmaps-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -54,7 +54,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-services-${serviceId}-${clusterDetails.name}`, + name: `my-services-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -64,7 +64,7 @@ const mockFetch = (mock: jest.Mock) => { ); }; -describe('handleGetKubernetesObjectsByServiceId', () => { +describe('handleGetKubernetesObjectsForService', () => { beforeEach(() => { jest.resetAllMocks(); }); @@ -79,22 +79,39 @@ describe('handleGetKubernetesObjectsByServiceId', () => { ]), ); - mockFetch(fetchObjectsByServiceId); + mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsByServiceId( + const result = await handleGetKubernetesObjectsForService( TEST_SERVICE_ID, { - fetchObjectsByServiceId, + fetchObjectsForService: fetchObjectsForService, }, { getClustersByServiceId, }, getVoidLogger(), - {}, + { + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, + }, + }, ); expect(getClustersByServiceId.mock.calls.length).toBe(1); - expect(fetchObjectsByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls.length).toBe(1); expect(result).toStrictEqual({ items: [ { @@ -153,12 +170,12 @@ describe('handleGetKubernetesObjectsByServiceId', () => { ]), ); - mockFetch(fetchObjectsByServiceId); + mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsByServiceId( + const result = await handleGetKubernetesObjectsForService( TEST_SERVICE_ID, { - fetchObjectsByServiceId, + fetchObjectsForService: fetchObjectsForService, }, { getClustersByServiceId, @@ -168,11 +185,27 @@ describe('handleGetKubernetesObjectsByServiceId', () => { auth: { google: 'google_token_123', }, + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, + }, }, ); expect(getClustersByServiceId.mock.calls.length).toBe(1); - expect(fetchObjectsByServiceId.mock.calls.length).toBe(2); + expect(fetchObjectsForService.mock.calls.length).toBe(2); expect(result).toStrictEqual({ items: [ { diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts similarity index 74% rename from plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts rename to plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts index 6fc6092bd6..2dd1e32b96 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts @@ -16,24 +16,25 @@ import { Logger } from 'winston'; import { - AuthRequestBody, + KubernetesRequestBody, ClusterDetails, KubernetesServiceLocator, KubernetesFetcher, KubernetesObjectTypes, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, + ObjectFetchParams, } from '../types/types'; import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; -export type GetKubernetesObjectsByServiceIdHandler = ( +export type GetKubernetesObjectsForServiceHandler = ( serviceId: string, fetcher: KubernetesFetcher, serviceLocator: KubernetesServiceLocator, logger: Logger, - requestBody: AuthRequestBody, - objectsToFetch?: Set, -) => Promise; + requestBody: KubernetesRequestBody, + objectTypesToFetch?: Set, +) => Promise; const DEFAULT_OBJECTS = new Set([ 'pods', @@ -46,13 +47,13 @@ const DEFAULT_OBJECTS = new Set([ ]); // Fans out the request to all clusters that the service lives in, aggregates their responses together -export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( +export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServiceHandler = async ( serviceId, fetcher, serviceLocator, logger, requestBody, - objectsToFetch = DEFAULT_OBJECTS, + objectTypesToFetch = DEFAULT_OBJECTS, ) => { const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId( serviceId, @@ -78,14 +79,24 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic .join(', ')}]`, ); + const labelSelector: string = + requestBody.entity?.metadata?.annotations?.[ + 'backstage.io/kubernetes-label-selector' + ] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`; + return Promise.all( - clusterDetailsDecoratedForAuth.map(cd => { + clusterDetailsDecoratedForAuth.map(clusterDetails => { return fetcher - .fetchObjectsByServiceId(serviceId, cd, objectsToFetch) + .fetchObjectsForService({ + serviceId, + clusterDetails, + objectTypesToFetch, + labelSelector, + } as ObjectFetchParams) .then(result => { return { cluster: { - name: cd.name, + name: clusterDetails.name, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 6f9fbbf70f..a407ce9693 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -21,18 +21,18 @@ import { makeRouter } from './router'; import { KubernetesServiceLocator, KubernetesFetcher, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, } from '..'; describe('router', () => { let app: express.Express; let kubernetesFetcher: jest.Mocked; let kubernetesServiceLocator: jest.Mocked; - let handleGetByServiceId: jest.Mock>; + let handleGetByServiceId: jest.Mock>; beforeAll(async () => { kubernetesFetcher = { - fetchObjectsByServiceId: jest.fn(), + fetchObjectsForService: jest.fn(), }; kubernetesServiceLocator = { diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index b73115e1e4..38a02ad970 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -22,11 +22,11 @@ import { MultiTenantServiceLocator } from '../service-locator/MultiTenantService import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesClientProvider } from './KubernetesClientProvider'; import { - GetKubernetesObjectsByServiceIdHandler, - handleGetKubernetesObjectsByServiceId, -} from './getKubernetesObjectsByServiceIdHandler'; + GetKubernetesObjectsForServiceHandler, + handleGetKubernetesObjectsForService, +} from './getKubernetesObjectsForServiceHandler'; import { - AuthRequestBody, + KubernetesRequestBody, KubernetesServiceLocator, KubernetesFetcher, ServiceLocatorMethod, @@ -64,16 +64,16 @@ export const makeRouter = ( logger: Logger, fetcher: KubernetesFetcher, serviceLocator: KubernetesServiceLocator, - handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler, + handleGetByEntity: GetKubernetesObjectsForServiceHandler, ): express.Router => { const router = Router(); router.use(express.json()); router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; - const requestBody: AuthRequestBody = req.body; + const requestBody: KubernetesRequestBody = req.body; try { - const response = await handleGetByServiceId( + const response = await handleGetByEntity( serviceId, fetcher, serviceLocator, @@ -119,6 +119,6 @@ export async function createRouter( logger, fetcher, serviceLocator, - handleGetKubernetesObjectsByServiceId, + handleGetKubernetesObjectsForService, ); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 1422511c21..9f0b90c032 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -23,6 +23,7 @@ import { V1ReplicaSet, V1Service, } from '@kubernetes/client-node'; +import { Entity } from '@backstage/catalog-model'; export interface ClusterDetails { name: string; @@ -31,10 +32,11 @@ export interface ClusterDetails { serviceAccountToken?: string | undefined; } -export interface AuthRequestBody { +export interface KubernetesRequestBody { auth?: { google?: string; }; + entity: Entity; } export interface ClusterObjects { @@ -43,7 +45,7 @@ export interface ClusterObjects { errors: KubernetesFetchError[]; } -export interface ObjectsByServiceIdResponse { +export interface ObjectsByEntityResponse { items: ClusterObjects[]; } @@ -107,13 +109,18 @@ export interface IngressesFetchResponse { resources: Array; } +export interface ObjectFetchParams { + serviceId: string; + clusterDetails: ClusterDetails; + objectTypesToFetch: Set; + labelSelector: string; +} + // Fetches information from a kubernetes cluster using the cluster details object // to target a specific cluster export interface KubernetesFetcher { - fetchObjectsByServiceId( - serviceId: string, - clusterDetails: ClusterDetails, - objectTypesToFetch: Set, + fetchObjectsForService( + params: ObjectFetchParams, ): Promise; } diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index 0ec2c5dbaf..7ad72103a3 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,64 @@ # @backstage/plugin-kubernetes +## 0.3.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [e3bd9fc2f] + - @backstage/core@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-kubernetes-backend@0.2.2 + +## 0.3.1 + +### Patch Changes + +- bcc211a08: k8s-plugin: refactor approach to use annotation based label-selector +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-kubernetes-backend@0.2.1 + +## 0.3.0 + +### Minor Changes + +- 1166fcc36: add kubernetes selector to component model + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-kubernetes-backend@0.2.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md index 1ad478bd89..a343764b70 100644 --- a/plugins/kubernetes/README.md +++ b/plugins/kubernetes/README.md @@ -14,7 +14,12 @@ It is only meant for local development, and the setup for it can be found inside ## Surfacing your Kubernetes components as part of an entity -### Adding the entity annotation +There are two ways to surface your kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Common `backstage.io/kubernetes-id` label + +#### Adding the entity annotation In order for Backstage to detect that an entity has Kubernetes components, the following annotation should be added to the entity. @@ -24,7 +29,7 @@ annotations: 'backstage.io/kubernetes-id': dice-roller ``` -### Labeling Kubernetes components +#### Labeling Kubernetes components In order for Kubernetes components to show up in the service catalog as a part of an entity, Kubernetes components must be labeled with the following label: @@ -32,3 +37,14 @@ as a part of an entity, Kubernetes components must be labeled with the following ```yaml 'backstage.io/kubernetes-id': ``` + +### label selector query annotation + +#### Adding a label selector query annotation + +You can write your own custom label selector query that backstage will use to lookup the objects (similar to `kubectl --selector="your query here"`) +review the documentation [here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) for more info + +```yaml +'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' +``` diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 29193e27ed..a7b6da2ce3 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.2.0", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,17 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/kubernetes" + }, + "keywords": [ + "backstage", + "kubernetes" + ], + "configSchema": "schema.d.ts", "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.2.0", - "@backstage/plugin-kubernetes-backend": "^0.1.2", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.4.0", + "@backstage/plugin-kubernetes-backend": "^0.2.2", + "@backstage/theme": "^0.2.2", "@kubernetes/client-node": "^0.12.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -35,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -47,6 +58,7 @@ "msw": "^0.21.2" }, "files": [ - "dist" + "dist", + "schema.d.ts" ] } diff --git a/plugins/kubernetes/schema.d.ts b/plugins/kubernetes/schema.d.ts new file mode 100644 index 0000000000..f3f3c98f8c --- /dev/null +++ b/plugins/kubernetes/schema.d.ts @@ -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. + */ +export interface Config { + kubernetes?: { + /** + * @visibility frontend + */ + serviceLocatorMethod: 'multiTenant'; + /** + * @visibility frontend + */ + clusterLocatorMethods: 'config'[]; + clusters: { + /** + * @visibility frontend + */ + url: string; + /** + * @visibility frontend + */ + name: string; + /** + * @visibility secret + */ + serviceAccountToken: string; + /** + * @visibility frontend + */ + authProvider: 'serviceAccount'; + }[]; + }; +} diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 86bb830046..b81a799d6b 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -17,8 +17,8 @@ import { DiscoveryApi } from '@backstage/core'; import { KubernetesApi } from './types'; import { - AuthRequestBody, - ObjectsByServiceIdResponse, + KubernetesRequestBody, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; export class KubernetesBackendClient implements KubernetesApi { @@ -30,7 +30,7 @@ export class KubernetesBackendClient implements KubernetesApi { private async getRequired( path: string, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; const response = await fetch(url, { @@ -50,10 +50,12 @@ export class KubernetesBackendClient implements KubernetesApi { return await response.json(); } - async getObjectsByServiceId( - serviceId: String, - requestBody: AuthRequestBody, - ): Promise { - return await this.getRequired(`/services/${serviceId}`, requestBody); + async getObjectsByEntity( + requestBody: KubernetesRequestBody, + ): Promise { + return await this.getRequired( + `/services/${requestBody.entity.metadata.name}`, + requestBody, + ); } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 8e44d58e6a..498e74a626 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -16,8 +16,8 @@ import { createApiRef } from '@backstage/core'; import { - AuthRequestBody, - ObjectsByServiceIdResponse, + KubernetesRequestBody, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; export const kubernetesApiRef = createApiRef({ @@ -27,8 +27,7 @@ export const kubernetesApiRef = createApiRef({ }); export interface KubernetesApi { - getObjectsByServiceId( - serviceId: String, - requestBody: AuthRequestBody, - ): Promise; + getObjectsByEntity( + requestBody: KubernetesRequestBody, + ): Promise; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index a3e82e79a4..65c1102d47 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -29,10 +29,10 @@ import { import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { - AuthRequestBody, + KubernetesRequestBody, ClusterObjects, FetchResponse, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; import { DeploymentTables } from '../DeploymentTables'; @@ -104,7 +104,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { const kubernetesApi = useApi(kubernetesApiRef); const [kubernetesObjects, setKubernetesObjects] = useState< - ObjectsByServiceIdResponse | undefined + ObjectsByEntityResponse | undefined >(undefined); const [error, setError] = useState(undefined); @@ -120,7 +120,9 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { useEffect(() => { (async () => { // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider - let requestBody: AuthRequestBody = {}; + let requestBody: KubernetesRequestBody = { + entity, + }; for (const authProviderStr of authProviders) { // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( @@ -131,7 +133,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { // TODO: Add validation on contents/format of requestBody kubernetesApi - .getObjectsByServiceId(entity.metadata.name, requestBody) + .getObjectsByEntity(requestBody) .then(result => { setKubernetesObjects(result); }) diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 7de5c7c0a9..570806a1a4 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -16,7 +16,7 @@ import { OAuthApi } from '@backstage/core'; import { KubernetesAuthProvider } from './types'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { authProvider: OAuthApi; @@ -26,8 +26,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } async decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { const googleAuthToken: string = await this.authProvider.getAccessToken( 'https://www.googleapis.com/auth/cloud-platform', ); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index ac909d6695..9f64af9c2e 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -15,7 +15,7 @@ */ import { OAuthApi } from '@backstage/core'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; @@ -40,8 +40,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { async decorateRequestBodyForAuth( authProvider: string, - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { const kubernetesAuthProvider: | KubernetesAuthProvider | undefined = this.kubernetesAuthProviderMap.get(authProvider); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts index 3ac5a9494b..5d33521ae4 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts @@ -15,13 +15,13 @@ */ import { KubernetesAuthProvider } from './types'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export class ServiceAccountKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { // No-op, with service account for auth, cluster config/details should already have serviceAccountToken return requestBody; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index 24fee0f94c..5bd253d2c1 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -15,12 +15,12 @@ */ import { createApiRef } from '@backstage/core'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export interface KubernetesAuthProvider { decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise; + requestBody: KubernetesRequestBody, + ): Promise; } export const kubernetesAuthProvidersApiRef = createApiRef< @@ -33,6 +33,6 @@ export const kubernetesAuthProvidersApiRef = createApiRef< export interface KubernetesAuthProvidersApi { decorateRequestBodyForAuth( authProvider: string, - requestBody: AuthRequestBody, - ): Promise; + requestBody: KubernetesRequestBody, + ): Promise; } diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 9a68b78a92..3d1d1a491d 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,80 @@ # @backstage/plugin-lighthouse +## 0.2.5 + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [e3bd9fc2f] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/core-api@0.2.4 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index cdcc2fe2c4..1f1d32937f 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -27,8 +27,7 @@ your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/pac to enable the plugin: ```js -import { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export LighthousePlugin; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; ``` Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in @@ -74,7 +73,7 @@ metadata: lighthouse.com/website-url: # A single website url e.g. https://backstage.io/ ``` -> NOTE: The lighthouse plugin only supports one website url per component at this time. +> NOTE: The lighthouse plugin only supports one website URL per component at this time. Add a lighthouse tab to the EntityPage: diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 27b6a21066..6a5082fcab 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.0", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/lighthouse" + }, + "keywords": [ + "backstage", + "lighthouse" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,36 +31,51 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.2.0", - "@backstage/core-api": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.4.0", + "@backstage/core-api": "^0.2.5", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@testing-library/react-hooks": "^3.4.2", - "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-markdown": "^4.3.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/lighthouse", + "type": "object", + "properties": { + "lighthouse": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } } diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 332dd5e3ca..0fb66a317b 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Table, TableColumn, TrendLine, useApi } from '@backstage/core'; import { Website, lighthouseApiRef } from '../../api'; import { useInterval } from 'react-use'; @@ -52,7 +52,7 @@ const columns: TableColumn[] = [ }, ]; -export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { +export const AuditListTable = ({ items }: { items: Website[] }) => { const [websiteState, setWebsiteState] = useState(items); const lighthouseApi = useApi(lighthouseApiRef); diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index c6411a9baa..b022482da4 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useMemo, FC, ReactNode } from 'react'; +import React, { useState, useMemo, ReactNode } from 'react'; import { useLocalStorage, useAsync } from 'react-use'; import { useNavigate } from 'react-router-dom'; import { Grid, Button } from '@material-ui/core'; @@ -39,7 +39,7 @@ import { createAuditRouteRef } from '../../plugin'; export const LIMIT = 10; -const AuditList: FC<{}> = () => { +const AuditList = () => { const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); const [dismissed, setDismissed] = useState(dismissedStored); diff --git a/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx index bce917d428..df6dcd148d 100644 --- a/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx +++ b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { StatusPending, StatusError, StatusOK } from '@backstage/core'; import { Audit } from '../../api'; -const AuditStatusIcon: FC<{ audit: Audit }> = ({ audit }) => { +const AuditStatusIcon = ({ audit }: { audit: Audit }) => { if (audit.status === 'FAILED') return ; if (audit.status === 'COMPLETED') return ; return ; diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index ac38a329f1..2a99339a84 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect, ReactNode, FC } from 'react'; +import React, { useState, useEffect, ReactNode } from 'react'; import { Link, useParams, @@ -65,10 +65,7 @@ interface AuditLinkListProps { audits?: Audit[]; selectedId: string; } -const AuditLinkList: FC = ({ - audits = [], - selectedId, -}: AuditLinkListProps) => ( +const AuditLinkList = ({ audits = [], selectedId }: AuditLinkListProps) => ( = ({ ); -const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { +const AuditView = ({ audit }: { audit?: Audit }) => { const classes = useStyles(); const params = useParams() as { id: string }; const { url: lighthouseUrl } = useApi(lighthouseApiRef); @@ -123,7 +120,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { ); }; -export const AuditViewContent: FC<{}> = () => { +export const AuditViewContent = () => { const lighthouseApi = useApi(lighthouseApiRef); const params = useParams() as { id: string }; const classes = useStyles(); diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx index 3f7020cba5..0bb83077ce 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; import { InfoCard, @@ -26,7 +26,7 @@ import { import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import AuditStatusIcon from '../AuditStatusIcon'; -const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => { +const LighthouseCategoryScoreStatus = ({ score }: { score: number }) => { const scoreAsPercentage = score * 100; switch (true) { case scoreAsPercentage >= 90: @@ -55,16 +55,19 @@ const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => { } }; -const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => ( +const LighthouseAuditStatus = ({ audit }: { audit: Audit }) => ( <> {audit.status.toUpperCase()} ); -const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ +const LighthouseAuditSummary = ({ audit, dense = false, +}: { + audit: Audit; + dense?: boolean; }) => { const { url } = audit; const flattenedCategoryData: Record = {}; @@ -88,10 +91,13 @@ const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ return ; }; -export const LastLighthouseAuditCard: FC<{ +export const LastLighthouseAuditCard = ({ + dense = false, + variant, +}: { dense?: boolean; variant?: string; -}> = ({ dense = false, variant }) => { +}) => { const { value: website, loading, error } = useWebsiteForEntity(); let content; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 55800d79ec..18f2c30a6e 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useCallback, FC } from 'react'; +import React, { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { makeStyles, @@ -63,7 +63,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CreateAuditContent: FC<{}> = () => { +export const CreateAuditContent = () => { const errorApi = useApi(errorApiRef); const lighthouseApi = useApi(lighthouseApiRef); const classes = useStyles(); diff --git a/plugins/lighthouse/src/components/Intro/index.tsx b/plugins/lighthouse/src/components/Intro/index.tsx index dc2a8ea283..b140141c9f 100644 --- a/plugins/lighthouse/src/components/Intro/index.tsx +++ b/plugins/lighthouse/src/components/Intro/index.tsx @@ -15,8 +15,7 @@ */ import React, { useState } from 'react'; import { useLocalStorage } from 'react-use'; -import Markdown from 'react-markdown'; -import { ContentHeader, InfoCard } from '@backstage/core'; +import { ContentHeader, InfoCard, MarkdownContent } from '@backstage/core'; import { makeStyles, Button, Grid, Tabs, Tab } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; @@ -27,7 +26,7 @@ export const LIGHTHOUSE_INTRO_LOCAL_STORAGE = const USE_CASES = ` Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites -is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site. +is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site. At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment. This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the @@ -47,8 +46,7 @@ your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/p to enable the plugin: \`\`\`js -import { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export LighthousePlugin; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; \`\`\` Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in @@ -116,8 +114,8 @@ function GettingStartedCard() { } > - {value === 0 && } - {value === 1 && } + {value === 0 && } + {value === 1 && } ); } diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 580162f182..7f5925abf8 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { renderHook } from '@testing-library/react-hooks'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api'; import { lighthouseApiRef, WebsiteListResponse } from '../api'; @@ -51,7 +51,7 @@ describe('useWebsiteForEntity', () => { }, }; - const wrapper: React.FC<{}> = ({ children }) => { + const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( = () => ( +const NewRelicComponent = () => (
diff --git a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx index 62f52e7875..153a80501f 100644 --- a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Progress, Table, TableColumn, useApi } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; import { useAsync } from 'react-use'; import { newRelicApiRef, NewRelicApplications } from '../../api'; -export const NewRelicAPMTable: FC = ({ - applications, -}) => { +export const NewRelicAPMTable = ({ applications }: NewRelicApplications) => { const columns: TableColumn[] = [ { title: 'Application', field: 'name' }, { title: 'Response Time', field: 'responseTime' }, @@ -61,7 +59,7 @@ export const NewRelicAPMTable: FC = ({ ); }; -const NewRelicFetchComponent: FC<{}> = () => { +const NewRelicFetchComponent = () => { const api = useApi(newRelicApiRef); const { value, loading, error } = useAsync(async () => { diff --git a/plugins/org/.eslintrc.js b/plugins/org/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/org/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md new file mode 100644 index 0000000000..541c11b35b --- /dev/null +++ b/plugins/org/CHANGELOG.md @@ -0,0 +1,18 @@ +# @backstage/plugin-org + +## 0.3.1 + +### Patch Changes + +- 2b71db211: Support transitive ownerships of users and groups. +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 diff --git a/plugins/org/README.md b/plugins/org/README.md new file mode 100644 index 0000000000..a117f36722 --- /dev/null +++ b/plugins/org/README.md @@ -0,0 +1,6 @@ +# Org Plugin for Backstage + +## Features + +- Show Group Page +- Show User Profile diff --git a/plugins/org/dev/index.tsx b/plugins/org/dev/index.tsx new file mode 100644 index 0000000000..264d6f801f --- /dev/null +++ b/plugins/org/dev/index.tsx @@ -0,0 +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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/org/package.json b/plugins/org/package.json new file mode 100644 index 0000000000..010fad775e --- /dev/null +++ b/plugins/org/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-org", + "version": "0.3.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx new file mode 100644 index 0000000000..779cb1d92d --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -0,0 +1,154 @@ +/* + * 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, + GroupEntity, + RELATION_CHILD_OF, + RELATION_PARENT_OF, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard } from '@backstage/core'; +import { entityRouteParams } from '@backstage/plugin-catalog'; +import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; +import AccountTreeIcon from '@material-ui/icons/AccountTree'; +import EmailIcon from '@material-ui/icons/Email'; +import GroupIcon from '@material-ui/icons/Group'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const GroupLink = ({ + groupName, + index = 0, + entity, +}: { + groupName: string; + index?: number; + entity: Entity; +}) => ( + <> + {index >= 1 ? ', ' : ''} + + [{groupName}] + + +); + +const CardTitle = ({ title }: { title: string }) => ( + + + {title} + +); + +export const GroupProfileCard = ({ + entity: group, + variant, +}: { + entity: GroupEntity; + variant: string; +}) => { + const { + metadata: { name, description }, + spec: { profile }, + } = group; + const parent = group?.relations + ?.filter(r => r.type === RELATION_CHILD_OF) + ?.map(group => group.target.name) + .toString(); + + const childrens = group?.relations + ?.filter(r => r.type === RELATION_PARENT_OF) + ?.map(group => group.target.name); + + const displayName = profile?.displayName ?? name; + + if (!group) return User not found; + + return ( + } + subheader={description} + variant={variant} + > + + + + + + + + {profile?.email && ( + + + + + + + + {profile.email} + + + + )} + {parent ? ( + + + + + + + + + + + ) : null} + {childrens?.length ? ( + + + + + + + {childrens.map((children, index) => ( + + ))} + + + + ) : null} + + + + ); +}; diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/index.ts b/plugins/org/src/components/Cards/Group/GroupProfile/index.ts new file mode 100644 index 0000000000..44efe25a50 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './GroupProfileCard'; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx new file mode 100644 index 0000000000..535b2acd0e --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { MembersListCard } from './MembersListCard'; + +describe('MemberTab Test', () => { + const groupEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-d', + description: 'The evil-corp organization', + namespace: 'default', + }, + spec: { + type: 'team', + parent: 'boxoffice', + children: [], + }, + }; + + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'tara.macgovern', + namespace: 'default', + uid: 'a5gerth56', + }, + relations: [ + { + type: 'memberOf', + target: { + kind: 'group', + name: 'team-d', + namespace: 'default', + }, + }, + ], + spec: { + profile: { + displayName: 'Tara MacGovern', + email: 'tara-macgovern@example.com', + picture: 'https://example.com/staff/tara.jpeg', + }, + memberOf: ['team-d'], + }, + }, + ] as Entity[], + }), + }; + + const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]); + + it('Display Profile Card', async () => { + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(rendered.getByAltText('Tara MacGovern')).toHaveAttribute( + 'src', + 'https://example.com/staff/tara.jpeg', + ); + expect( + rendered.getByText('tara-macgovern@example.com'), + ).toBeInTheDocument(); + expect(rendered.getByText('Tara MacGovern')).toHaveAttribute( + 'href', + '/catalog/default/user/tara.macgovern', + ); + }); +}); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx new file mode 100644 index 0000000000..579db9d9a5 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -0,0 +1,164 @@ +/* + * 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, + GroupEntity, + RELATION_MEMBER_OF, + UserEntity, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; +import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog'; +import { + Box, + createStyles, + Grid, + Link, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { useAsync } from 'react-use'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + overflow: 'visible', + position: 'relative', + margin: theme.spacing(3, 0, 1), + flex: '1', + minWidth: '0px', + }, + }), +); + +const MemberComponent = ({ + member, + groupEntity, +}: { + member: UserEntity; + groupEntity: Entity; +}) => { + const classes = useStyles(); + const { + metadata: { name: metaName }, + spec: { profile }, + } = member; + const displayName = profile?.displayName ?? metaName; + + return ( + + + + + + + + {displayName} + + + {profile?.email} + + + + + ); +}; + +export const MembersListCard = ({ + entity: groupEntity, +}: { + entity: GroupEntity; +}) => { + const { + metadata: { name: groupName }, + spec: { profile }, + } = groupEntity; + const catalogApi = useApi(catalogApiRef); + + const displayName = profile?.displayName ?? groupName; + + const { loading, error, value: members } = useAsync(async () => { + const membersList = await catalogApi.getEntities({ + filter: { + kind: 'User', + }, + }); + const groupMembersList = ((membersList.items as unknown) as Array< + UserEntity + >).filter(member => + member?.relations?.some( + r => r.type === RELATION_MEMBER_OF && r.target.name === groupName, + ), + ); + return groupMembersList; + }, [catalogApi]); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + return ( + + + + {members && members.length ? ( + members.map(member => ( + + )) + ) : ( + + This group has no members. + + )} + + + + ); +}; diff --git a/plugins/org/src/components/Cards/Group/MembersList/index.ts b/plugins/org/src/components/Cards/Group/MembersList/index.ts new file mode 100644 index 0000000000..c3f4ea9178 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './MembersListCard'; diff --git a/plugins/org/src/components/Cards/Group/index.ts b/plugins/org/src/components/Cards/Group/index.ts new file mode 100644 index 0000000000..a011891f62 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/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 './MembersList'; +export * from './GroupProfile'; diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx new file mode 100644 index 0000000000..16a833cf56 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -0,0 +1,193 @@ +/* + * 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 { InfoCard, useApi, Progress } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { useAsync } from 'react-use'; +import Alert from '@material-ui/lab/Alert'; +import { + Box, + createStyles, + Grid, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import { pageTheme } from '@backstage/theme'; +import { isOwnerOf } from '../../isOwnerOf'; + +type EntitiesKinds = 'Component' | 'API'; +type EntitiesTypes = + | 'service' + | 'website' + | 'library' + | 'documentation' + | 'api' + | 'tool'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + padding: theme.spacing(2), + color: '#fff', + transition: `${theme.transitions.duration.standard}ms`, + '&:hover': { + boxShadow: theme.shadows[4], + }, + }, + bold: { + fontWeight: theme.typography.fontWeightBold, + }, + service: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.service.colors})`, + }, + website: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.website.colors})`, + }, + library: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.library.colors})`, + }, + documentation: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.documentation.colors})`, + }, + api: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, #005B4B, #005B4B)`, + }, + tool: { + background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.tool.colors})`, + }, + }), +); + +const countEntitiesBy = ( + entities: Array, + kind: EntitiesKinds, + type?: EntitiesTypes, +) => + entities.filter( + e => e.kind === kind && (type ? e?.spec?.type === type : true), + ).length; + +const EntityCountTile = ({ + counter, + className, + name, +}: { + counter: number; + className: EntitiesTypes; + name: string; +}) => { + const classes = useStyles(); + return ( + + + {counter} + + + {name} + + + ); +}; + +export const OwnershipCard = ({ + entity, + variant, +}: { + entity: Entity; + variant: string; +}) => { + const catalogApi = useApi(catalogApiRef); + const { + loading, + error, + value: componentsWithCounters, + } = useAsync(async () => { + const entitiesList = await catalogApi.getEntities(); + const ownedEntitiesList = entitiesList.items.filter(component => + isOwnerOf(entity, component), + ); + + return [ + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'service'), + className: 'service', + name: 'Services', + }, + { + counter: countEntitiesBy( + ownedEntitiesList, + 'Component', + 'documentation', + ), + className: 'documentation', + name: 'Documentation', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'API'), + className: 'api', + name: 'APIs', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'library'), + className: 'library', + name: 'Libraries', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'website'), + className: 'website', + name: 'Websites', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'tool'), + className: 'tool', + name: 'Tools', + }, + ] as Array<{ counter: number; className: EntitiesTypes; name: string }>; + }, [catalogApi]); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + return ( + + + {componentsWithCounters?.map(c => ( + + + + ))} + + + ); +}; diff --git a/plugins/org/src/components/Cards/OwnershipCard/index.ts b/plugins/org/src/components/Cards/OwnershipCard/index.ts new file mode 100644 index 0000000000..1fa1bb4044 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './OwnershipCard'; diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx new file mode 100644 index 0000000000..77708e6f27 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { UserEntity } from '@backstage/catalog-model'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { UserProfileCard } from './UserProfileCard'; + +describe('UserSummary Test', () => { + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'calum.leavy', + }, + spec: { + profile: { + displayName: 'Calum Leavy', + email: 'calum-leavy@example.com', + picture: 'https://example.com/staff/calum.jpeg', + }, + memberOf: ['ExampleGroup'], + }, + relations: [ + { + type: 'memberOf', + target: { + kind: 'group', + name: 'ExampleGroup', + namespace: 'default', + }, + }, + ], + }; + + it('Display Profile Card', async () => { + const rendered = await renderWithEffects( + wrapInTestApp(), + ); + + expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument(); + expect(rendered.getByAltText('Calum Leavy')).toHaveAttribute( + 'src', + 'https://example.com/staff/calum.jpeg', + ); + expect(rendered.getByText('[ExampleGroup]')).toHaveAttribute( + 'href', + '/catalog/default/group/ExampleGroup', + ); + }); +}); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx new file mode 100644 index 0000000000..99048c0cd8 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -0,0 +1,132 @@ +/* + * 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, + RELATION_MEMBER_OF, + UserEntity, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard } from '@backstage/core'; +import { entityRouteParams } from '@backstage/plugin-catalog'; +import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; +import EmailIcon from '@material-ui/icons/Email'; +import GroupIcon from '@material-ui/icons/Group'; +import PersonIcon from '@material-ui/icons/Person'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const GroupLink = ({ + groupName, + index, + entity, +}: { + groupName: string; + index: number; + entity: Entity; +}) => ( + <> + {index >= 1 ? ', ' : ''} + + [{groupName}] + + +); + +const CardTitle = ({ title }: { title?: string }) => + title ? ( + + + {title} + + ) : null; + +export const UserProfileCard = ({ + entity: user, + variant, +}: { + entity: UserEntity; + variant: string; +}) => { + const { + metadata: { name: metaName }, + spec: { profile }, + } = user; + const groupNames = + user?.relations + ?.filter(r => r.type === RELATION_MEMBER_OF) + ?.map(group => group.target.name) || []; + const displayName = profile?.displayName ?? metaName; + + if (!user) { + return User not found; + } + + return ( + } variant={variant}> + + + + + + + + {profile?.email && ( + + + + + + + + {profile.email} + + + + )} + + + + + + + {groupNames.map((groupName, index) => ( + + ))} + + + + + + + ); +}; diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/index.ts b/plugins/org/src/components/Cards/User/UserProfileCard/index.ts new file mode 100644 index 0000000000..dc5e2902b7 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './UserProfileCard'; diff --git a/plugins/org/src/components/Cards/User/index.ts b/plugins/org/src/components/Cards/User/index.ts new file mode 100644 index 0000000000..dc5e2902b7 --- /dev/null +++ b/plugins/org/src/components/Cards/User/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './UserProfileCard'; diff --git a/plugins/org/src/components/Cards/index.ts b/plugins/org/src/components/Cards/index.ts new file mode 100644 index 0000000000..62f63da3d4 --- /dev/null +++ b/plugins/org/src/components/Cards/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 './Group'; +export * from './User'; +export * from './OwnershipCard'; diff --git a/plugins/org/src/components/getEntityRelations.ts b/plugins/org/src/components/getEntityRelations.ts new file mode 100644 index 0000000000..9230aaebee --- /dev/null +++ b/plugins/org/src/components/getEntityRelations.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityName } from '@backstage/catalog-model'; + +// TODO: this file is copied from /packages/app/catalog/src/components/getEntityRelations.ts and +// should be replaced once common relation-functions are introduced. + +/** + * Get the related entity references. + */ +export function getEntityRelations( + entity: Entity | undefined, + relationType: string, + filter?: { kind: string }, +): EntityName[] { + let entityNames = + entity?.relations + ?.filter(r => r.type === relationType) + ?.map(r => r.target) || []; + + if (filter?.kind) { + entityNames = entityNames?.filter( + e => e.kind.toLowerCase() === filter.kind.toLowerCase(), + ); + } + + return entityNames; +} diff --git a/plugins/sentry-backend/src/index.ts b/plugins/org/src/components/index.ts similarity index 94% rename from plugins/sentry-backend/src/index.ts rename to plugins/org/src/components/index.ts index 7612c392a2..975f66bd25 100644 --- a/plugins/sentry-backend/src/index.ts +++ b/plugins/org/src/components/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './service/router'; +export * from './Cards'; diff --git a/plugins/org/src/components/isOwnerOf.ts b/plugins/org/src/components/isOwnerOf.ts new file mode 100644 index 0000000000..dd7c7d6805 --- /dev/null +++ b/plugins/org/src/components/isOwnerOf.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. + */ + +import { + Entity, + EntityName, + getEntityName, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { getEntityRelations } from './getEntityRelations'; + +// TODO: this file is copied from /packages/app/catalog/src/components/isOwnerOf.ts and +// should be replaced once common relation-functions are introduced. + +/** + * Check if one entity is owned by another. Returns true, if the entity is owned by the + * owner directly, or if the entity is owned by a group that the owner is a member of. + */ +export function isOwnerOf(owner: Entity, owned: Entity) { + const possibleOwners: EntityName[] = [ + ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), + ...(owner ? [getEntityName(owner)] : []), + ]; + + const owners = getEntityRelations(owned, RELATION_OWNED_BY); + + for (const owner of owners) { + if ( + possibleOwners.find( + o => + owner.kind.toLowerCase() === o.kind.toLowerCase() && + owner.namespace.toLowerCase() === o.namespace.toLowerCase() && + owner.name.toLowerCase() === o.name.toLowerCase(), + ) !== undefined + ) { + return true; + } + } + + return false; +} diff --git a/plugins/org/src/index.ts b/plugins/org/src/index.ts new file mode 100644 index 0000000000..77ad7f9266 --- /dev/null +++ b/plugins/org/src/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 { plugin } from './plugin'; +export * from './components'; diff --git a/plugins/org/src/plugin.test.ts b/plugins/org/src/plugin.test.ts new file mode 100644 index 0000000000..d77cfd7ae8 --- /dev/null +++ b/plugins/org/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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('groups', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/org/src/plugin.ts b/plugins/org/src/plugin.ts new file mode 100644 index 0000000000..39c3502fb5 --- /dev/null +++ b/plugins/org/src/plugin.ts @@ -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 { createPlugin } from '@backstage/core'; + +export const plugin = createPlugin({ + id: 'org', +}); diff --git a/plugins/org/src/setupTests.ts b/plugins/org/src/setupTests.ts new file mode 100644 index 0000000000..43b8421558 --- /dev/null +++ b/plugins/org/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'; +import 'cross-fetch/polyfill'; diff --git a/plugins/pagerduty/.eslintrc.js b/plugins/pagerduty/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/pagerduty/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md new file mode 100644 index 0000000000..d4a5439072 --- /dev/null +++ b/plugins/pagerduty/CHANGELOG.md @@ -0,0 +1,16 @@ +# @backstage/plugin-pagerduty + +## 0.2.2 + +### Patch Changes + +- 6011b7d3e: Added pagerduty plugin to example app +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md new file mode 100644 index 0000000000..1c22053b88 --- /dev/null +++ b/plugins/pagerduty/README.md @@ -0,0 +1,82 @@ +# PagerDuty + +## Overview + +This plugin displays PagerDuty information about an entity such as if there are any active incidents and what the escalation policy is. + +There is also an easy way to trigger an alarm directly to the person who is currently on-call. + +This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document. + +This plugin provides: + +- A list of incidents +- A way to trigger an alarm to the person on-call +- Information details about the person on-call + +## Setup instructions + +Install the plugin: + +```bash +yarn add @backstage/plugin-pagerduty +``` + +Add it to the app in `plugins.ts`: + +```ts +export { plugin as Pagerduty } from '@backstage/plugin-pagerduty'; +``` + +Add it to the `EntityPage.ts`: + +```ts +import { + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from '@backstage/plugin-pagerduty'; +// add to code +{ + isPagerDutyAvailable(entity) && ( + + + + ); +} +``` + +## Client configuration + +If you want to override the default URL for events, you can add it to `app-config.yaml`. + +In `app-config.yaml`: + +```yaml +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' +``` + +## Providing the API Token + +In order for the client to make requests to the [PagerDuty API](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/) it needs an [API Token](https://support.pagerduty.com/docs/generating-api-keys#generating-a-general-access-rest-api-key). + +Then start the backend passing the token as an environment variable: + +```bash +$ PAGERDUTY_TOKEN='Token token=' yarn start +``` + +This will proxy the request by adding `Authorization` header with the provided token. + +## Integration Key + +The information displayed for each entity is based on the [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). + +### Adding the integration key to the entity annotation + +If you want to use this plugin for an entity, you need to label it with the below annotation: + +```yml +annotations: + pagerduty.com/integration-key: [INTEGRATION_KEY] +``` diff --git a/plugins/pagerduty/dev/index.tsx b/plugins/pagerduty/dev/index.tsx new file mode 100644 index 0000000000..264d6f801f --- /dev/null +++ b/plugins/pagerduty/dev/index.tsx @@ -0,0 +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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json new file mode 100644 index 0000000000..e1db22c0cd --- /dev/null +++ b/plugins/pagerduty/package.json @@ -0,0 +1,62 @@ +{ + "name": "@backstage/plugin-pagerduty", + "version": "0.2.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/pagerduty" + }, + "keywords": [ + "backstage", + "pagerduty" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "classnames": "^2.2.6", + "date-fns": "^2.15.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "msw": "^0.21.2", + "node-fetch": "^2.6.1", + "cross-fetch": "^3.0.6" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts new file mode 100644 index 0000000000..d1220f715f --- /dev/null +++ b/plugins/pagerduty/src/api/client.ts @@ -0,0 +1,143 @@ +/* + * 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, ConfigApi } from '@backstage/core'; +import { Service, Incident, OnCall } from '../components/types'; +import { + PagerDutyApi, + TriggerAlarmRequest, + ServicesResponse, + IncidentsResponse, + OnCallsResponse, + ClientApiConfig, + RequestOptions, +} from './types'; + +export class UnauthorizedError extends Error {} + +export const pagerDutyApiRef = createApiRef({ + id: 'plugin.pagerduty.api', + description: 'Used to fetch data from PagerDuty API', +}); + +export class PagerDutyClient implements PagerDutyApi { + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const eventsBaseUrl: string = + configApi.getOptionalString('pagerDuty.eventsBaseUrl') ?? + 'https://events.pagerduty.com/v2'; + return new PagerDutyClient({ + eventsBaseUrl, + discoveryApi, + }); + } + constructor(private readonly config: ClientApiConfig) {} + + async getServiceByIntegrationKey(integrationKey: string): Promise { + const params = `time_zone=UTC&include[]=integrations&include[]=escalation_policies&query=${integrationKey}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/services?${params}`; + const { services } = await this.getByUrl(url); + + return services; + } + + async getIncidentsByServiceId(serviceId: string): Promise { + const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/incidents?${params}`; + const { incidents } = await this.getByUrl(url); + + return incidents; + } + + async getOnCallByPolicyId(policyId: string): Promise { + const params = `time_zone=UTC&include[]=users&escalation_policy_ids[]=${policyId}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/oncalls?${params}`; + const { oncalls } = await this.getByUrl(url); + + return oncalls; + } + + triggerAlarm({ + integrationKey, + source, + description, + userName, + }: TriggerAlarmRequest): Promise { + const body = JSON.stringify({ + event_action: 'trigger', + routing_key: integrationKey, + client: 'Backstage', + client_url: source, + payload: { + summary: description, + source: source, + severity: 'error', + class: 'manual trigger', + custom_details: { + user: userName, + }, + }, + }); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body, + }; + + const url = this.config.eventsBaseUrl ?? 'https://events.pagerduty.com/v2'; + + return this.request(`${url}/enqueue`, options); + } + + private async getByUrl(url: string): Promise { + const options = { + method: 'GET', + headers: { + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + const response = await this.request(url, options); + + return response.json(); + } + + private async request( + url: string, + options: RequestOptions, + ): Promise { + const response = await fetch(url, options); + if (response.status === 401) { + throw new UnauthorizedError(); + } + if (!response.ok) { + const payload = await response.json(); + const errors = payload.errors.map((error: string) => error).join(' '); + const message = `Request failed with ${response.status}, ${errors}`; + throw new Error(message); + } + return response; + } +} diff --git a/plugins/pagerduty/src/api/index.ts b/plugins/pagerduty/src/api/index.ts new file mode 100644 index 0000000000..90604c4012 --- /dev/null +++ b/plugins/pagerduty/src/api/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 { PagerDutyClient, pagerDutyApiRef, UnauthorizedError } from './client'; +export type { PagerDutyApi } from './types'; diff --git a/plugins/pagerduty/src/api/types.ts b/plugins/pagerduty/src/api/types.ts new file mode 100644 index 0000000000..733f171489 --- /dev/null +++ b/plugins/pagerduty/src/api/types.ts @@ -0,0 +1,73 @@ +/* + * 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 { Incident, OnCall, Service } from '../components/types'; +import { DiscoveryApi } from '@backstage/core'; + +export type TriggerAlarmRequest = { + integrationKey: string; + source: string; + description: string; + userName: string; +}; + +export interface PagerDutyApi { + /** + * Fetches a list of services, filtered by the provided integration key. + * + */ + getServiceByIntegrationKey(integrationKey: string): Promise; + + /** + * Fetches a list of incidents a provided service has. + * + */ + getIncidentsByServiceId(serviceId: string): Promise; + + /** + * Fetches the list of users in an escalation policy. + * + */ + getOnCallByPolicyId(policyId: string): Promise; + + /** + * Triggers an incident to whoever is on-call. + */ + triggerAlarm(request: TriggerAlarmRequest): Promise; +} + +export type ServicesResponse = { + services: Service[]; +}; + +export type IncidentsResponse = { + incidents: Incident[]; +}; + +export type OnCallsResponse = { + oncalls: OnCall[]; +}; + +export type ClientApiConfig = { + eventsBaseUrl?: string; + discoveryApi: DiscoveryApi; +}; + +export type RequestOptions = { + method: string; + headers: HeadersInit; + body?: BodyInit; +}; diff --git a/plugins/pagerduty/src/assets/emptystate.svg b/plugins/pagerduty/src/assets/emptystate.svg new file mode 100644 index 0000000000..57b35238dd --- /dev/null +++ b/plugins/pagerduty/src/assets/emptystate.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/types.ts b/plugins/pagerduty/src/components/About/AboutCard.tsx similarity index 54% rename from plugins/techdocs-backend/src/techdocs/stages/publish/types.ts rename to plugins/pagerduty/src/components/About/AboutCard.tsx index ca85d9f56e..7d5419908a 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/types.ts +++ b/plugins/pagerduty/src/components/About/AboutCard.tsx @@ -13,20 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import React from 'react'; +import { Card, CardContent, CardHeader, Divider } from '@material-ui/core'; +import { SubHeader } from './SubHeader'; +import { SubHeaderLink } from '../types'; -/** - * Publisher is in charge of taking a folder created by - * the builder, and pushing it to storage - */ -export type PublisherBase = { - /** - * - * @param opts object containing the entity from the service - * catalog, and the directory that has been generated - */ - publish(opts: { - entity: Entity; - directory: string; - }): Promise<{ remoteUrl: string }> | { remoteUrl: string }; +type Props = { + title: string; + links: SubHeaderLink[]; + content: React.ReactNode; }; + +export const AboutCard = ({ title, links, content }: Props) => ( + + } /> + + {content} + +); diff --git a/plugins/pagerduty/src/components/About/SubHeader.tsx b/plugins/pagerduty/src/components/About/SubHeader.tsx new file mode 100644 index 0000000000..5e4ea96fe0 --- /dev/null +++ b/plugins/pagerduty/src/components/About/SubHeader.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { VerticalIcon } from './VerticalIcon'; +import { SubHeaderLink } from '../types'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + links: { + margin: theme.spacing(2, 0), + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'min-content', + gridGap: theme.spacing(3), + }, +})); + +type Props = { + links: SubHeaderLink[]; +}; + +export const SubHeader = ({ links }: Props) => { + const classes = useStyles(); + return ( + + ); +}; diff --git a/plugins/pagerduty/src/components/About/VerticalIcon.tsx b/plugins/pagerduty/src/components/About/VerticalIcon.tsx new file mode 100644 index 0000000000..5463372ac0 --- /dev/null +++ b/plugins/pagerduty/src/components/About/VerticalIcon.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import classnames from 'classnames'; +import { makeStyles, Link } from '@material-ui/core'; +import LinkIcon from '@material-ui/icons/Link'; +import { Link as RouterLink } from 'react-router-dom'; + +export type VerticalIconProps = { + icon?: React.ReactNode; + href?: string; + title?: string; + label: string; + action?: React.ReactNode; +}; + +const useIconStyles = makeStyles(theme => ({ + link: { + display: 'grid', + justifyItems: 'center', + gridGap: 4, + textAlign: 'center', + }, + label: { + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + }, + linkStyle: { + color: theme.palette.secondary.main, + }, +})); + +export function VerticalIcon({ + icon = , + href = '#', + action, + ...props +}: VerticalIconProps) { + const classes = useIconStyles(); + + if (action) { + return ( + + {icon} + {action} + + ); + } + + // Absolute links should not be using RouterLink + if (href?.startsWith('//') || href?.includes('://')) { + return ( + + {icon} + {props.label} + + ); + } + + return ( + + {icon} + {props.label} + + ); +} diff --git a/plugins/pagerduty/src/components/About/index.ts b/plugins/pagerduty/src/components/About/index.ts new file mode 100644 index 0000000000..4e495daa54 --- /dev/null +++ b/plugins/pagerduty/src/components/About/index.ts @@ -0,0 +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. + */ + +export { AboutCard } from './AboutCard'; +// export { SubHeader } from './SubHeader'; +// export { VerticalIcon } from './VerticalIcon'; diff --git a/plugins/techdocs-backend/src/service/metadata.ts b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx similarity index 51% rename from plugins/techdocs-backend/src/service/metadata.ts rename to plugins/pagerduty/src/components/Errors/MissingTokenError.tsx index 760180f8d2..c22552b7c6 100644 --- a/plugins/techdocs-backend/src/service/metadata.ts +++ b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx @@ -13,29 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import React from 'react'; +import { EmptyState } from '@backstage/core'; +import { Button } from '@material-ui/core'; -import fetch from 'cross-fetch'; - -export class TechDocsMetadata { - private async getMetadataFile(docsUrl: String) { - const metadataURL = `${docsUrl}/techdocs_metadata.json`; - - try { - const req = await fetch(metadataURL); - - return await req.json(); - } catch (error) { - throw new Error(error); +export const MissingTokenError = () => ( + + Read More + } - } - - public async getMkDocsMetaData(docsUrl: any) { - const mkDocsMetadata = await this.getMetadataFile(docsUrl); - - if (!mkDocsMetadata) return null; - - return { - ...mkDocsMetadata, - }; - } -} + /> +); diff --git a/plugins/pagerduty/src/components/Errors/index.ts b/plugins/pagerduty/src/components/Errors/index.ts new file mode 100644 index 0000000000..3c2dfa65f2 --- /dev/null +++ b/plugins/pagerduty/src/components/Errors/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 { MissingTokenError } from './MissingTokenError'; diff --git a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx new file mode 100644 index 0000000000..15ff3278a0 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { EscalationPolicy } from './EscalationPolicy'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { User } from '../types'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; + +const mockPagerDutyApi = { + getOnCallByPolicyId: () => [], +}; +const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); + +describe('Escalation', () => { + it('Handles an empty response', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('456'); + }); + + it('Render a list of users', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockImplementationOnce(async () => [ + { + user: { + name: 'person1', + id: 'p1', + summary: 'person1', + email: 'person1@example.com', + html_url: 'http://a.com/id1', + } as User, + }, + ]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('person1')).toBeInTheDocument(); + expect(getByText('person1@example.com')).toBeInTheDocument(); + expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('abc'); + }); + + it('Handles errors', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockRejectedValueOnce(new Error('Error message')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText('Error encountered while fetching information. Error message'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx new file mode 100644 index 0000000000..faa480f6e2 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -0,0 +1,65 @@ +/* + * 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 { List, ListSubheader } from '@material-ui/core'; +import { EscalationUsersEmptyState } from './EscalationUsersEmptyState'; +import { EscalationUser } from './EscalationUser'; +import { useAsync } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + policyId: string; +}; + +export const EscalationPolicy = ({ policyId }: Props) => { + const api = useApi(pagerDutyApiRef); + + const { value: users, loading, error } = useAsync(async () => { + const oncalls = await api.getOnCallByPolicyId(policyId); + const users = oncalls + .sort((a, b) => a.escalation_level - b.escalation_level) + .map(oncall => oncall.user); + + return users; + }); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!users?.length) { + return ; + } + + return ( + ON CALL}> + {users!.map((user, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx new file mode 100644 index 0000000000..41995c86f5 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUser.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 React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import Avatar from '@material-ui/core/Avatar'; +import EmailIcon from '@material-ui/icons/Email'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { User } from '../types'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, +}); + +type Props = { + user: User; +}; + +export const EscalationUser = ({ user }: Props) => { + const classes = useStyles(); + + return ( + + + + + + {user.name} + + } + secondary={user.email} + /> + + + + + + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx new file mode 100644 index 0000000000..d587011601 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { StatusWarning } from '@backstage/core'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const EscalationUsersEmptyState = () => { + const classes = useStyles(); + return ( + + +
+ +
+
+ +
+ ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/index.ts b/plugins/pagerduty/src/components/Escalation/index.ts new file mode 100644 index 0000000000..ac2db62cd9 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/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 { EscalationPolicy } from './EscalationPolicy'; diff --git a/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx new file mode 100644 index 0000000000..f7a0398c55 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import EmptyStateImage from '../../assets/emptystate.svg'; + +export const IncidentsEmptyState = () => { + return ( + + + Nice! No incidents found! + + + EmptyState + + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx new file mode 100644 index 0000000000..860623dcd4 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx @@ -0,0 +1,105 @@ +/* + * 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 { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Link, + Typography, +} from '@material-ui/core'; +import { StatusError, StatusWarning } from '@backstage/core'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { Incident } from '../types'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + listItemPrimary: { + fontWeight: 'bold', + }, + listItemIcon: { + minWidth: '1em', + }, +}); + +type Props = { + incident: Incident; +}; + +export const IncidentListItem = ({ incident }: Props) => { + const classes = useStyles(); + const user = incident.assignments[0]?.assignee; + const createdAt = formatDistanceToNowStrict(new Date(incident.created_at)); + + return ( + + + +
+ {incident.status === 'triggered' ? ( + + ) : ( + + )} +
+
+
+ + Created {createdAt} ago and assigned to{' '} + + {user?.summary ?? 'nobody'} + + + } + /> + + + + + + + +
+ ); +}; diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx new file mode 100644 index 0000000000..9da9f9f11b --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { render, waitFor } from '@testing-library/react'; +import { Incidents } from './Incidents'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; +import { Incident } from '../types'; + +const mockPagerDutyApi = { + getIncidentsByServiceId: () => [], +}; +const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); + +describe('Incidents', () => { + it('Renders an empty state when there are no incidents', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + }); + + it('Renders all incidents', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest.fn().mockImplementationOnce( + async () => + [ + { + id: 'id1', + status: 'triggered', + title: 'title1', + created_at: '2020-11-06T00:00:00Z', + assignments: [ + { + assignee: { + id: 'p1', + summary: 'person1', + html_url: 'http://a.com/id1', + }, + }, + ], + html_url: 'http://a.com/id1', + serviceId: 'sId1', + }, + { + id: 'id2', + status: 'acknowledged', + title: 'title2', + created_at: '2020-11-07T00:00:00Z', + assignments: [ + { + assignee: { + id: 'p2', + summary: 'person2', + + html_url: 'http://a.com/id2', + }, + }, + ], + html_url: 'http://a.com/id2', + serviceId: 'sId2', + }, + ] as Incident[], + ); + const { + getByText, + getByTitle, + getAllByTitle, + getByLabelText, + queryByTestId, + } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('title1')).toBeInTheDocument(); + expect(getByText('title2')).toBeInTheDocument(); + expect(getByText('person1')).toBeInTheDocument(); + expect(getByText('person2')).toBeInTheDocument(); + expect(getByTitle('triggered')).toBeInTheDocument(); + expect(getByTitle('acknowledged')).toBeInTheDocument(); + expect(getByLabelText('Status error')).toBeInTheDocument(); + expect(getByLabelText('Status warning')).toBeInTheDocument(); + + // assert links, mailto and hrefs, date calculation + expect(getAllByTitle('View in PagerDuty').length).toEqual(2); + }); + + it('Handle errors', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest + .fn() + .mockRejectedValueOnce(new Error('Error occurred')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Incident/Incidents.tsx b/plugins/pagerduty/src/components/Incident/Incidents.tsx new file mode 100644 index 0000000000..732a582cdb --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { IncidentListItem } from './IncidentListItem'; +import { IncidentsEmptyState } from './IncidentEmptyState'; +import { useAsyncFn } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + serviceId: string; + refreshIncidents: boolean; +}; + +export const Incidents = ({ serviceId, refreshIncidents }: Props) => { + const api = useApi(pagerDutyApiRef); + + const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn( + async () => await api.getIncidentsByServiceId(serviceId), + ); + + useEffect(() => { + getIncidents(); + }, [refreshIncidents, getIncidents]); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!incidents?.length) { + return ; + } + + return ( + INCIDENTS}> + {incidents!.map((incident, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/index.ts b/plugins/pagerduty/src/components/Incident/index.ts new file mode 100644 index 0000000000..fb2702602b --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/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 { Incidents } from './Incidents'; diff --git a/plugins/pagerduty/src/components/PagerDutyCard.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx new file mode 100644 index 0000000000..a6615aee82 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 { render, waitFor, fireEvent, act } from '@testing-library/react'; +import { PagerDutyCard } from './PagerDutyCard'; +import { Entity } from '@backstage/catalog-model'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + alertApiRef, + ApiProvider, + ApiRegistry, + createApiRef, +} from '@backstage/core'; +import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../api'; +import { Service } from './types'; + +const mockPagerDutyApi: Partial = { + getServiceByIntegrationKey: async () => [], + getOnCallByPolicyId: async () => [], + getIncidentsByServiceId: async () => [], +}; + +const apis = ApiRegistry.from([ + [pagerDutyApiRef, mockPagerDutyApi], + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], +]); +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + }, + }, +}; + +const service: Service = { + id: 'abc', + name: 'pagerduty-name', + html_url: 'www.example.com', + escalation_policy: { + id: 'def', + user: { + name: 'person1', + id: 'p1', + summary: 'person1', + email: 'person1@example.com', + html_url: 'http://a.com/id1', + }, + }, + integrationKey: 'abcd', +}; + +describe('PageDutyCard', () => { + it('Render pagerduty', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockImplementationOnce(async () => [service]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Missing or invalid PagerDuty Token')).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + it('opens the dialog when trigger button is clicked', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockImplementationOnce(async () => [service]); + + const { getByText, queryByTestId, getByTestId, getByRole } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx new file mode 100644 index 0000000000..8fd20f37e8 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useCallback } from 'react'; +import { useApi, Progress } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { Button, makeStyles } from '@material-ui/core'; +import { Incidents } from './Incident'; +import { EscalationPolicy } from './Escalation'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { pagerDutyApiRef, UnauthorizedError } from '../api'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import { TriggerDialog } from './TriggerDialog'; +import { MissingTokenError } from './Errors/MissingTokenError'; +import WebIcon from '@material-ui/icons/Web'; +import { AboutCard } from './About/AboutCard'; + +const useStyles = makeStyles({ + triggerAlarm: { + paddingTop: 0, + paddingBottom: 0, + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + lineHeight: 1.5, + '&:hover, &:focus, &.focus': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }, +}); + +export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]); + +type Props = { + entity: Entity; +}; + +export const PagerDutyCard = ({ entity }: Props) => { + const classes = useStyles(); + const api = useApi(pagerDutyApiRef); + const [showDialog, setShowDialog] = useState(false); + const [refreshIncidents, setRefreshIncidents] = useState(false); + const integrationKey = entity.metadata.annotations![ + PAGERDUTY_INTEGRATION_KEY + ]; + + const handleRefresh = useCallback(() => { + setRefreshIncidents(x => !x); + }, []); + + const handleDialog = useCallback(() => { + setShowDialog(x => !x); + }, []); + + const { value: service, loading, error } = useAsync(async () => { + const services = await api.getServiceByIntegrationKey(integrationKey); + + return { + id: services[0].id, + name: services[0].name, + url: services[0].html_url, + policyId: services[0].escalation_policy.id, + }; + }); + + if (error instanceof UnauthorizedError) { + return ; + } + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + const serviceLink = { + title: 'Service Directory', + href: service!.url, + icon: , + }; + + const triggerLink = { + title: 'Create Incident', + action: ( + + ), + icon: , + }; + + return ( + + + + + + } + /> + ); +}; diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx new file mode 100644 index 0000000000..8073ab44b5 --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + ApiRegistry, + alertApiRef, + createApiRef, + ApiProvider, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; +import { Entity } from '@backstage/catalog-model'; +import { TriggerDialog } from './TriggerDialog'; + +describe('TriggerDialog', () => { + const mockIdentityApi: Partial = { + getUserId: () => 'guest@example.com', + }; + + const mockTriggerAlarmFn = jest.fn(); + const mockPagerDutyApi = { + triggerAlarm: mockTriggerAlarmFn, + }; + + const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [identityApiRef, mockIdentityApi], + [pagerDutyApiRef, mockPagerDutyApi], + ]); + + it('open the dialog and trigger an alarm', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + }, + }, + }; + + const { getByText, getByRole, getByTestId } = render( + wrapInTestApp( + + {}} + name={entity.metadata.name} + integrationKey="abc123" + onIncidentCreated={() => {}} + /> + , + ), + ); + + expect(getByRole('dialog')).toBeInTheDocument(); + expect( + getByText('This action will trigger an incident for ', { + exact: false, + }), + ).toBeInTheDocument(); + const input = getByTestId('trigger-input'); + const description = 'Test Trigger Alarm'; + await act(async () => { + fireEvent.change(input, { target: { value: description } }); + }); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(mockTriggerAlarmFn).toHaveBeenCalled(); + expect(mockTriggerAlarmFn).toHaveBeenCalledWith({ + integrationKey: entity!.metadata!.annotations![ + 'pagerduty.com/integration-key' + ], + source: window.location.toString(), + description, + userName: 'guest@example.com', + }); + }); +}); diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx new file mode 100644 index 0000000000..4fa88a1b9a --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + TextField, + DialogActions, + Button, + DialogContent, + Typography, + CircularProgress, +} from '@material-ui/core'; +import { useApi, alertApiRef, identityApiRef } from '@backstage/core'; +import { useAsyncFn } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; + +type Props = { + name: string; + integrationKey: string; + showDialog: boolean; + handleDialog: () => void; + onIncidentCreated: () => void; +}; + +export const TriggerDialog = ({ + name, + integrationKey, + showDialog, + handleDialog, + onIncidentCreated: onIncidentCreated, +}: Props) => { + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const api = useApi(pagerDutyApiRef); + const [description, setDescription] = useState(''); + + const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( + async (description: string) => + await api.triggerAlarm({ + integrationKey, + source: window.location.toString(), + description, + userName, + }), + ); + + const descriptionChanged = ( + event: React.ChangeEvent, + ) => { + setDescription(event.target.value); + }; + + useEffect(() => { + if (value) { + alertApi.post({ + message: `Alarm successfully triggered by ${userName}`, + }); + onIncidentCreated(); + handleDialog(); + } + }, [value, alertApi, handleDialog, userName, onIncidentCreated]); + + if (error) { + alertApi.post({ + message: `Failed to trigger alarm. ${error.message}`, + severity: 'error', + }); + } + + return ( + + + This action will trigger an incident for "{name}". + + + + + If the issue you are seeing does not need urgent attention, please + get in touch with the responsible team using their preferred + communications channel. You can find information about the owner of + this entity in the "About" card. If the issue is urgent, please + don't hesitate to trigger the alert. + + + + Please describe the problem you want to report. Be as descriptive as + possible. Your signed in user and a reference to the current page will + automatically be amended to the alarm so that the receiver can reach + out to you if necessary. + + + + + + + + + ); +}; diff --git a/plugins/api-docs/src/catalog/EntityPageApi/index.ts b/plugins/pagerduty/src/components/TriggerDialog/index.ts similarity index 92% rename from plugins/api-docs/src/catalog/EntityPageApi/index.ts rename to plugins/pagerduty/src/components/TriggerDialog/index.ts index 1d382e01de..655cef8504 100644 --- a/plugins/api-docs/src/catalog/EntityPageApi/index.ts +++ b/plugins/pagerduty/src/components/TriggerDialog/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { EntityPageApi } from './EntityPageApi'; +export { TriggerDialog } from './TriggerDialog'; diff --git a/plugins/pagerduty/src/components/types.ts b/plugins/pagerduty/src/components/types.ts new file mode 100644 index 0000000000..ff63c81518 --- /dev/null +++ b/plugins/pagerduty/src/components/types.ts @@ -0,0 +1,66 @@ +/* + * 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 type Incident = { + id: string; + title: string; + status: string; + html_url: string; + assignments: [ + { + assignee: Assignee; + }, + ]; + serviceId: string; + created_at: string; +}; + +export type Service = { + id: string; + name: string; + html_url: string; + integrationKey: string; + escalation_policy: { + id: string; + user: User; + }; +}; + +export type OnCall = { + user: User; + escalation_level: number; +}; + +export type Assignee = { + id: string; + summary: string; + html_url: string; +}; + +export type User = { + id: string; + summary: string; + email: string; + html_url: string; + name: string; +}; + +export type SubHeaderLink = { + title: string; + href?: string; + icon: React.ReactNode; + action?: React.ReactNode; +}; diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts new file mode 100644 index 0000000000..4ecd4edcc6 --- /dev/null +++ b/plugins/pagerduty/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { plugin } from './plugin'; +export { + isPluginApplicableToEntity, + PagerDutyCard, +} from './components/PagerDutyCard'; +export { + PagerDutyClient, + pagerDutyApiRef, + UnauthorizedError, +} from './api/client'; diff --git a/plugins/pagerduty/src/plugin.test.ts b/plugins/pagerduty/src/plugin.test.ts new file mode 100644 index 0000000000..8d4545ac12 --- /dev/null +++ b/plugins/pagerduty/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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('pagerduty', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts new file mode 100644 index 0000000000..34796f491e --- /dev/null +++ b/plugins/pagerduty/src/plugin.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + configApiRef, +} from '@backstage/core'; +import { pagerDutyApiRef, PagerDutyClient } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '/pagerduty', + title: 'pagerduty', +}); + +export const plugin = createPlugin({ + id: 'pagerduty', + apis: [ + createApiFactory({ + api: pagerDutyApiRef, + deps: { discoveryApi: discoveryApiRef, configApi: configApiRef }, + factory: ({ configApi, discoveryApi }) => + PagerDutyClient.fromConfig(configApi, discoveryApi), + }), + ], +}); diff --git a/plugins/pagerduty/src/setupTests.ts b/plugins/pagerduty/src/setupTests.ts new file mode 100644 index 0000000000..0bfa67b49a --- /dev/null +++ b/plugins/pagerduty/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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'; diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index dcbe7eb7e8..0c928d19f9 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/plugin-proxy-backend +## 0.2.3 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + +## 0.2.2 + +### Patch Changes + +- 6a6c7c14e: Filter the headers that are sent from the proxied-targed back to the frontend to not forwarded unwanted authentication or + monitoring contexts from other origins (like `Set-Cookie` with e.g. a google analytics context). The implementation reuses + the `allowedHeaders` configuration that now controls both directions `frontend->target` and `target->frontend`. +- 3619ea4c4: Add configuration schema for the commonly used properties +- Updated dependencies [612368274] + - @backstage/backend-common@0.3.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.2.0 ### Minor Changes diff --git a/plugins/proxy-backend/config.d.ts b/plugins/proxy-backend/config.d.ts new file mode 100644 index 0000000000..e9b0f45893 --- /dev/null +++ b/plugins/proxy-backend/config.d.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 Config { + /** + * A list of forwarding-proxies. Each key is a route to match, + * below the prefix that the proxy plugin is mounted on. It must + * start with a '/'. + */ + proxy?: { + [key: string]: + | string + | { + /** + * Target of the proxy. Url string to be parsed with the url module. + */ + target: string; + /** + * Object with extra headers to be added to target requests. + */ + headers?: { [key: string]: string }; + /** + * Changes the origin of the host header to the target URL. Default: true. + */ + changeOrigin?: boolean; + /** + * Rewrite target's url path. Object-keys will be used as RegExp to match paths. + * If pathRewrite is not specified, it is set to a single rewrite that removes the entire prefix and route. + */ + pathRewrite?: { [regexp: string]: string }; + /** + * Limit the forwarded HTTP methods, for example allowedMethods: ['GET'] to enforce read-only access. + */ + allowedMethods?: string[]; + /** + * Limit the forwarded HTTP methods. By default, only the headers that are considered safe for CORS + * and headers that are set by the proxy will be forwarded. + */ + allowedHeaders?: string[]; + }; + }; +} diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index f147e400e4..a39d839fd1 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-proxy-backend", - "version": "0.2.0", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/proxy-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -19,8 +28,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.4.0", + "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -33,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", @@ -41,6 +50,8 @@ "supertest": "^4.0.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 77db35c917..32e7a4d49d 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -198,4 +198,74 @@ describe('buildMiddleware', () => { 'X-Auth-Request-User', ); }); + + it('responds default headers', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock + .calls[0][1] as ProxyMiddlewareConfig; + + const testClientResponse = { + headers: { + 'cache-control': 'value', + 'content-language': 'value', + 'content-length': 'value', + 'content-type': 'value', + expires: 'value', + 'last-modified': 'value', + pragma: 'value', + 'set-cookie': ['value'], + }, + } as Partial; + + expect(config).toBeDefined(); + expect(config.onProxyReq).toBeDefined(); + + config.onProxyRes!( + testClientResponse as http.IncomingMessage, + {} as http.IncomingMessage, + {} as http.ServerResponse, + ); + + expect(Object.keys(testClientResponse.headers!)).toEqual([ + 'cache-control', + 'content-language', + 'content-length', + 'content-type', + 'expires', + 'last-modified', + 'pragma', + ]); + }); + + it('responds configured headers', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + allowedHeaders: ['set-cookie'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock + .calls[0][1] as ProxyMiddlewareConfig; + + const testClientResponse = { + headers: { + 'set-cookie': [], + 'x-auth-request-user': 'asd', + }, + } as Partial; + + config.onProxyRes!( + testClientResponse as http.IncomingMessage, + {} as http.IncomingMessage, + {} as http.ServerResponse, + ); + + expect(Object.keys(testClientResponse.headers!)).toEqual(['set-cookie']); + }); }); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 75eab029b5..036e3a8d1b 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -90,8 +90,8 @@ export function buildMiddleware( return fullConfig?.allowedMethods?.includes(req.method!) ?? true; }; - // Only forward the allowed HTTP headers to not forward unwanted secret headers - const headerAllowList = new Set( + // Only return the allowed HTTP headers to not forward unwanted secret headers + const requestHeaderAllowList = new Set( [ // allow all safe headers ...safeForwardHeaders, @@ -104,16 +104,39 @@ export function buildMiddleware( ].map(h => h.toLocaleLowerCase()), ); + // only forward the allowed headers in client->backend fullConfig.onProxyReq = (proxyReq: http.ClientRequest) => { const headerNames = proxyReq.getHeaderNames(); headerNames.forEach(h => { - if (!headerAllowList.has(h.toLocaleLowerCase())) { + if (!requestHeaderAllowList.has(h.toLocaleLowerCase())) { proxyReq.removeHeader(h); } }); }; + // Only forward the allowed HTTP headers to not forward unwanted secret headers + const responseHeaderAllowList = new Set( + [ + // allow all safe headers + ...safeForwardHeaders, + + // allow all configured headers + ...(fullConfig.allowedHeaders || []), + ].map(h => h.toLocaleLowerCase()), + ); + + // only forward the allowed headers in backend->client + fullConfig.onProxyRes = (proxyRes: http.IncomingMessage) => { + const headerNames = Object.keys(proxyRes.headers); + + headerNames.forEach(h => { + if (!responseHeaderAllowList.has(h.toLocaleLowerCase())) { + delete proxyRes.headers[h]; + } + }); + }; + return createProxyMiddleware(filter, fullConfig); } diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index cb1a24d131..2f4fe297db 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,61 @@ # @backstage/plugin-register-component +## 0.2.4 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.2 + +### Patch Changes + +- 2a71f4bab: Remove catalog link on validate popup +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 095469f6b4..08b56a8c05 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.0", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/register-component" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index 2b57050661..c31d1344cb 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -100,7 +100,9 @@ export const RegisterComponentResultDialog = ({ metadata={{ name: entity.metadata.name, type: entity.spec.type, - link: ( + link: dryRun ? ( + entityPath + ) : ( {entityPath} diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index 21f9ce8090..4093e5f3af 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-rollbar-backend +## 0.1.5 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + +## 0.1.4 + +### Patch Changes + +- 3a201c5d5: Add config schema for the rollbar & rollbar-backend plugins +- Updated dependencies [3aa7efb3f] +- Updated dependencies [b3d4e4e57] + - @backstage/backend-common@0.3.2 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.1.2 ### Patch Changes diff --git a/plugins/rollbar-backend/README.md b/plugins/rollbar-backend/README.md index 8f13eb5f26..6cf3b55948 100644 --- a/plugins/rollbar-backend/README.md +++ b/plugins/rollbar-backend/README.md @@ -8,7 +8,6 @@ The following values are read from the configuration file. ```yaml rollbar: - organization: organization-name accountToken: $env: ROLLBAR_ACCOUNT_TOKEN ``` diff --git a/plugins/rollbar-backend/config.d.ts b/plugins/rollbar-backend/config.d.ts new file mode 100644 index 0000000000..473b27a506 --- /dev/null +++ b/plugins/rollbar-backend/config.d.ts @@ -0,0 +1,26 @@ +/* + * 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 Config { + /** Configuration options for the rollbar-backend plugin */ + rollbar?: { + /** + * The autentication token for accessing the Rollbar API + * @see https://explorer.docs.rollbar.com/#section/Authentication + */ + accountToken: string; + }; +} diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index b1764f238f..2c88692964 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.2", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/rollbar-backend" + }, + "keywords": [ + "backstage", + "rollbar" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.4.0", + "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "axios": "^0.20.0", "camelcase-keys": "^6.2.2", @@ -37,11 +47,13 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/rollbar-backend/src/service/router.test.ts b/plugins/rollbar-backend/src/service/router.test.ts index f5dc2f1c3d..c3f1c73e09 100644 --- a/plugins/rollbar-backend/src/service/router.test.ts +++ b/plugins/rollbar-backend/src/service/router.test.ts @@ -106,7 +106,7 @@ describe('createRouter', () => { level: 50, occurrences: 100, projectId: 12345, - title: 'error occured', + title: 'error occurred', uniqueOccurrences: 10, }, counts: [10, 10, 10, 10, 10, 50], diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index ee25fb10d9..50ed3e75b1 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,82 @@ # @backstage/plugin-rollbar +## 0.2.6 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.4 + +### Patch Changes + +- 3a201c5d5: Add config schema for the rollbar & rollbar-backend plugins +- Updated dependencies [6f70ed7a9] +- Updated dependencies [ab94c9542] +- Updated dependencies [2daf18e80] +- Updated dependencies [069cda35f] + - @backstage/plugin-catalog@0.2.4 + - @backstage/catalog-model@0.3.1 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index a232462dde..cc862051f7 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -54,6 +54,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( # app.config.yaml rollbar: organization: organization-name + # used by rollbar-backend accountToken: $env: ROLLBAR_ACCOUNT_TOKEN ``` diff --git a/plugins/sentry/src/data/api-factory.ts b/plugins/rollbar/config.d.ts similarity index 60% rename from plugins/sentry/src/data/api-factory.ts rename to plugins/rollbar/config.d.ts index 88e1148b72..63526d19b0 100644 --- a/plugins/sentry/src/data/api-factory.ts +++ b/plugins/rollbar/config.d.ts @@ -13,16 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryApi } from './sentry-api'; -import { MockSentryApi } from './mock-api'; -import { ProductionSentryApi } from './production-api'; -export function sentryApiFactory( - organization: string, - backendBaseUrl: string, -): SentryApi { - if (process.env.NODE_ENV === 'production') { - return new ProductionSentryApi(organization, backendBaseUrl); - } - return new MockSentryApi(); +export interface Config { + /** Configuration options for the rollbar plugin */ + rollbar?: { + /** + * The Rollbar organization name. This can be omitted by using the `rollbar.com/project-slug` annotation. + * @see https://backstage.io/docs/features/software-catalog/well-known-annotations#rollbarcomproject-slug + * @visibility frontend + */ + organization?: string; + }; } diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 03e393aa1b..7a479deba3 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.2.0", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/rollbar" + }, + "keywords": [ + "backstage", + "rollbar" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", @@ -51,6 +61,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx index c6ee052c0e..ff3e909bf7 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx @@ -52,7 +52,7 @@ describe('RollbarHome component', () => { catalogApiRef, ({ async getEntities() { - return []; + return { items: [] }; }, } as Partial) as CatalogApi, ], diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx index ad3d4a554d..a1c54dfd5a 100644 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx @@ -40,7 +40,7 @@ describe('RollbarProjectPage component', () => { level: 50, occurrences: 100, projectId: 12345, - title: 'error occured', + title: 'error occurred', uniqueOccurrences: 10, }, counts: [10, 10, 10, 10, 10, 50], diff --git a/plugins/rollbar/src/hooks/useRollbarEntities.ts b/plugins/rollbar/src/hooks/useRollbarEntities.ts index 3edf34d1f3..5979c5832a 100644 --- a/plugins/rollbar/src/hooks/useRollbarEntities.ts +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -28,8 +28,8 @@ export function useRollbarEntities() { configApi.getString('organization.name'); const { value, loading, error } = useAsync(async () => { - const entities = await catalogApi.getEntities(); - return entities.filter(entity => { + const response = await catalogApi.getEntities(); + return response.items.filter(entity => { return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION]; }); }, [catalogApi]); diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 78a6d9527b..2962476b72 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,75 @@ # @backstage/plugin-scaffolder-backend +## 0.3.4 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + +## 0.3.2 + +### Patch Changes + +- ef2831dde: Move constructing the catalog-info.yaml URL for scaffolded components to the publishers +- 5a1d8dca3: Fix React entity YAML filename to new standard +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + +## 0.3.1 + +### Patch Changes + +- d33f5157c: Extracted pushToRemote function for reuse between publishers +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.3.0 + +### Minor Changes + +- 59166e5ec: `createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. + + ```js + import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + ``` + + - Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. + +### Patch Changes + +- Updated dependencies [33b7300eb] + - @backstage/backend-common@0.2.1 + ## 0.2.0 ### Minor Changes @@ -16,7 +86,7 @@ - 991a950e0: Added .fromConfig static factories for Preparers and Publishers + read integrations config to support url location types - c926765a2: Allow templates to be located on non-default branch - 6840a68df: Add authentication token to Scaffolder GitHub Preparer -- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub Enterprise instances - 5e4551e3a: Added support for configuring the working directory of the Scaffolder: ```yaml diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts new file mode 100644 index 0000000000..877d40ca4e --- /dev/null +++ b/plugins/scaffolder-backend/config.d.ts @@ -0,0 +1,31 @@ +/* + * 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 Config { + /** Configuration options for the scaffolder plugin */ + scaffolder?: { + github?: { + [key: string]: string; + }; + gitlab?: { + api: { [key: string]: string }; + }; + azure?: { + baseUrl: string; + api: { [key: string]: string }; + }; + }; +} diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index a730446108..b1c9c099b4 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.2.0", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/scaffolder-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,20 +29,20 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/plugin-github-actions": "^0.2.0", - "@backstage/backend-common": "^0.2.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", + "@backstage/plugin-github-actions": "^0.2.4", + "@backstage/backend-common": "^0.4.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", "@gitbeaker/core": "^25.2.0", "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", - "@types/dockerode": "^2.5.32", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "azure-devops-node-api": "^10.1.1", "command-exists-promise": "^2.0.2", "compression": "^1.7.4", "cors": "^2.8.5", - "dockerode": "^3.2.0", + "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", @@ -49,7 +58,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", @@ -59,6 +68,8 @@ "yaml": "^1.10.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 1e26084952..91f887ed0a 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -30,9 +30,9 @@ spec: type: string description: Help others understand what this website is for. use_typescript: - title: Use Typescript + title: Use TypeScript type: boolean - description: Include typescript + description: Include TypeScript default: true use_github_actions: title: Use Github Actions workflows to build this component diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index f1dbb24dd7..25f9b23e59 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -117,7 +117,7 @@ export class JobProcessor implements Processor { // Complete the current stage stage.status = 'COMPLETED'; } catch (error) { - // Log to the current stage the error that occured and fail the stage. + // Log to the current stage the error that occurred and fail the stage. stage.status = 'FAILED'; logger.error(`Stage failed with error: ${error.message}`); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index 8b84eb0edf..9ea7d2de5f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -17,10 +17,13 @@ jest.mock('nodegit'); jest.mock('azure-devops-node-api/GitApi'); jest.mock('azure-devops-node-api/interfaces/GitInterfaces'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { AzurePublisher } from './azure'; import { GitApi } from 'azure-devops-node-api/GitApi'; -import * as NodeGit from 'nodegit'; +import { pushToRemoteUserPass } from './helpers'; const { mockGitApi } = require('azure-devops-node-api/GitApi') as { mockGitApi: { @@ -28,25 +31,6 @@ const { mockGitApi } = require('azure-devops-node-api/GitApi') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('Azure Publisher', () => { const publisher = new AzurePublisher(new GitApi('', []), 'fake-token'); @@ -57,10 +41,10 @@ describe('Azure Publisher', () => { describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { mockGitApi.createRepository.mockResolvedValue({ - remoteUrl: 'mockclone', + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', } as { remoteUrl: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'project/repo', owner: 'bob', @@ -68,109 +52,20 @@ describe('Azure Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + catalogInfoUrl: + 'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml', + }); expect(mockGitApi.createRepository).toHaveBeenCalledWith( { name: 'repo', }, 'project', ); - }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - isOrg: true, - storePath: 'blam/test', - owner: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGitApi.createRepository.mockResolvedValue({ - remoteUrl: 'mockclone', - } as { remoteUrl: string }); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://dev.azure.com/organization/project/_git/repo', 'notempty', 'fake-token', ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index 9dfc7b5b66..1e962bf223 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { GitApi } from 'azure-devops-node-api/GitApi'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export class AzurePublisher implements PublisherBase { private readonly client: GitApi; @@ -34,14 +33,12 @@ export class AzurePublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass(directory, remoteUrl, 'notempty', this.token); + const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; - return { remoteUrl }; + return { remoteUrl, catalogInfoUrl }; } private async createRemote( @@ -54,29 +51,4 @@ export class AzurePublisher implements PublisherBase { return repo.remoteUrl || ''; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - // Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - credentials: () => Cred.userpassPlaintextNew('notempty', this.token), - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index e68b0d0447..cda64faf25 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -16,15 +16,18 @@ jest.mock('@octokit/rest'); jest.mock('nodegit'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { Octokit } from '@octokit/rest'; -import * as NodeGit from 'nodegit'; import { OctokitResponse, ReposCreateInOrgResponseData, UsersGetByUsernameResponseData, } from '@octokit/types'; import { GithubPublisher } from './github'; +import { pushToRemoteUserPass } from './helpers'; const { mockGithubClient } = require('@octokit/rest') as { mockGithubClient: { @@ -34,25 +37,6 @@ const { mockGithubClient } = require('@octokit/rest') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('GitHub Publisher', () => { beforeEach(() => { jest.clearAllMocks(); @@ -69,11 +53,16 @@ describe('GitHub Publisher', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -82,6 +71,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ org: 'blam', name: 'test', @@ -97,12 +91,18 @@ describe('GitHub Publisher', () => { repo: 'test', permission: 'admin', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -111,7 +111,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -120,6 +120,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ @@ -127,13 +132,19 @@ describe('GitHub Publisher', () => { private: false, }); expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); it('should invite other user in the authed user', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -142,7 +153,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -152,6 +163,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ @@ -165,113 +181,12 @@ describe('GitHub Publisher', () => { username: 'bob', permission: 'admin', }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - storePath: 'blam/test', - owner: 'lols', - access: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as OctokitResponse); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( - 'abc', - 'x-oauth-basic', - ); - }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); @@ -285,7 +200,7 @@ describe('GitHub Publisher', () => { it('creates a private repository in the organization with visibility set to internal', async () => { mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -294,7 +209,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { isOrg: true, storePath: 'blam/test', @@ -303,12 +218,23 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ org: 'blam', name: 'test', private: true, visibility: 'internal', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); @@ -322,7 +248,7 @@ describe('GitHub Publisher', () => { it('creates a private repository', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -331,7 +257,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -339,12 +265,23 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ name: 'test', private: true, }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 60ed86daa1..64976b8e41 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { Octokit } from '@octokit/rest'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; @@ -47,14 +46,20 @@ export class GithubPublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass( + directory, + remoteUrl, + this.token, + 'x-oauth-basic', + ); + const catalogInfoUrl = remoteUrl.replace( + /\.git$/, + '/blob/master/catalog-info.yaml', + ); - return { remoteUrl }; + return { remoteUrl, catalogInfoUrl }; } private async createRemote( @@ -104,29 +109,4 @@ export class GithubPublisher implements PublisherBase { return data?.clone_url; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - credentials: () => { - return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic'); - }, - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 43b19859de..a87fa6c6d9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -16,11 +16,14 @@ jest.mock('nodegit'); jest.mock('@gitbeaker/node'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { GitlabPublisher } from './gitlab'; import { Gitlab as GitlabAPI } from '@gitbeaker/core'; import { Gitlab } from '@gitbeaker/node'; -import * as NodeGit from 'nodegit'; +import { pushToRemoteUserPass } from './helpers'; const { mockGitlabClient } = require('@gitbeaker/node') as { mockGitlabClient: { @@ -30,25 +33,6 @@ const { mockGitlabClient } = require('@gitbeaker/node') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('GitLab Publisher', () => { const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); @@ -61,8 +45,11 @@ describe('GitLab Publisher', () => { mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, } as { id: number }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'mockclone', + } as { http_url_to_repo: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { isOrg: true, storePath: 'blam/test', @@ -71,10 +58,17 @@ describe('GitLab Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ remoteUrl: 'mockclone' }); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'mockclone', + 'oauth2', + 'fake-token', + ); }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { @@ -86,7 +80,7 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -94,109 +88,15 @@ describe('GitLab Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ remoteUrl: 'mockclone' }); expect(mockGitlabClient.Users.current).toHaveBeenCalled(); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, name: 'test', }); - }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - isOrg: true, - storePath: 'blam/test', - owner: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( 'oauth2', 'fake-token', ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index a755a138b5..4fae8e8a5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { Gitlab } from '@gitbeaker/core'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export class GitlabPublisher implements PublisherBase { private readonly client: Gitlab; @@ -33,12 +32,9 @@ export class GitlabPublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass(directory, remoteUrl, 'oauth2', this.token); return { remoteUrl }; } @@ -63,28 +59,4 @@ export class GitlabPublisher implements PublisherBase { return project?.http_url_to_repo; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - credentials: () => Cred.userpassPlaintextNew('oauth2', this.token), - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts new file mode 100644 index 0000000000..3a317adacd --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts @@ -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. + */ +jest.mock('nodegit'); +import * as NodeGit from 'nodegit'; +import { pushToRemoteCred } from './helpers'; + +const { + Repository, + mockRepo, + mockIndex, + Signature, + Remote, + mockRemote, + Cred, +} = require('nodegit') as { + Repository: jest.Mocked<{ init: any }>; + Signature: jest.Mocked<{ now: any }>; + Cred: jest.Mocked<{ userpassPlaintextNew: any }>; + Remote: jest.Mocked<{ create: any }>; + + mockIndex: jest.Mocked; + mockRepo: jest.Mocked; + mockRemote: jest.Mocked; +}; + +describe('pushToRemoteCred', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const directory = '/tmp/test/dir'; + const remote = 'mockclone'; + const credentialsProvider = () => + NodeGit.Cred.userpassPlaintextNew('username', 'password'); + + it('should call init on the repo with the directory', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Repository.init).toHaveBeenCalledWith(directory, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Remote.create).toHaveBeenCalledWith(mockRepo, 'origin', 'mockclone'); + }); + + it('shoud push to the remote repo', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'username', + 'password', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts new file mode 100644 index 0000000000..51f5a4c854 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.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. + */ +import { Repository, Remote, Signature, Cred } from 'nodegit'; + +export async function pushToRemoteCred( + directory: string, + remote: string, + credentialsProvider: () => Cred, +): Promise { + const repo = await Repository.init(directory, 0); + const index = await repo.refreshIndex(); + await index.addAll(); + await index.write(); + const oid = await index.writeTree(); + await repo.createCommit( + 'HEAD', + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + 'initial commit', + oid, + [], + ); + + const remoteRepo = await Remote.create(repo, 'origin', remote); + + await remoteRepo.push(['refs/heads/master:refs/heads/master'], { + callbacks: { + credentials: credentialsProvider, + }, + }); +} + +export async function pushToRemoteUserPass( + directory: string, + remote: string, + username: string, + password: string, +): Promise { + return pushToRemoteCred(directory, remote, () => + Cred.userpassPlaintextNew(username, password), + ); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index db383aaf7f..961fc45cae 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -102,12 +102,12 @@ export class Publishers implements PublisherBuilder { } } - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); + const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab'); if (gitLabConfig) { try { - const gitLabToken = gitLabConfig.getString('token'); + const gitLabToken = gitLabConfig.getConfig('api').getString('token'); const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), + host: gitLabConfig.getConfig('api').getOptionalString('baseUrl'), token: gitLabToken, }); const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index 6e9c45ba43..f3bc59e19d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -29,11 +29,17 @@ export type PublisherBase = { * catalog, plus the values from the form and the directory that has * been templated */ - publish(opts: { - entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }>; + publish(opts: PublisherOptions): Promise; +}; + +export type PublisherOptions = { + values: RequiredTemplateValues & Record; + directory: string; +}; + +export type PublisherResult = { + remoteUrl: string; + catalogInfoUrl?: string; }; export type PublisherBuilder = { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts index 7d27688c0b..ce7e76fb47 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -70,7 +70,6 @@ export class CreateReactAppTemplater implements TemplaterBase { extraAnnotations[GITHUB_ACTIONS_ANNOTATION] = options.values.storePath; } - // Need to also make a component-info.yaml to store the data about the service. const componentInfo = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -89,7 +88,7 @@ export class CreateReactAppTemplater implements TemplaterBase { }; await fs.promises.writeFile( - `${finalDir}/component-info.yaml`, + `${finalDir}/catalog-info.yaml`, yaml.stringify(componentInfo), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts index a4441ca23f..57e47f4c27 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts @@ -17,16 +17,9 @@ import Stream, { PassThrough } from 'stream'; import os from 'os'; import fs from 'fs'; import Docker from 'dockerode'; -import { runDockerContainer } from './helpers'; +import { UserOptions, runDockerContainer } from './helpers'; describe('helpers', () => { - if (process.platform === 'win32') { - // eslint-disable-next-line jest/no-focused-tests - it.only('should skip tests on windows', () => { - expect('test').not.toBe('run'); - }); - } - const mockDocker = new Docker() as jest.Mocked; beforeEach(() => { @@ -142,12 +135,17 @@ describe('helpers', () => { dockerClient: mockDocker, }); + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + expect(mockDocker.run).toHaveBeenCalledWith( imageName, args, expect.any(Stream), expect.objectContaining({ - User: `${process.getuid()}:${process.getgid()}`, + ...userOptions, Env: ['HOME=/tmp'], }), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index e71b63bfb0..645133a4ae 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -36,6 +36,10 @@ export type RunCommandOptions = { logStream?: Writable; }; +export type UserOptions = { + User?: string; +}; + /** * Gets the templater key to use for templating from the entity * @param entity Template entity @@ -62,7 +66,7 @@ export const runCommand = async ({ args, logStream = new PassThrough(), }: RunCommandOptions) => { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const process = spawn(command, args); process.stdout.on('data', stream => { @@ -105,7 +109,7 @@ export const runDockerContainer = async ({ dockerClient, createOptions = {}, }: RunDockerContainerOptions) => { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { dockerClient.pull(imageName, {}, (err, stream) => { if (err) return reject(err); stream.pipe(logStream, { end: false }); @@ -115,6 +119,17 @@ export const runDockerContainer = async ({ }); }); + const userOptions: UserOptions = {}; + // @ts-ignore + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, @@ -129,11 +144,7 @@ export const runDockerContainer = async ({ `${await fs.promises.realpath(templateDir)}:/template`, ], }, - // Files that are created inside the Docker container will be owned by - // root on the host system on non Mac systems, because of reasons. Mainly the fact that - // volume sharing is done using NFS on Mac and actual mounts in Linux world. - // So we set the user in the container as the same user and group id as the host. - User: `${process.getuid()}:${process.getgid()}`, + ...userOptions, // Set the home directory inside the container as something that applications can // write to, otherwise they will just flop and fail trying to write to / Env: ['HOME=/tmp'], diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index c2b60a2040..633d16b2a4 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -172,8 +172,8 @@ describe('createRouter', () => { }, use_typescript: { default: true, - description: 'Include typescript', - title: 'Use Typescript', + description: 'Include TypeScript', + title: 'Use TypeScript', type: 'boolean', }, }, diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2dc6a794fb..ac3b1c80ec 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -156,12 +156,11 @@ export async function createRouter( handler: async (ctx: StageContext<{ resultDir: string }>) => { const publisher = publishers.get(ctx.entity); ctx.logger.info('Will now store the template'); - const { remoteUrl } = await publisher.publish({ - entity: ctx.entity, + const result = await publisher.publish({ values: ctx.values, directory: ctx.resultDir, }); - return { remoteUrl }; + return result; }, }, ], diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 9c09366174..08915241e0 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,97 @@ # @backstage/plugin-scaffolder +## 0.3.3 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.2 + +### Patch Changes + +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.3.1 + +### Patch Changes + +- ef2831dde: Move constructing the catalog-info.yaml URL for scaffolded components to the publishers +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.3.0 + +### Minor Changes + +- 59166e5ec: `createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. + + ```js + import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + ``` + + - Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes @@ -23,7 +115,7 @@ ![failed-to-create-component](https://user-images.githubusercontent.com/33940798/94339296-90969400-0016-11eb-9a74-ce16b3dd8d88.gif) - c5ef12926: fix the accordion details design when job stage fail -- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub Enterprise instances - Updated dependencies [28edd7d29] - Updated dependencies [819a70229] - Updated dependencies [3a4236570] diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 738734ceb7..6c802d8130 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.2.0", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/scaffolder" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,9 +50,9 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index d5ac80c3d4..01a6346d24 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -13,51 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Button } from '@backstage/core'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; import { + Button as Action, Dialog, DialogActions, DialogContent, DialogTitle, LinearProgress, } from '@material-ui/core'; -import React, { useEffect, useState } from 'react'; -import { generatePath } from 'react-router-dom'; + +import React, { useCallback, useState } from 'react'; import { Job } from '../../types'; import { JobStage } from '../JobStage/JobStage'; -import { useJobPolling } from './useJobPolling'; type Props = { - onClose: () => void; - onComplete: (job: Job) => void; - jobId: string; - entity: TemplateEntityV1alpha1 | null; + job: Job | null; + toCatalogLink?: string; }; -export const JobStatusModal = ({ - onClose, - jobId, - onComplete, - entity, -}: Props) => { - const job = useJobPolling(jobId); - const jobStatus = job?.status ?? ''; +export const JobStatusModal = ({ job, toCatalogLink }: Props) => { + const [isOpen, setOpen] = useState(true); - const [dialogTitle, setDialogTitle] = useState('Creating component...'); + const renderTitle = () => { + switch (job?.status) { + case 'COMPLETED': + return 'Successfully created component'; + case 'FAILED': + return 'Failed to create component'; + default: + return 'Create component'; + } + }; - useEffect(() => { - if (jobStatus === 'COMPLETED') { - setDialogTitle('Successfully created component'); - onComplete(job!); - } else if (jobStatus === 'FAILED') - setDialogTitle('Failed to create component'); - }, [jobStatus, onComplete, setDialogTitle]); // eslint-disable-line react-hooks/exhaustive-deps + const onClose = useCallback(() => { + if (!job) { + return; + } + if (job.status === 'COMPLETED' || job.status === 'FAILED') { + setOpen(false); + } + }, [job]); return ( - - {dialogTitle} + + {renderTitle()} {!job ? ( @@ -74,16 +74,14 @@ export const JobStatusModal = ({ )) )} - {entity && ( + {job?.status && toCatalogLink && ( - + + + )} + {job?.status === 'FAILED' && ( + + setOpen(false)}>Close )} diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index f0ba720b39..528d42b94f 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -69,8 +69,9 @@ export const MultistepJsonForm = ({ {steps.map(({ label, schema, ...formProps }) => ( {label} - +
{ const { data: templates, isValidating, error } = useStaleWhileRevalidate( 'templates/all', - async () => - catalogApi.getEntities({ kind: 'Template' }) as Promise< - TemplateEntityV1alpha1[] - >, + async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template' }, + }); + return response.items as TemplateEntityV1alpha1[]; + }, ); useEffect(() => { @@ -81,7 +83,7 @@ export const ScaffolderPage = () => { variant="contained" color="primary" component={RouterLink} - to="/register-component" + to="/catalog-import" > Register Existing Component diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 6229a88fc3..62963b58b8 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { TemplatePage } from './TemplatePage'; -import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils'; -import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core'; -import { scaffolderApiRef, ScaffolderApi } from '../../api'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; -import { mutate } from 'swr'; -import { act } from 'react-dom/test-utils'; -import { Route, MemoryRouter } from 'react-router'; -import { rootRoute } from '../../routes'; -import { ThemeProvider } from '@material-ui/core'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter, Route } from 'react-router'; +import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { rootRoute } from '../../routes'; +import { TemplatePage } from './TemplatePage'; const templateMock = { apiVersion: 'backstage.io/v1alpha1', @@ -90,48 +89,43 @@ const apis = ApiRegistry.from([ ]); describe('TemplatePage', () => { - afterEach(async () => { - // Cleaning up swr's cache - await act(async () => { - await mutate('templates/test'); - }); - }); + beforeEach(() => jest.resetAllMocks()); + it('renders correctly', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]); - const rendered = await renderWithEffects( - wrapInTestApp( - - - , - ), + catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] }); + const rendered = await renderInTestApp( + + + , ); expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); expect(rendered.queryByText('React SSR Template')).toBeInTheDocument(); // await act(async () => await mutate('templates/test')); }); + it('renders spinner while loading', async () => { let resolve: Function; const promise = new Promise(res => { resolve = res; }); - catalogApiMock.getEntities.mockResolvedValueOnce(promise); - const rendered = await renderWithEffects( - wrapInTestApp( - - - , - ), + catalogApiMock.getEntities.mockReturnValueOnce(promise); + const rendered = await renderInTestApp( + + + , ); expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); // Need to cleanup the promise or will timeout - resolve!(); + act(() => { + resolve!({ items: [] }); + }); }); it('navigates away if no template was loaded', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce([]); + catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] }); const rendered = await renderWithEffects( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 3f403f0191..0754b64d7f 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -23,32 +23,34 @@ import { Page, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import React, { useState, useCallback } from 'react'; +import { generatePath, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; -import useStaleWhileRevalidate from 'swr'; +import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { JobStatusModal } from '../JobStatusModal'; -import { Job } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; -import { Navigate } from 'react-router'; import { rootRoute } from '../../routes'; +import { JobStatusModal } from '../JobStatusModal'; +import { MultistepJsonForm } from '../MultistepJsonForm'; +import { useJobPolling } from '../hooks/useJobPolling'; const useTemplate = ( templateName: string, catalogApi: typeof catalogApiRef.T, ) => { - const { data, error } = useStaleWhileRevalidate( - `templates/${templateName}`, - async () => - catalogApi.getEntities({ - kind: 'Template', - 'metadata.name': templateName, - }) as Promise, - ); - return { template: data?.[0], loading: !error && !data, error }; + const { value, loading, error } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template', 'metadata.name': templateName }, + }); + return response.items as TemplateEntityV1alpha1[]; + }); + return { template: value?.[0], loading, error }; }; const OWNER_REPO_SCHEMA = { @@ -83,58 +85,55 @@ export const TemplatePage = () => { const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); + const [catalogLink, setCatalogLink] = useState(); const { template, loading } = useTemplate(templateName, catalogApi); - const [formState, setFormState] = useState({}); - + const [modalOpen, setModalOpen] = useState(false); const handleFormReset = () => setFormState({}); - const handleChange = (e: IChangeEvent) => - setFormState({ ...formState, ...e.formData }); + const handleChange = useCallback( + (e: IChangeEvent) => setFormState({ ...formState, ...e.formData }), + [setFormState, formState], + ); const [jobId, setJobId] = useState(null); - const handleClose = () => setJobId(null); + const job = useJobPolling(jobId, async job => { + if (!job.metadata.catalogInfoUrl) { + errorApi.post( + new Error(`No catalogInfoUrl returned from the scaffolder`), + ); + return; + } + + try { + const { + entities: [createdEntity], + } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); + + const resolvedPath = generatePath( + `/catalog/${entityRoute.path}`, + entityRouteParams(createdEntity), + ); + + setCatalogLink(resolvedPath); + } catch (ex) { + errorApi.post( + new Error( + `Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`, + ), + ); + } + }); const handleCreate = async () => { try { - const job = await scaffolderApi.scaffold(templateName, formState); - setJobId(job); + const jobId = await scaffolderApi.scaffold(templateName, formState); + setJobId(jobId); + setModalOpen(true); } catch (e) { errorApi.post(e); } }; - const [entity, setEntity] = React.useState( - null, - ); - - const handleCreateComplete = useCallback( - async (job: Job) => { - const target = job.metadata.remoteUrl?.replace( - /\.git$/, - // TODO(Rugvip): This is not the location we want. As part of scaffodler v2 we - // want this to be more flexible, but before that we might want - // to update all templates to use catalog-info.yaml instead. - '/blob/master/component-info.yaml', - ); - - if (!target) { - errorApi.post( - new Error( - `Failed to find component-info.yaml file in ${job.metadata.remoteUrl}.`, - ), - ); - return; - } - - const { - entities: [createdEntity], - } = await catalogApi.addLocation({ target }); - - setEntity((createdEntity as any) as TemplateEntityV1alpha1); - }, - [catalogApi, setEntity, errorApi], - ); - if (!loading && !template) { errorApi.post(new Error('Template was not found.')); return ; @@ -162,14 +161,7 @@ export const TemplatePage = () => { /> {loading && } - {jobId && ( - - )} + {modalOpen && } {template && ( Promise, ms: number) => { - let shouldStop = false; - (async () => { - while (!shouldStop) { - await thunk(); - await new Promise(res => setTimeout(res, ms)); - } - })(); - - return () => { - shouldStop = true; - }; -}; export const useJobPolling = ( jobId: string | null, + onFinish?: (j: Job) => void, pollingInterval = DEFAULT_POLLING_INTERVAL, ) => { const scaffolderApi = useApi(scaffolderApiRef); - const [job, setJob] = useState(null); - - useEffect(() => { - if (!jobId) return () => {}; - - const stopPolling = poll(async () => { - const nextJobState = await scaffolderApi.getJob(jobId); - if ( - nextJobState.status === 'FAILED' || - nextJobState.status === 'COMPLETED' - ) { - stopPolling(); + const [currentJob, setCurrentJob] = useState(null); + const shouldBeRunningInterval = + jobId && + currentJob?.status !== 'COMPLETED' && + currentJob?.status !== 'FAILED'; + useInterval( + async () => { + if (jobId) { + const job = await scaffolderApi.getJob(jobId); + if (job?.status === 'COMPLETED' || job?.status === 'FAILED') { + onFinish?.(job); + } + setCurrentJob(job); } - setJob(nextJobState); - }, pollingInterval); - return () => { - stopPolling(); - }; - }, [jobId, setJob, scaffolderApi, pollingInterval]); + }, + shouldBeRunningInterval ? pollingInterval : null, + ); - return job; + return currentJob; }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 7106444904..45672c603d 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; export type Job = { id: string; metadata: { entity: any; values: any; remoteUrl?: string; + catalogInfoUrl?: string; }; - status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + status: JobStatus; stages: Stage[]; error?: Error; }; @@ -28,7 +31,7 @@ export type Job = { export type Stage = { name: string; log: string[]; - status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + status: JobStatus; startedAt: string; endedAt?: string; }; diff --git a/plugins/search/.eslintrc.js b/plugins/search/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/search/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md new file mode 100644 index 0000000000..f00437b19a --- /dev/null +++ b/plugins/search/CHANGELOG.md @@ -0,0 +1,41 @@ +# @backstage/plugin-search + +## 0.2.3 + +### Patch Changes + +- 6a0d7a9fb: change default size for pageSize in search result view +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + +## 0.2.1 + +### Patch Changes + +- 475fc0aaa: Using the search field in the sidebar now navigates to the search result page. +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 diff --git a/plugins/search/README.md b/plugins/search/README.md new file mode 100644 index 0000000000..fb4b047811 --- /dev/null +++ b/plugins/search/README.md @@ -0,0 +1,9 @@ +# Backstage Search + +**This plugin is still under development.** + +You can follow the progress under the Global search in Backstage [milestone](https://github.com/backstage/backstage/milestone/21) or reach out to us in the #search Discord channel. + +## Getting started + +Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search)to check out the plugin. diff --git a/plugins/search/dev/index.tsx b/plugins/search/dev/index.tsx new file mode 100644 index 0000000000..264d6f801f --- /dev/null +++ b/plugins/search/dev/index.tsx @@ -0,0 +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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/search/package.json b/plugins/search/package.json new file mode 100644 index 0000000000..ffafbafbbd --- /dev/null +++ b/plugins/search/package.json @@ -0,0 +1,60 @@ +{ + "name": "@backstage/plugin-search", + "version": "0.2.3", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/search" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.4.0", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/catalog-model": "^0.5.0", + "@backstage/theme": "^0.2.2", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "qs": "^6.9.4", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts new file mode 100644 index 0000000000..e74741e27b --- /dev/null +++ b/plugins/search/src/apis.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogApi } from '@backstage/plugin-catalog'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; + +export type Result = { + name: string; + description: string | undefined; + owner: string | undefined; + kind: string; + lifecycle: string | undefined; + url: string; +}; + +export type SearchResults = Array; + +class SearchApi { + private catalogApi: CatalogApi; + + constructor(catalogApi: CatalogApi) { + this.catalogApi = catalogApi; + } + + private async entities() { + const entities = await this.catalogApi.getEntities(); + return entities.items.map((entity: Entity) => ({ + name: entity.metadata.name, + description: entity.metadata.description, + owner: + typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined, + kind: entity.kind, + lifecycle: + typeof entity.spec?.lifecycle === 'string' + ? entity.spec?.lifecycle + : undefined, + url: `/catalog/${ + entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE + }/${entity.kind.toLowerCase()}/${entity.metadata.name}`, + })); + } + + public getSearchResult(): Promise { + return this.entities(); + } +} + +export default SearchApi; diff --git a/plugins/search/src/components/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx new file mode 100644 index 0000000000..7bd344e12e --- /dev/null +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + makeStyles, + Typography, + Divider, + Card, + CardHeader, + Button, + CardContent, + Select, + Checkbox, + List, + ListItem, + ListItemText, + MenuItem, +} from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + filters: { + background: 'transparent', + boxShadow: '0px 0px 0px 0px', + }, + checkbox: { + padding: theme.spacing(0, 1, 0, 1), + }, + dropdown: { + width: '100%', + }, +})); + +export type FiltersState = { + selected: string; + checked: Array; +}; + +export type FilterOptions = { + kind: Array; + lifecycle: Array; +}; + +type FiltersProps = { + filters: FiltersState; + filterOptions: FilterOptions; + resetFilters: () => void; + updateSelected: (filter: string) => void; + updateChecked: (filter: string) => void; +}; + +export const Filters = ({ + filters, + filterOptions, + resetFilters, + updateSelected, + updateChecked, +}: FiltersProps) => { + const classes = useStyles(); + + return ( + + Filters} + action={ + + } + /> + + {filterOptions.kind.length === 0 && filterOptions.lifecycle.length === 0 && ( + + + Filters cannot be applied to available results + + + )} + {filterOptions.kind.length > 0 && ( + + Kind + + + )} + {filterOptions.lifecycle.length > 0 && ( + + Lifecycle + + {filterOptions.lifecycle.map(filter => ( + updateChecked(filter)} + > + + + + ))} + + + )} + + ); +}; diff --git a/plugins/search/src/components/Filters/FiltersButton.tsx b/plugins/search/src/components/Filters/FiltersButton.tsx new file mode 100644 index 0000000000..4775e9d8b8 --- /dev/null +++ b/plugins/search/src/components/Filters/FiltersButton.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import { makeStyles, IconButton, Typography } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + filters: { + width: '250px', + display: 'flex', + }, + icon: { + margin: theme.spacing(-1, 0, 0, 0), + }, +})); + +type FiltersButtonProps = { + numberOfSelectedFilters: number; + handleToggleFilters: () => void; +}; + +export const FiltersButton = ({ + numberOfSelectedFilters, + handleToggleFilters, +}: FiltersButtonProps) => { + const classes = useStyles(); + + return ( +
+ + + + + Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0}) + +
+ ); +}; diff --git a/plugins/search/src/components/Filters/index.tsx b/plugins/search/src/components/Filters/index.tsx new file mode 100644 index 0000000000..ea431a3c01 --- /dev/null +++ b/plugins/search/src/components/Filters/index.tsx @@ -0,0 +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. + */ + +export { FiltersButton } from './FiltersButton'; +export { Filters } from './Filters'; +export type { FiltersState } from './Filters'; diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..9aa48e7284 --- /dev/null +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { Paper } from '@material-ui/core'; +import InputBase from '@material-ui/core/InputBase'; +import IconButton from '@material-ui/core/IconButton'; +import SearchIcon from '@material-ui/icons/Search'; +import ClearButton from '@material-ui/icons/Clear'; + +const useStyles = makeStyles(() => ({ + root: { + display: 'flex', + alignItems: 'center', + }, + input: { + flex: 1, + }, +})); + +type SearchBarProps = { + searchQuery: string; + handleSearch: any; + handleClearSearchBar: any; +}; + +export const SearchBar = ({ + searchQuery, + handleSearch, + handleClearSearchBar, +}: SearchBarProps) => { + const classes = useStyles(); + + return ( + handleSearch(e)} + className={classes.root} + > + + + + handleSearch(e)} + inputProps={{ 'aria-label': 'search backstage' }} + /> + handleClearSearchBar()}> + + + + ); +}; diff --git a/plugins/search/src/components/SearchBar/index.tsx b/plugins/search/src/components/SearchBar/index.tsx new file mode 100644 index 0000000000..e2b8af1946 --- /dev/null +++ b/plugins/search/src/components/SearchBar/index.tsx @@ -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 { SearchBar } from './SearchBar'; diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx new file mode 100644 index 0000000000..33e89e90dc --- /dev/null +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -0,0 +1,65 @@ +/* + * 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 { Content, Header, Page, useQueryParamState } from '@backstage/core'; +import { Grid } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { SearchBar } from '../SearchBar'; +import { SearchResult } from '../SearchResult'; + +export const SearchPage = () => { + const [queryString, setQueryString] = useQueryParamState('query'); + const [searchQuery, setSearchQuery] = useState(queryString ?? ''); + + const handleSearch = (event: React.ChangeEvent) => { + event.preventDefault(); + setSearchQuery(event.target.value); + }; + + useEffect(() => setSearchQuery(queryString ?? ''), [queryString]); + + useDebounce( + () => { + setQueryString(searchQuery); + }, + 200, + [searchQuery], + ); + + const handleClearSearchBar = () => { + setSearchQuery(''); + }; + + return ( + +
+ + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchPage/index.tsx b/plugins/search/src/components/SearchPage/index.tsx new file mode 100644 index 0000000000..acdc0967ab --- /dev/null +++ b/plugins/search/src/components/SearchPage/index.tsx @@ -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 { SearchPage } from './SearchPage'; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx new file mode 100644 index 0000000000..4bbb15e229 --- /dev/null +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -0,0 +1,273 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useEffect } from 'react'; +import { useAsync } from 'react-use'; + +import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { + Link, + EmptyState, + Progress, + Table, + TableColumn, + useApi, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; + +import { FiltersButton, Filters, FiltersState } from '../Filters'; +import SearchApi, { Result, SearchResults } from '../../apis'; + +const useStyles = makeStyles(theme => ({ + searchQuery: { + color: theme.palette.text.primary, + background: theme.palette.background.default, + borderRadius: '10%', + }, + tableHeader: { + margin: theme.spacing(1, 0, 0, 0), + display: 'flex', + }, + divider: { + width: '1px', + margin: theme.spacing(0, 2), + padding: theme.spacing(2, 0), + }, +})); + +type SearchResultProps = { + searchQuery?: string; +}; + +type TableHeaderProps = { + searchQuery?: string; + numberOfSelectedFilters: number; + numberOfResults: number; + handleToggleFilters: () => void; +}; + +type Filters = { + selected: string; + checked: Array; +}; + +// TODO: move out column to make the search result component more generic +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'name', + highlight: true, + render: (result: Partial) => ( + {result.name} + ), + }, + { + title: 'Description', + field: 'description', + }, + { + title: 'Owner', + field: 'owner', + }, + { + title: 'Kind', + field: 'kind', + }, + { + title: 'LifeCycle', + field: 'lifecycle', + }, +]; + +const TableHeader = ({ + searchQuery, + numberOfSelectedFilters, + numberOfResults, + handleToggleFilters, +}: TableHeaderProps) => { + const classes = useStyles(); + + return ( +
+ + + + {searchQuery ? ( + + {`${numberOfResults} `} + {numberOfResults > 1 ? `results for ` : `result for `} + "{searchQuery}"{' '} + + ) : ( + {`${numberOfResults} results`} + )} + +
+ ); +}; + +export const SearchResult = ({ searchQuery }: SearchResultProps) => { + const catalogApi = useApi(catalogApiRef); + + const [showFilters, toggleFilters] = useState(false); + const [selectedFilters, setSelectedFilters] = useState({ + selected: 'All', + checked: [], + }); + + const [filteredResults, setFilteredResults] = useState([]); + + const searchApi = new SearchApi(catalogApi); + + const { loading, error, value: results } = useAsync(() => { + return searchApi.getSearchResult(); + }, []); + + useEffect(() => { + if (results) { + let withFilters = results; + + // apply filters + + // filter on selected + if (selectedFilters.selected !== 'All') { + withFilters = results.filter((result: Result) => + selectedFilters.selected.includes(result.kind), + ); + } + + // filter on checked + if (selectedFilters.checked.length > 0) { + withFilters = withFilters.filter( + (result: Result) => + result.lifecycle && + selectedFilters.checked.includes(result.lifecycle), + ); + } + + // filter on searchQuery + if (searchQuery) { + withFilters = withFilters.filter( + (result: Result) => + result.name?.toLowerCase().includes(searchQuery) || + result.name + ?.toLowerCase() + .includes(searchQuery.split(' ').join('-')) || + result.description?.toLowerCase().includes(searchQuery), + ); + } + + setFilteredResults(withFilters); + } + }, [selectedFilters, searchQuery, results]); + if (loading) { + return ; + } + if (error) { + return ( + + Error encountered while fetching search results. {error.toString()} + + ); + } + if (!results || results.length === 0) { + return ; + } + + const resetFilters = () => { + setSelectedFilters({ + selected: 'All', + checked: [], + }); + }; + + const updateSelected = (filter: string) => { + setSelectedFilters(prevState => ({ + ...prevState, + selected: filter, + })); + }; + + const updateChecked = (filter: string) => { + if (selectedFilters.checked.includes(filter)) { + setSelectedFilters(prevState => ({ + ...prevState, + checked: prevState.checked.filter(item => item !== filter), + })); + return; + } + + setSelectedFilters(prevState => ({ + ...prevState, + checked: [...prevState.checked, filter], + })); + }; + + const filterOptions = results.reduce( + (acc, curr) => { + if (curr.kind && acc.kind.indexOf(curr.kind) < 0) { + acc.kind.push(curr.kind); + } + if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) { + acc.lifecycle.push(curr.lifecycle); + } + return acc; + }, + { + kind: [] as Array, + lifecycle: [] as Array, + }, + ); + + return ( + <> + + {showFilters && ( + + + + )} + +
toggleFilters(!showFilters)} + /> + } + /> + + + + ); +}; diff --git a/plugins/sentry/src/components/SentryPluginPage/index.ts b/plugins/search/src/components/SearchResult/index.tsx similarity index 92% rename from plugins/sentry/src/components/SentryPluginPage/index.ts rename to plugins/search/src/components/SearchResult/index.tsx index 67b34db517..cf10135fd0 100644 --- a/plugins/sentry/src/components/SentryPluginPage/index.ts +++ b/plugins/search/src/components/SearchResult/index.tsx @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './SentryPluginPage'; +export { SearchResult } from './SearchResult'; diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx new file mode 100644 index 0000000000..7e72e83b02 --- /dev/null +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useCallback } from 'react'; +import qs from 'qs'; +import { useNavigate } from 'react-router-dom'; +import { SidebarSearchField } from '@backstage/core'; + +export const SidebarSearch = () => { + const navigate = useNavigate(); + const handleSearch = useCallback( + (query: string): void => { + const queryString = qs.stringify({ query }, { addQueryPrefix: true }); + + // TODO: Here the url to the search plugin is hardcoded. We need a way to query the route in the future. + // Maybe an API that I can just call from other places? + navigate(`/search${queryString}`); + }, + [navigate], + ); + + return ; +}; diff --git a/plugins/search/src/components/SidebarSearch/index.ts b/plugins/search/src/components/SidebarSearch/index.ts new file mode 100644 index 0000000000..33869ffb77 --- /dev/null +++ b/plugins/search/src/components/SidebarSearch/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { SidebarSearch } from './SidebarSearch'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx new file mode 100644 index 0000000000..ac47860dc2 --- /dev/null +++ b/plugins/search/src/components/index.tsx @@ -0,0 +1,21 @@ +/* + * 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 './Filters'; +export * from './SearchBar'; +export * from './SearchPage'; +export * from './SearchResult'; +export * from './SidebarSearch'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts new file mode 100644 index 0000000000..77ad7f9266 --- /dev/null +++ b/plugins/search/src/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 { plugin } from './plugin'; +export * from './components'; diff --git a/plugins/search/src/plugin.test.ts b/plugins/search/src/plugin.test.ts new file mode 100644 index 0000000000..92b8d5bcbf --- /dev/null +++ b/plugins/search/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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('search', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts new file mode 100644 index 0000000000..44c7bcb042 --- /dev/null +++ b/plugins/search/src/plugin.ts @@ -0,0 +1,29 @@ +/* + * 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 { createPlugin, createRouteRef } from '@backstage/core'; +import { SearchPage } from './components/SearchPage'; + +export const rootRouteRef = createRouteRef({ + path: '/search', + title: 'search', +}); + +export const plugin = createPlugin({ + id: 'search', + register({ router }) { + router.addRoute(rootRouteRef, SearchPage); + }, +}); diff --git a/plugins/search/src/setupTests.ts b/plugins/search/src/setupTests.ts new file mode 100644 index 0000000000..43b8421558 --- /dev/null +++ b/plugins/search/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'; +import 'cross-fetch/polyfill'; diff --git a/plugins/sentry-backend/CHANGELOG.md b/plugins/sentry-backend/CHANGELOG.md deleted file mode 100644 index 93361e05ff..0000000000 --- a/plugins/sentry-backend/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @backstage/plugin-sentry-backend - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [5249594c5] -- Updated dependencies [56e4eb589] -- Updated dependencies [e37c0a005] -- Updated dependencies [f00ca3cb8] -- Updated dependencies [6579769df] -- Updated dependencies [8c2b76e45] -- Updated dependencies [440a17b39] -- Updated dependencies [8afce088a] -- Updated dependencies [7bbeb049f] - - @backstage/backend-common@0.2.0 diff --git a/plugins/sentry-backend/README.md b/plugins/sentry-backend/README.md deleted file mode 100644 index efd4c1b472..0000000000 --- a/plugins/sentry-backend/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# sentry-backend - -Simple plugin forwarding requests to [Sentry](https://sentry.io) API. diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json deleted file mode 100644 index a1e1531b8b..0000000000 --- a/plugins/sentry-backend/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@backstage/plugin-sentry-backend", - "version": "0.1.2", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "private": false, - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "scripts": { - "start": "backstage-cli backend:dev", - "build": "backstage-cli backend:build", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@types/express": "^4.17.6", - "axios": "^0.20.0", - "compression": "^1.7.4", - "cors": "^2.8.5", - "express": "^4.17.1", - "express-promise-router": "^3.0.3", - "fs-extra": "^9.0.0", - "helmet": "^4.0.0", - "morgan": "^1.10.0", - "winston": "^3.2.1", - "yn": "^4.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.2.0" - }, - "files": [ - "dist" - ] -} diff --git a/plugins/sentry-backend/src/service/router.ts b/plugins/sentry-backend/src/service/router.ts deleted file mode 100644 index 153a8325b5..0000000000 --- a/plugins/sentry-backend/src/service/router.ts +++ /dev/null @@ -1,42 +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. - */ -import { Logger } from 'winston'; -import Router from 'express-promise-router'; -import express from 'express'; -import { getSentryApiForwarder } from './sentry-api'; - -export async function createRouter(logger: Logger): Promise { - const router = Router(); - router.use(express.json()); - - const SENTRY_TOKEN = process.env.SENTRY_TOKEN; - if (!SENTRY_TOKEN) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.', - ); - } - logger.warn( - 'Failed to initialize Sentry backend, set SENTRY_TOKEN environment variable to start the API.', - ); - } else { - const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger); - - router.use(sentryForwarder); - } - - return router; -} diff --git a/plugins/sentry-backend/src/service/sentry-api.ts b/plugins/sentry-backend/src/service/sentry-api.ts deleted file mode 100644 index 8d35038840..0000000000 --- a/plugins/sentry-backend/src/service/sentry-api.ts +++ /dev/null @@ -1,47 +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. - */ -import express from 'express'; -import axios from 'axios'; -import { Logger } from 'winston'; - -export function getRequestHeaders(token: string) { - return { - headers: { - Authorization: `Bearer ${token}`, - }, - }; -} - -export function getSentryApiForwarder(token: string, logger: Logger) { - return function forwardRequest( - request: express.Request, - response: express.Response, - ) { - const sentryUrl = request.path; - const effectiveUrl = `https://sentry.io/${sentryUrl}`; - logger.info(`Calling Sentry REST API, ${effectiveUrl}`); - axios - .get(effectiveUrl, getRequestHeaders(token)) - .then(res => { - response.send(res.data); - }) - .catch(err => { - return response.status(err.response.status).json({ - detail: err.response.statusText, - }); - }); - }; -} diff --git a/plugins/sentry-backend/src/service/standaloneApplication.ts b/plugins/sentry-backend/src/service/standaloneApplication.ts deleted file mode 100644 index fdaad8bb2e..0000000000 --- a/plugins/sentry-backend/src/service/standaloneApplication.ts +++ /dev/null @@ -1,42 +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. - */ - -import { - errorHandler, - notFoundHandler, - requestLoggingHandler, -} from '@backstage/backend-common'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import { Logger } from 'winston'; -import { createRouter } from './router'; - -export async function createStandaloneApplication( - logger: Logger, -): Promise { - const app = express(); - - app.use(helmet()); - app.use(cors()); - app.use(express.json()); - app.use(requestLoggingHandler()); - app.use('/', await createRouter(logger)); - app.use(notFoundHandler()); - app.use(errorHandler()); - - return app; -} diff --git a/plugins/sentry-backend/src/service/standaloneServer.ts b/plugins/sentry-backend/src/service/standaloneServer.ts deleted file mode 100644 index 37b87c5c40..0000000000 --- a/plugins/sentry-backend/src/service/standaloneServer.ts +++ /dev/null @@ -1,42 +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. - */ - -import { Server } from 'http'; -import { Logger } from 'winston'; -import { createStandaloneApplication } from './standaloneApplication'; - -export async function startStandaloneServer( - parentLogger: Logger, -): Promise { - const logger = parentLogger.child({ service: 'scaffolder-backend' }); - logger.debug('Creating application...'); - - const app = await createStandaloneApplication(logger); - - logger.debug('Starting application server...'); - const PORT = parseInt(process.env.PORT || '5001', 10); - return await new Promise((resolve, reject) => { - const server = app.listen(PORT, (err?: Error) => { - if (err) { - reject(err); - return; - } - - logger.info(`Listening on port ${PORT}`); - resolve(server); - }); - }); -} diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 06380c39d9..862dfd30eb 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,125 @@ # @backstage/plugin-sentry +## 0.3.0 + +### Minor Changes + +- 075d3dc5a: The plugin uses the `proxy-backend` instead of a custom `sentry-backend`. + It requires a proxy configuration: + + `app-config.yaml`: + + ```yaml + proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN # export SENTRY_TOKEN="Bearer " + ``` + + The `MockApiBackend` is no longer configured by the `NODE_ENV` variable. + Instead, the mock backend can be used with an api-override: + + `packages/app/src/apis.ts`: + + ```ts + import { createApiFactory } from '@backstage/core'; + import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + + export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), + ]; + ``` + + If you already use the Sentry backend, you must remove it from the backend: + + Delete `packages/backend/src/plugins/sentry.ts`. + + ```diff + # packages/backend/package.json + + ... + "@backstage/plugin-scaffolder-backend": "^0.3.2", + - "@backstage/plugin-sentry-backend": "^0.1.3", + "@backstage/plugin-techdocs-backend": "^0.3.0", + ... + ``` + + ```diff + // packages/backend/src/index.html + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/rollbar', await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + - apiRouter.use('/sentry', await sentry(sentryEnv)); + apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', await proxy(proxyEnv)); + apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use(notFoundHandler()); + ``` + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.2.4 + +### Patch Changes + +- 303c5ea17: Refactor route registration to remove deprecating code +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/catalog-model@0.4.0 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] + - @backstage/core@0.3.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 73cca29d34..5d280de249 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -1,17 +1,116 @@ -# sentry +# Sentry Plugin -Welcome to the sentry plugin! +The Sentry Plugin displays issues from [Sentry](https://sentry.io). -_This plugin was created through the Backstage CLI_ +![Sentry Card](./docs/sentry-card.png) -## Getting started +## Getting Started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/sentry](http://localhost:3000/sentry). +1. Install the Sentry Plugin: -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +```bash +# packages/app -Needs SENTRY_TOKEN set in the environment for the backend to startup +yarn add @backstage/plugin-sentry +``` -export SENTRY_TOKEN= +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as Sentry } from '@backstage/plugin-sentry'; +``` + +3. Add the `SentryIssuesWidget` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { SentryIssuesWidget } from '@backstage/plugin-sentry'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +> You can also import a `Router` if you want to have a dedicated sentry page: +> +> ```tsx +> // packages/app/src/components/catalog/EntityPage.tsx +> +> import { Router as SentryRouter } from '@backstage/plugin-sentry'; +> +> const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( +> +> // ... +> path="/sentry" +> title="Sentry" +> element={} +> /> +> // ... +> +> ); +> ``` + +4. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Bearer ' + $env: SENTRY_TOKEN + +sentry: + organization: +``` + +5. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. + +6. Add the `sentry.io/project-slug` annotation to your catalog-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: + sentry.io/project-slug: YOUR_PROJECT_SLUG +spec: + type: library + owner: CNCF + lifecycle: experimental +``` + +### Demo Mode + +The plugin provides a MockAPI that always returns dummy data instead of talking to the sentry backend. +You can add it by overriding the `sentryApiRef`: + +```ts +// packages/app/src/apis.ts + +import { createApiFactory } from '@backstage/core'; +import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + +export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), +]; +``` diff --git a/plugins/sentry/config.d.ts b/plugins/sentry/config.d.ts new file mode 100644 index 0000000000..4718568236 --- /dev/null +++ b/plugins/sentry/config.d.ts @@ -0,0 +1,26 @@ +/* + * 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 Config { + /** Configuration options for the sentry plugin */ + sentry: { + /** + * The 'organization' attribute + * @visibility frontend + */ + organization: string; + }; +} diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index 812a5585d4..fc75008040 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -14,7 +14,94 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { + Content, + createPlugin, + createRouteRef, + Header, + Page, +} from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { + MockSentryApi, + SentryApi, + sentryApiRef, + SentryIssuesWidget, +} from '../src'; +import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: sentryApiRef, + deps: {}, + factory: () => + ({ + fetchIssues: async (project: string) => { + switch (project) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'with-values': + return new MockSentryApi().fetchIssues(); + + default: + return []; + } + }, + } as SentryApi), + }) + .registerPlugin( + createPlugin({ + id: 'sentry-demo', + register({ router }) { + const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SENTRY_PROJECT_SLUG_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + + const ExamplePage = () => ( + +
+ + + + + + + + + + + + + + + + + + + + + ); + + router.addRoute( + createRouteRef({ path: '/', title: 'Sentry' }), + ExamplePage, + ); + }, + }), + ) + .render(); diff --git a/plugins/sentry/docs/sentry-card.png b/plugins/sentry/docs/sentry-card.png new file mode 100644 index 0000000000..30aad2a56e Binary files /dev/null and b/plugins/sentry/docs/sentry-card.png differ diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 2ede8fd709..7b4ca9f083 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.2.0", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/sentry" + }, + "keywords": [ + "backstage", + "sentry" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,13 +31,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -36,18 +45,21 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/sentry/src/api/index.ts b/plugins/sentry/src/api/index.ts new file mode 100644 index 0000000000..4cccfdb7a1 --- /dev/null +++ b/plugins/sentry/src/api/index.ts @@ -0,0 +1,21 @@ +/* + * 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 './mock'; +export type { SentryApi } from './sentry-api'; +export { sentryApiRef } from './sentry-api'; +export type { SentryIssue } from './sentry-issue'; +export { ProductionSentryApi } from './production-api'; diff --git a/plugins/sentry/src/api/mock/index.ts b/plugins/sentry/src/api/mock/index.ts new file mode 100644 index 0000000000..b65fb7a919 --- /dev/null +++ b/plugins/sentry/src/api/mock/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 { MockSentryApi } from './mock-api'; diff --git a/plugins/sentry/src/data/mock-api.ts b/plugins/sentry/src/api/mock/mock-api.ts similarity index 93% rename from plugins/sentry/src/data/mock-api.ts rename to plugins/sentry/src/api/mock/mock-api.ts index 8026fec4ee..6743cee79f 100644 --- a/plugins/sentry/src/data/mock-api.ts +++ b/plugins/sentry/src/api/mock/mock-api.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryIssue } from './sentry-issue'; -import { SentryApi } from './sentry-api'; + +import { SentryIssue } from '../sentry-issue'; +import { SentryApi } from '../sentry-api'; import mockData from './sentry-issue-mock.json'; function getMockIssue(): SentryIssue { diff --git a/plugins/sentry/src/data/sentry-issue-mock.json b/plugins/sentry/src/api/mock/sentry-issue-mock.json similarity index 100% rename from plugins/sentry/src/data/sentry-issue-mock.json rename to plugins/sentry/src/api/mock/sentry-issue-mock.json diff --git a/plugins/sentry/src/data/production-api.ts b/plugins/sentry/src/api/production-api.ts similarity index 53% rename from plugins/sentry/src/data/production-api.ts rename to plugins/sentry/src/api/production-api.ts index 5d21e80604..bf6fa98bce 100644 --- a/plugins/sentry/src/data/production-api.ts +++ b/plugins/sentry/src/api/production-api.ts @@ -13,36 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { SentryIssue } from './sentry-issue'; import { SentryApi } from './sentry-api'; +import { DiscoveryApi } from '@backstage/core'; export class ProductionSentryApi implements SentryApi { - private organization: string; - private backendBaseUrl: string; - - constructor(organization: string, backendBaseUrl: string) { - this.organization = organization; - this.backendBaseUrl = backendBaseUrl; - } + constructor( + private readonly discoveryApi: DiscoveryApi, + private readonly organization: string, + ) {} async fetchIssues(project: string, statsFor: string): Promise { - try { - const apiBaseUrl = `${this.backendBaseUrl}/sentry/api/0/projects/`; - - const response = await fetch( - `${apiBaseUrl}/${this.organization}/${project}/issues/?statsFor=${statsFor}`, - ); - - if (response.status >= 400 && response.status < 600) { - throw new Error('Failed fetching Sentry issues'); - } - - return (await response.json()) as SentryIssue[]; - } catch (exception) { - if (exception.detail) { - return exception; - } - throw new Error('Unknown error'); + if (!project) { + return []; } + + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sentry/api`; + + const response = await fetch( + `${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsFor=${statsFor}`, + ); + + if (response.status >= 400 && response.status < 600) { + throw new Error('Failed fetching Sentry issues'); + } + + return (await response.json()) as SentryIssue[]; } } diff --git a/plugins/sentry/src/data/sentry-api.ts b/plugins/sentry/src/api/sentry-api.ts similarity index 78% rename from plugins/sentry/src/data/sentry-api.ts rename to plugins/sentry/src/api/sentry-api.ts index 538900b738..1edb550ec4 100644 --- a/plugins/sentry/src/data/sentry-api.ts +++ b/plugins/sentry/src/api/sentry-api.ts @@ -13,7 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { SentryIssue } from './sentry-issue'; +import { createApiRef } from '@backstage/core'; + +export const sentryApiRef = createApiRef({ + id: 'plugin.sentry.service', + description: 'Used by the Sentry plugin to make requests', +}); export interface SentryApi { fetchIssues(project: string, statsFor: string): Promise; diff --git a/plugins/sentry/src/data/sentry-issue.ts b/plugins/sentry/src/api/sentry-issue.ts similarity index 99% rename from plugins/sentry/src/data/sentry-issue.ts rename to plugins/sentry/src/api/sentry-issue.ts index 14621bf629..017396229d 100644 --- a/plugins/sentry/src/data/sentry-issue.ts +++ b/plugins/sentry/src/api/sentry-issue.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + type SentryPlatform = 'javascript' | 'javascript-react' | string; type EventPoint = number[]; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx index 74f1501225..b02c2860ad 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ErrorCell } from './ErrorCell'; import React from 'react'; import { render } from '@testing-library/react'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx index 617bdb3ce9..8834c12f06 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; + +import React from 'react'; +import { SentryIssue } from '../../api'; import { Link, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; @@ -41,9 +42,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const ErrorCell: FC<{ sentryIssue: SentryIssue }> = ({ - sentryIssue, -}) => { +export const ErrorCell = ({ sentryIssue }: { sentryIssue: SentryIssue }) => { const classes = useStyles(); return (
diff --git a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx index 7643c5ac6e..f7e2049455 100644 --- a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx +++ b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; + +import React from 'react'; +import { SentryIssue } from '../../api'; import { Sparklines, SparklinesBars } from 'react-sparklines'; -export const ErrorGraph: FC<{ sentryIssue: SentryIssue }> = ({ - sentryIssue, -}) => { +export const ErrorGraph = ({ sentryIssue }: { sentryIssue: SentryIssue }) => { const data = '12h' in sentryIssue.stats ? sentryIssue.stats['12h'] diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx index 6d15268629..7bed3700ab 100644 --- a/plugins/sentry/src/components/Router.tsx +++ b/plugins/sentry/src/components/Router.tsx @@ -13,28 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { Routes, Route } from 'react-router'; -import { MissingAnnotationEmptyState } from '@backstage/core'; -import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; - -const SENTRY_ANNOTATION = 'sentry.io/project-slug'; +import { Route, Routes } from 'react-router'; +import { SentryIssuesWidget } from './SentryIssuesWidget'; export const Router = ({ entity }: { entity: Entity }) => { - const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; - - if (!projectId) { - return ; - } - return ( - } + element={} /> ) diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx index 6926fdfc2a..441104c0ee 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { render } from '@testing-library/react'; import SentryIssuesTable from './SentryIssuesTable'; -import { SentryIssue } from '../../data/sentry-issue'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import { SentryIssue } from '../../api'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index aae8009d98..72081bf2da 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Table, TableColumn } from '@backstage/core'; -import { SentryIssue } from '../../data/sentry-issue'; +import { SentryIssue } from '../../api'; import { format } from 'timeago.js'; import { ErrorCell } from '../ErrorCell/ErrorCell'; import { ErrorGraph } from '../ErrorGraph/ErrorGraph'; diff --git a/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx new file mode 100644 index 0000000000..c52b76c4bc --- /dev/null +++ b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { + EmptyState, + ErrorApi, + errorApiRef, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; +import { useAsync } from 'react-use'; +import { sentryApiRef } from '../../api'; +import { + SENTRY_PROJECT_SLUG_ANNOTATION, + useProjectSlug, +} from '../useProjectSlug'; +import { Entity } from '@backstage/catalog-model'; + +export const SentryIssuesWidget = ({ + entity, + statsFor = '24h', + variant = 'gridItem', +}: { + entity: Entity; + statsFor?: '24h' | '12h'; + variant?: string; +}) => { + const errorApi = useApi(errorApiRef); + const sentryApi = useApi(sentryApiRef); + + const projectId = useProjectSlug(entity); + + const { loading, value, error } = useAsync( + () => sentryApi.fetchIssues(projectId, statsFor), + [sentryApi, statsFor, projectId], + ); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + if (loading || !projectId || error) { + return ( + + {loading && } + + {!loading && !projectId && ( + + )} + + {!loading && error && ( + + )} + + ); + } + + return ; +}; diff --git a/plugins/sentry/src/components/SentryIssuesWidget/index.ts b/plugins/sentry/src/components/SentryIssuesWidget/index.ts new file mode 100644 index 0000000000..fddc1374f2 --- /dev/null +++ b/plugins/sentry/src/components/SentryIssuesWidget/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 { SentryIssuesWidget } from './SentryIssuesWidget'; diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx deleted file mode 100644 index 21c21ce385..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx +++ /dev/null @@ -1,58 +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. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import SentryPluginPage from './SentryPluginPage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; -import { msw } from '@backstage/test-utils'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; - -import { - ApiProvider, - ApiRegistry, - errorApiRef, - configApiRef, -} from '@backstage/core'; - -const errorApi = { post: () => {} }; -const ConfigApi = { getString: () => 'test' }; - -describe('SentryPluginPage', () => { - const server = setupServer(); - msw.setupDefaultHandlers(server); - - it('should render header and time switched', () => { - server.use(rest.get('/', (_req, res, ctx) => res(ctx.json({})))); - const rendered = render( - - - - - , - ); - expect(rendered.getByText('Sentry issues')).toBeInTheDocument(); - expect(rendered.getByText('24H')).toBeInTheDocument(); - expect(rendered.getByText('12H')).toBeInTheDocument(); - }); -}); diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx deleted file mode 100644 index 763a8d753f..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx +++ /dev/null @@ -1,69 +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. - */ - -import React, { FC, useState } from 'react'; -import { Grid } from '@material-ui/core'; -import { - Header, - Page, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { SentryPluginWidget } from '../SentryPluginWidget/SentryPluginWidget'; -import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab'; - -const SentryPluginPage: FC<{}> = () => { - const [statsFor, setStatsFor] = useState<'12h' | '24h'>('12h'); - const toggleStatsFor = () => setStatsFor(statsFor === '12h' ? '12h' : '24h'); - const sentryProjectId = 'sample-sentry-project-id'; - - return ( - -
- - - - - 24H - - - 12H - - - - Sentry plugin allows you to preview issues and navigate to sentry. - - - - - - - - - - ); -}; - -export default SentryPluginPage; diff --git a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx b/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx deleted file mode 100644 index 8d522df4a7..0000000000 --- a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx +++ /dev/null @@ -1,60 +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. - */ - -import React, { FC, useEffect } from 'react'; -import { - ErrorApi, - errorApiRef, - InfoCard, - Progress, - useApi, - configApiRef, -} from '@backstage/core'; -import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; -import { useAsync } from 'react-use'; -import { sentryApiFactory } from '../../data/api-factory'; - -export const SentryPluginWidget: FC<{ - sentryProjectId: string; - statsFor: '24h' | '12h'; -}> = ({ sentryProjectId, statsFor }) => { - const errorApi = useApi(errorApiRef); - const configApi = useApi(configApiRef); - const org = configApi.getString('sentry.organization'); - const backendBaseUrl = configApi.getString('backend.baseUrl'); - const api = sentryApiFactory(org, backendBaseUrl); - - const { loading, value, error } = useAsync( - () => api.fetchIssues(sentryProjectId, statsFor), - [statsFor, sentryProjectId], - ); - - useEffect(() => { - if (error) { - errorApi.post(error); - } - }, [error, errorApi]); - - if (loading) { - return ( - - - - ); - } - - return ; -}; diff --git a/plugins/sentry/src/components/index.ts b/plugins/sentry/src/components/index.ts new file mode 100644 index 0000000000..b1588954c9 --- /dev/null +++ b/plugins/sentry/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 './SentryIssuesWidget'; diff --git a/plugins/api-docs/src/components/useComponentApiNames.ts b/plugins/sentry/src/components/useProjectSlug.ts similarity index 70% rename from plugins/api-docs/src/components/useComponentApiNames.ts rename to plugins/sentry/src/components/useProjectSlug.ts index 0eabe2b6c7..072d517b05 100644 --- a/plugins/api-docs/src/components/useComponentApiNames.ts +++ b/plugins/sentry/src/components/useProjectSlug.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import { ComponentEntity } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; -export const useComponentApiNames = (entity: ComponentEntity) => { - return (entity.spec?.implementsApis as string[]) || []; +export const SENTRY_PROJECT_SLUG_ANNOTATION = 'sentry.io/project-slug'; + +export const useProjectSlug = (entity: Entity) => { + return entity?.metadata.annotations?.[SENTRY_PROJECT_SLUG_ANNOTATION] ?? ''; }; diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index a0d3cab1be..2b9e2186ec 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './api'; +export * from './components'; export { plugin } from './plugin'; export { Router } from './components/Router'; -export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget'; diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 6b4080dd2b..6a4fe4e5a4 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -14,12 +14,31 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import SentryPluginPage from './components/SentryPluginPage'; +import { + configApiRef, + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { ProductionSentryApi, sentryApiRef } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '/sentry', + title: 'Sentry', +}); export const plugin = createPlugin({ id: 'sentry', - register({ router }) { - router.registerRoute('/sentry', SentryPluginPage); - }, + apis: [ + createApiFactory({ + api: sentryApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new ProductionSentryApi( + discoveryApi, + configApi.getString('sentry.organization'), + ), + }), + ], }); diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 62f88596e7..bccff558c8 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,54 @@ # @backstage/plugin-sonarqube +## 0.1.6 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/catalog-model@0.4.0 + +## 0.1.4 + +### Patch Changes + +- 26484d413: Add configuration schema +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.1.2 ### Patch Changes diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index af18017359..28bf196ea5 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -9,6 +9,8 @@ The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarclo 1. Install the SonarQube Plugin: ```bash +# packages/app + yarn add @backstage/plugin-sonarqube ``` @@ -43,7 +45,7 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( **SonarCloud** ```yaml -// app-config.yaml +# app-config.yaml proxy: '/sonarqube': @@ -59,7 +61,7 @@ proxy: **SonarQube** ```yaml -// app-config.yaml +# app-config.yaml proxy: '/sonarqube': @@ -77,7 +79,7 @@ sonarQube: 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: +6. Add the `sonarqube.org/project-key` annotation to your catalog-info.yaml file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/packages/backend/src/plugins/sentry.ts b/plugins/sonarqube/config.d.ts similarity index 69% rename from packages/backend/src/plugins/sentry.ts rename to plugins/sonarqube/config.d.ts index 5cd0e55761..5facd9cef7 100644 --- a/packages/backend/src/plugins/sentry.ts +++ b/plugins/sonarqube/config.d.ts @@ -14,9 +14,13 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-sentry-backend'; -import type { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter(logger); +export interface Config { + /** Optional configurations for the SonarQube plugin */ + sonarQube?: { + /** + * The base url of the sonarqube installation. Defaults to https://sonarcloud.io. + * @visibility frontend + */ + baseUrl: string; + }; } diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index 812a5585d4..830d8aa575 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -14,7 +14,149 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { + Content, + createPlugin, + createRouteRef, + Header, + Page, +} from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { SonarQubeCard } from '../src'; +import { FindingSummary, SonarQubeApi, sonarQubeApiRef } from '../src/api'; +import { SONARQUBE_PROJECT_KEY_ANNOTATION } from '../src/components/useProjectKey'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: sonarQubeApiRef, + deps: {}, + factory: () => + ({ + getFindingSummary: async componentKey => { + switch (componentKey) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'not-computed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + bugs: '0', + reliability_rating: '1.0', + vulnerabilities: '0', + security_rating: '1.0', + code_smells: '0', + sqale_rating: '1.0', + coverage: '0.0', + duplicated_lines_density: '0.0', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + } as FindingSummary; + + case 'failed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + alert_status: 'FAILED', + bugs: '4', + reliability_rating: '2.0', + vulnerabilities: '18', + security_rating: '3.0', + code_smells: '22', + sqale_rating: '5.0', + coverage: '15.7', + duplicated_lines_density: '15.6', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + } as FindingSummary; + + case 'passed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + alert_status: 'OK', + bugs: '0', + reliability_rating: '1.0', + vulnerabilities: '0', + security_rating: '1.0', + code_smells: '0', + sqale_rating: '1.0', + coverage: '100.0', + duplicated_lines_density: '0.0', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + } as FindingSummary; + + default: + return undefined; + } + }, + } as SonarQubeApi), + }) + .registerPlugin( + createPlugin({ + id: 'defectdojo-demo', + register({ router }) { + const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + + const ExamplePage = () => ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ); + + router.addRoute( + createRouteRef({ path: '/', title: 'SonarQube' }), + ExamplePage, + ); + }, + }), + ) + .render(); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index bc41213ad8..7d9467b42b 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.2", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,17 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/sonarqube" + }, + "keywords": [ + "backstage", + "sonarqube", + "sonarcloud" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -47,6 +58,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts new file mode 100644 index 0000000000..5734f9a3ca --- /dev/null +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { MetricKey, SonarUrlProcessorFunc } from './types'; + +/** + * Define a type to make sure that all metrics are used + */ +export type Metrics = { + [key in MetricKey]: string | undefined; +}; + +export interface FindingSummary { + lastAnalysis: string; + metrics: Metrics; + projectUrl: string; + getIssuesUrl: SonarUrlProcessorFunc; + getComponentMeasuresUrl: SonarUrlProcessorFunc; +} + +export const sonarQubeApiRef = createApiRef({ + id: 'plugin.sonarqube.service', + description: 'Used by the SonarQube plugin to make requests', +}); + +export type SonarQubeApi = { + getFindingSummary(componentKey?: string): Promise; +}; diff --git a/plugins/sonarqube/src/api/index.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts similarity index 96% rename from plugins/sonarqube/src/api/index.test.ts rename to plugins/sonarqube/src/api/SonarQubeClient.test.ts index 2d96fc2135..a3ae24de52 100644 --- a/plugins/sonarqube/src/api/index.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -18,12 +18,12 @@ import { UrlPatternDiscovery } from '@backstage/core'; import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { FindingSummary, SonarQubeApi } from './index'; +import { FindingSummary, SonarQubeClient } from './index'; import { ComponentWrapper, MeasuresWrapper } from './types'; const server = setupServer(); -describe('SonarQubeApi', () => { +describe('SonarQubeClient', () => { msw.setupDefaultHandlers(server); const mockBaseUrl = 'http://backstage:9191/api/proxy'; @@ -111,7 +111,7 @@ describe('SonarQubeApi', () => { it('should report finding summary', async () => { setupHandlers(); - const client = new SonarQubeApi({ discoveryApi }); + const client = new SonarQubeClient({ discoveryApi }); const summary = await client.getFindingSummary('our-service'); expect(summary).toEqual( @@ -142,7 +142,7 @@ describe('SonarQubeApi', () => { it('should report finding summary (custom baseUrl)', async () => { setupHandlers(); - const client = new SonarQubeApi({ + const client = new SonarQubeClient({ discoveryApi, baseUrl: 'http://a.instance.local', }); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts new file mode 100644 index 0000000000..893f32b0a6 --- /dev/null +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -0,0 +1,101 @@ +/* + * 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 { DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi'; +import { ComponentWrapper, MeasuresWrapper } from './types'; + +export class SonarQubeClient implements 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}`, + getIssuesUrl: identifier => + `${ + this.baseUrl + }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, + getComponentMeasuresUrl: (identifier: string) => + `${ + this.baseUrl + }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, + }; + } +} diff --git a/plugins/sonarqube/src/api/index.ts b/plugins/sonarqube/src/api/index.ts index f2c616f53c..8442465dee 100644 --- a/plugins/sonarqube/src/api/index.ts +++ b/plugins/sonarqube/src/api/index.ts @@ -14,112 +14,6 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; -import fetch from 'cross-fetch'; -import { - ComponentWrapper, - MeasuresWrapper, - MetricKey, - SonarUrlProcessorFunc, -} 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; - getIssuesUrl: SonarUrlProcessorFunc; - getComponentMeasuresUrl: SonarUrlProcessorFunc; -} - -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}`, - getIssuesUrl: identifier => - `${ - this.baseUrl - }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, - getComponentMeasuresUrl: (identifier: string) => - `${ - this.baseUrl - }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, - }; - } -} +export type { Metrics, FindingSummary, SonarQubeApi } from './SonarQubeApi'; +export { sonarQubeApiRef } from './SonarQubeApi'; +export { SonarQubeClient } from './SonarQubeClient'; diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts index 154d4ee3b8..f8b8cafc5c 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -20,7 +20,7 @@ import { createPlugin, discoveryApiRef, } from '@backstage/core'; -import { SonarQubeApi, sonarQubeApiRef } from './api'; +import { sonarQubeApiRef, SonarQubeClient } from './api'; export const plugin = createPlugin({ id: 'sonarqube', @@ -29,7 +29,7 @@ export const plugin = createPlugin({ api: sonarQubeApiRef, deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, factory: ({ configApi, discoveryApi }) => - new SonarQubeApi({ + new SonarQubeClient({ discoveryApi, baseUrl: configApi.getOptionalString('sonarQube.baseUrl'), }), diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 7f9f11a702..246ea9c8f7 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,39 @@ # @backstage/plugin-tech-radar +## 0.3.1 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + +## 0.3.0 + +### Minor Changes + +- a906f20e7: Added tech radar blip history backend support and normalized the data structure + +### Patch Changes + +- 3f05616bf: Make the footer color of the tech-radar work in both light and dark theme. +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 875d6d161b..f10eef259d 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.2.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/tech-radar" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,9 +30,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.2.0", - "@backstage/test-utils": "^0.1.2", - "@backstage/theme": "^0.2.0", + "@backstage/core": "^0.4.0", + "@backstage/test-utils": "^0.1.5", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,9 +44,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 7a6e790136..0c30c9374c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -34,11 +34,17 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: MovedState; - quadrant: RadarQuadrant; - ring: RadarRing; + quadrant: string; title: string; url: string; + timeline: Array; +} + +export interface RadarEntrySnapshot { + date: Date; + ringId: string; + description?: string; + moved?: MovedState; } /** diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 99190202b9..583a7c91e0 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -20,6 +20,7 @@ import { useAsync } from 'react-use'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; import getSampleData from '../sampleData'; +import { Entry } from '../utils/types'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); @@ -47,6 +48,29 @@ const useTechRadarLoader = (props: TechRadarComponentProps) => { const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { const { loading, error, value: data } = useTechRadarLoader(props); + const mapToEntries = ( + loaderResponse: TechRadarLoaderResponse | undefined, + ): Array => { + return loaderResponse!.entries.map(entry => { + return { + id: entry.key, + quadrant: loaderResponse!.quadrants.find(q => q.id === entry.quadrant)!, + title: entry.title, + ring: loaderResponse!.rings.find( + r => r.id === entry.timeline[0].ringId, + )!, + history: entry.timeline.map(e => { + return { + date: e.date, + ring: loaderResponse!.rings.find(a => a.id === e.ringId)!, + description: e.description, + moved: e.moved, + }; + }), + }; + }); + }; + return ( <> {loading && } @@ -55,7 +79,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { {...props} rings={data!.rings} quadrants={data!.quadrants} - entries={data!.entries} + entries={mapToEntries(data)} /> )} diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index a9fa7efd7b..914bc30931 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -35,85 +35,132 @@ quadrants.push({ id: 'process', name: 'Process' }); const entries = new Array(); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua', + }, + ], url: '#', key: 'javascript', id: 'javascript', title: 'JavaScript', - quadrant: { id: 'languages', name: 'Languages' }, + quadrant: 'languages', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat', + }, + ], url: '#', key: 'typescript', id: 'typescript', title: 'TypeScript', - quadrant: { id: 'languages', name: 'Languages' }, + quadrant: 'languages', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'webpack', id: 'webpack', title: 'Webpack', - quadrant: { id: 'frameworks', name: 'Frameworks' }, + quadrant: 'frameworks', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'react', id: 'react', title: 'React', - quadrant: { id: 'frameworks', name: 'Frameworks' }, + quadrant: 'frameworks', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'code-reviews', id: 'code-reviews', title: 'Code Reviews', - quadrant: { id: 'process', name: 'Process' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + moved: 0, + ringId: 'assess', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'mob-programming', id: 'mob-programming', title: 'Mob Programming', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'docs-like-code', id: 'docs-like-code', title: 'Docs-like-code', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + ringId: 'hold', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'force-push', id: 'force-push', title: 'Force push to master', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'hold', name: 'HOLD', color: '#93c47d' }, + quadrant: 'process', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'github-actions', id: 'github-actions', title: 'GitHub Actions', - quadrant: { id: 'infrastructure', name: 'Infrastructure' }, + quadrant: 'infrastructure', }); export default function getSampleData(): Promise { diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index f3933ee584..3322a67dcb 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -69,6 +69,14 @@ export type Entry = { // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved moved?: MovedState; active?: boolean; + timeline?: Array; +}; + +export type EntrySnapshot = { + date: Date; + ring: Ring; + description?: string; + moved?: MovedState; }; // The same as Entry except quadrant/ring are declared by their string ID instead of being the actual objects diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 4191361a33..92901f7e42 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,69 @@ # @backstage/plugin-techdocs-backend +## 0.3.2 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.3.1 + +### Patch Changes + +- ae95c7ff3: Update URL auth format for GitLab clone +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + +## 0.3.0 + +### Minor Changes + +- 4b53294a6: - Use techdocs annotation to add repo_url if missing in mkdocs.yml. Having repo_url creates a Edit button on techdocs pages. + - techdocs-backend: API endpoint `/metadata/mkdocs/*` renamed to `/metadata/techdocs/*` + +### Patch Changes + +- Updated dependencies [3aa7efb3f] +- Updated dependencies [ab94c9542] +- Updated dependencies [2daf18e80] +- Updated dependencies [069cda35f] +- Updated dependencies [b3d4e4e57] + - @backstage/backend-common@0.3.2 + - @backstage/catalog-model@0.3.1 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + ## 0.2.0 ### Minor Changes @@ -17,7 +81,7 @@ Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. -- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens. +- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for GitHub, GitLab, Azure & GitHub Enterprise access tokens. ### Detail: diff --git a/plugins/techdocs-backend/README.md b/plugins/techdocs-backend/README.md index 625f0910b5..c2a1cb99aa 100644 --- a/plugins/techdocs-backend/README.md +++ b/plugins/techdocs-backend/README.md @@ -18,30 +18,13 @@ yarn start ## What techdocs-backend does -This plugin is the backend part of the techdocs plugin. It provides building and serving of your docs without having to use another service and hosting provider. To use it set your techdocs storageUrl in your `app-config.yml` to `http://localhost:7000/api/techdocs/static/docs`. +This plugin is the backend part of the techdocs plugin. It provides serving and building of documentation for any entity. +To configure various storage providers and building options, see http://backstage.io/docs/features/techdocs/configuration -```yaml -techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs -``` - -## Extending techdocs-backend - -Currently the build process of techdocs-backend is split up in these three stages. - -- Preparers -- Generators -- Publishers - -Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/backstage/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. - -Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. - -Publishers gets a folder path from the generator and publish it to your storage solution. Currently the only built in storage solution is a folder called `static/docs` inside the techdocs-backend plugin. - -Any of these can be extended. If we want to publish to a external static file server using rsync for example that can be done by creating a rsync publisher. _(Keep in mind that if you want techdocs-backend to initiate a build this would also require techdocs-backend to act as a proxy, which is not yet implemented.)_ +The techdocs-backend re-exports the [techdocs-common](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) package which has the features to prepare, generate and publish docs. +The Publishers are also used to fetch the static documentation files and render them in TechDocs. ## Links - [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/techdocs) -- [The Backstage homepage](https://backstage.io) +- [Backstage homepage](https://backstage.io) diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index c1d1f6ad27..fc11122ead 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.2.0", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/techdocs-backend" + }, + "keywords": [ + "backstage", + "techdocs" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,24 +30,21 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.2.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/config": "^0.1.1", - "@types/dockerode": "^2.5.34", + "@backstage/backend-common": "^0.4.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/config": "^0.1.2", + "@backstage/techdocs-common": "^0.1.1", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "command-exists-promise": "^2.0.2", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.0", "knex": "^0.21.6", - "nodegit": "^0.27.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.2.0", + "@backstage/cli": "^0.4.1", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts new file mode 100644 index 0000000000..87de9d1d02 --- /dev/null +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts @@ -0,0 +1,27 @@ +/* + * 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 { BuildMetadataStorage } from './BuildMetadataStorage'; + +describe('BuildMetadataStorage', () => { + it('should return build timestamp', () => { + const newMetadataStorage = new BuildMetadataStorage('123abc'); + newMetadataStorage.storeBuildTimestamp(); + + const timestamp = newMetadataStorage.getTimestamp(); + + expect(timestamp).toBeLessThanOrEqual(Date.now()); + }); +}); diff --git a/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts similarity index 83% rename from plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts rename to plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts index b82ec1291a..d6a19764d9 100644 --- a/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts @@ -20,6 +20,11 @@ type buildInfo = { const builds = {} as buildInfo; +/** + * Store timestamps of the most recent TechDocs build of each Entity. This is + * used to invalidate cache if the latest commit in the documentation source + * repository is later than the timestamp. + */ export class BuildMetadataStorage { public entityUid: string; private builds: buildInfo; diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts new file mode 100644 index 0000000000..67d0dfb89e --- /dev/null +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -0,0 +1,129 @@ +/* + * 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 Docker from 'dockerode'; +import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; +import { + PreparerBuilder, + PublisherBase, + GeneratorBuilder, + PreparerBase, + GeneratorBase, + getLocationForEntity, + getLastCommitTimestamp, +} from '@backstage/techdocs-common'; +import { BuildMetadataStorage } from '.'; + +const getEntityId = (entity: Entity) => { + return `${entity.kind}:${entity.metadata.namespace ?? ''}:${ + entity.metadata.name + }`; +}; + +type DocsBuilderArguments = { + preparers: PreparerBuilder; + generators: GeneratorBuilder; + publisher: PublisherBase; + entity: Entity; + logger: Logger; + dockerClient: Docker; +}; + +export class DocsBuilder { + private preparer: PreparerBase; + private generator: GeneratorBase; + private publisher: PublisherBase; + private entity: Entity; + private logger: Logger; + private dockerClient: Docker; + + constructor({ + preparers, + generators, + publisher, + entity, + logger, + dockerClient, + }: DocsBuilderArguments) { + this.preparer = preparers.get(entity); + this.generator = generators.get(entity); + this.publisher = publisher; + this.entity = entity; + this.logger = logger; + this.dockerClient = dockerClient; + } + + public async build() { + this.logger.info(`Running preparer on entity ${getEntityId(this.entity)}`); + const preparedDir = await this.preparer.prepare(this.entity); + + const parsedLocationAnnotation = getLocationForEntity(this.entity); + + this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); + const { resultDir } = await this.generator.run({ + directory: preparedDir, + dockerClient: this.dockerClient, + parsedLocationAnnotation, + }); + + this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); + await this.publisher.publish({ + entity: this.entity, + directory: resultDir, + }); + + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } + + new BuildMetadataStorage(this.entity.metadata.uid).storeBuildTimestamp(); + } + + public async docsUpToDate() { + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } + + const buildMetadataStorage = new BuildMetadataStorage( + this.entity.metadata.uid, + ); + const { type, target } = getLocationForEntity(this.entity); + + // Unless docs are stored locally + const nonAgeCheckTypes = ['dir', 'file', 'url']; + if (!nonAgeCheckTypes.includes(type)) { + const lastCommit = await getLastCommitTimestamp(target, this.logger); + const storageTimeStamp = buildMetadataStorage.getTimestamp(); + + // Check if documentation source is newer than what we have + if (storageTimeStamp && storageTimeStamp >= lastCommit) { + this.logger.debug( + `Docs for entity ${getEntityId(this.entity)} is up to date.`, + ); + return true; + } + } + + this.logger.debug( + `Docs for entity ${getEntityId(this.entity)} was outdated.`, + ); + return false; + } +} diff --git a/plugins/techdocs-backend/src/storage/index.ts b/plugins/techdocs-backend/src/DocsBuilder/index.ts similarity index 95% rename from plugins/techdocs-backend/src/storage/index.ts rename to plugins/techdocs-backend/src/DocsBuilder/index.ts index 3d37f69679..1380e24e7c 100644 --- a/plugins/techdocs-backend/src/storage/index.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './BuildMetadataStorage'; +export * from './builder'; diff --git a/plugins/techdocs-backend/src/helpers.test.ts b/plugins/techdocs-backend/src/helpers.test.ts deleted file mode 100644 index 8d4d69ba26..0000000000 --- a/plugins/techdocs-backend/src/helpers.test.ts +++ /dev/null @@ -1,117 +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. - */ - -import { getDocFilesFromRepository } from './helpers'; -import { UrlReader, ReadTreeResponse } from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; - -describe('getDocFilesFromRepository', () => { - it('should take the directory from UrlReader.readTree and add the docs path when mkdocs.yml is in root', async () => { - class MockUrlReader implements UrlReader { - async read() { - return Buffer.from('mock'); - } - - async readTree(): Promise { - return { - dir: async () => { - return '/tmp/testfolder'; - }, - files: () => { - return []; - }, - archive: async () => { - return Buffer.from(''); - }, - }; - } - } - - const mockEntity: Entity = { - metadata: { - namespace: 'default', - annotations: { - 'backstage.io/techdocs-ref': - 'url:https://github.com/backstage/backstage/blob/master/mkdocs.yml', - }, - name: 'mytestcomponent', - description: 'A component for testing', - }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - spec: { - type: 'documentation', - lifecycle: 'experimental', - owner: 'testuser', - }, - }; - - const output = await getDocFilesFromRepository( - new MockUrlReader(), - mockEntity, - ); - - expect(output).toBe('/tmp/testfolder/.'); - }); - - it('should take the directory from UrlReader.readTree and add the docs path when mkdocs.yml is in a subfolder', async () => { - class MockUrlReader implements UrlReader { - async read() { - return Buffer.from('mock'); - } - - async readTree(): Promise { - return { - dir: async () => { - return '/tmp/testfolder'; - }, - files: () => { - return []; - }, - archive: async () => { - return Buffer.from(''); - }, - }; - } - } - - const mockEntity: Entity = { - metadata: { - namespace: 'default', - annotations: { - 'backstage.io/techdocs-ref': - 'url:https://github.com/backstage/backstage/blob/master/subfolder/mkdocs.yml', - }, - name: 'mytestcomponent', - description: 'A component for testing', - }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - spec: { - type: 'documentation', - lifecycle: 'experimental', - owner: 'testuser', - }, - }; - - const output = await getDocFilesFromRepository( - new MockUrlReader(), - mockEntity, - ); - - expect(output).toBe('/tmp/testfolder/subfolder'); - }); -}); diff --git a/plugins/techdocs-backend/src/index.ts b/plugins/techdocs-backend/src/index.ts index 8c65708017..5c57882788 100644 --- a/plugins/techdocs-backend/src/index.ts +++ b/plugins/techdocs-backend/src/index.ts @@ -15,4 +15,4 @@ */ export * from './service/router'; -export * from './techdocs'; +export * from '@backstage/techdocs-common'; diff --git a/plugins/techdocs-backend/src/service/helpers.test.ts b/plugins/techdocs-backend/src/service/helpers.test.ts new file mode 100644 index 0000000000..507373107d --- /dev/null +++ b/plugins/techdocs-backend/src/service/helpers.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { getEntityNameFromUrlPath } from './helpers'; + +describe('getEntityNameFromUrlPath', () => { + it('should parse correctly', () => { + const path = 'default/Component/documented-component'; + const parsedEntity = getEntityNameFromUrlPath(path); + expect(parsedEntity).toHaveProperty('namespace', 'default'); + expect(parsedEntity).toHaveProperty('kind', 'Component'); + expect(parsedEntity).toHaveProperty('name', 'documented-component'); + }); +}); diff --git a/plugins/techdocs-backend/src/service/helpers.ts b/plugins/techdocs-backend/src/service/helpers.ts index 3017a68461..fc80ab717d 100644 --- a/plugins/techdocs-backend/src/service/helpers.ts +++ b/plugins/techdocs-backend/src/service/helpers.ts @@ -13,113 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Docker from 'dockerode'; -import { Logger } from 'winston'; -import { Entity } from '@backstage/catalog-model'; -import { - PreparerBuilder, - PublisherBase, - GeneratorBuilder, - PreparerBase, - GeneratorBase, -} from '../techdocs'; -import { BuildMetadataStorage } from '../storage'; -import { getLocationForEntity, getLastCommitTimestamp } from '../helpers'; +import { EntityName } from '@backstage/catalog-model'; +/** + * Using the path of the TechDocs page URL, return a structured EntityName type object with namespace, + * kind and name of the Entity. + * @param {string} path Example: default/Component/documented-component + */ +export const getEntityNameFromUrlPath = (path: string): EntityName => { + const [namespace, kind, name] = path.split('/'); -const getEntityId = (entity: Entity) => { - return `${entity.kind}:${entity.metadata.namespace ?? ''}:${ - entity.metadata.name - }`; + return { + namespace, + kind, + name, + }; }; - -type DocsBuilderArguments = { - preparers: PreparerBuilder; - generators: GeneratorBuilder; - publisher: PublisherBase; - entity: Entity; - logger: Logger; - dockerClient: Docker; -}; - -export class DocsBuilder { - private preparer: PreparerBase; - private generator: GeneratorBase; - private publisher: PublisherBase; - private entity: Entity; - private logger: Logger; - private dockerClient: Docker; - - constructor({ - preparers, - generators, - publisher, - entity, - logger, - dockerClient, - }: DocsBuilderArguments) { - this.preparer = preparers.get(entity); - this.generator = generators.get(entity); - this.publisher = publisher; - this.entity = entity; - this.logger = logger; - this.dockerClient = dockerClient; - } - - public async build() { - this.logger.info(`Running preparer on entity ${getEntityId(this.entity)}`); - const preparedDir = await this.preparer.prepare(this.entity); - - this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); - const { resultDir } = await this.generator.run({ - directory: preparedDir, - dockerClient: this.dockerClient, - }); - - this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); - await this.publisher.publish({ - entity: this.entity, - directory: resultDir, - }); - - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - new BuildMetadataStorage(this.entity.metadata.uid).storeBuildTimestamp(); - } - - public async docsUpToDate() { - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - const buildMetadataStorage = new BuildMetadataStorage( - this.entity.metadata.uid, - ); - const { type, target } = getLocationForEntity(this.entity); - - // Unless docs are stored locally - const nonAgeCheckTypes = ['dir', 'file', 'url']; - if (!nonAgeCheckTypes.includes(type)) { - const lastCommit = await getLastCommitTimestamp(target, this.logger); - const storageTimeStamp = buildMetadataStorage.getTimestamp(); - - // Check if documentation source is newer than what we have - if (storageTimeStamp && storageTimeStamp >= lastCommit) { - this.logger.debug( - `Docs for entity ${getEntityId(this.entity)} is up to date.`, - ); - return true; - } - } - - this.logger.debug( - `Docs for entity ${getEntityId(this.entity)} was outdated.`, - ); - return false; - } -} diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 1d932a1fb9..a64f581a1e 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -24,15 +24,12 @@ import { GeneratorBuilder, PreparerBuilder, PublisherBase, - LocalPublish, -} from '../techdocs'; -import { - PluginEndpointDiscovery, - resolvePackagePath, -} from '@backstage/backend-common'; + getLocationForEntity, +} from '@backstage/techdocs-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { DocsBuilder } from './helpers'; -import { getLocationForEntity } from '../helpers'; +import { getEntityNameFromUrlPath } from './helpers'; +import { DocsBuilder } from '../DocsBuilder'; type RouterOptions = { preparers: PreparerBuilder; @@ -45,11 +42,6 @@ type RouterOptions = { dockerClient: Docker; }; -const staticDocsDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', -); - export async function createRouter({ preparers, generators, @@ -61,25 +53,19 @@ export async function createRouter({ }: RouterOptions): Promise { const router = Router(); - router.get('/metadata/mkdocs/*', async (req, res) => { - let storageUrl = config.getString('techdocs.storageUrl'); - if (publisher instanceof LocalPublish) { - storageUrl = new URL( - new URL(storageUrl).pathname, - await discovery.getBaseUrl('techdocs'), - ).toString(); - } + router.get('/metadata/techdocs/*', async (req, res) => { + // path is `:namespace/:kind:/:name` const { '0': path } = req.params; + const entityName = getEntityNameFromUrlPath(path); - const metadataURL = `${storageUrl}/${path}/techdocs_metadata.json`; - - try { - const mkDocsMetadata = await (await fetch(metadataURL)).json(); - res.send(mkDocsMetadata); - } catch (err) { - logger.info(`Unable to get metadata for ${path} with error ${err}`); - throw new Error(`Unable to get metadata for ${path} with error ${err}`); - } + publisher + .fetchTechDocsMetadata(entityName) + .then(techdocsMetadataJson => { + res.send(techdocsMetadataJson); + }) + .catch(reason => { + res.status(500).send(`Unable to get Metadata. Reason: ${reason}`); + }); }); router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => { @@ -107,9 +93,8 @@ export async function createRouter({ }); router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { - const storageUrl = config.getString('techdocs.storageUrl'); - const { kind, namespace, name } = req.params; + const storageUrl = config.getString('techdocs.storageUrl'); const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); @@ -124,25 +109,80 @@ export async function createRouter({ const entity: Entity = await catalogRes.json(); - const docsBuilder = new DocsBuilder({ - preparers, - generators, - publisher, - dockerClient, - logger, - entity, - }); + let publisherType = ''; + try { + publisherType = config.getString('techdocs.publisher.type'); + } catch (err) { + throw new Error( + 'Unable to get techdocs.publisher.type in your app config. Set it to either ' + + "'local', 'googleGcs' or other support storage providers. Read more here " + + 'https://backstage.io/docs/features/techdocs/architecture', + ); + } - if (!(await docsBuilder.docsUpToDate())) { - await docsBuilder.build(); + // techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to 'local' + // If set to 'external', it will only try to fetch and assume that an external process (e.g. CI/CD pipeline + // of the repository) is responsible for building and publishing documentation to the storage provider. + if (config.getString('techdocs.builder') === 'local') { + const docsBuilder = new DocsBuilder({ + preparers, + generators, + publisher, + dockerClient, + logger, + entity, + }); + if (publisherType === 'local') { + if (!(await docsBuilder.docsUpToDate())) { + await docsBuilder.build(); + } + } else if (publisherType === 'googleGcs') { + // This block should be valid for all external storage implementations. So no need to duplicate in future, + // add the publisher type in the list here. + if (!(await publisher.hasDocsBeenGenerated(entity))) { + logger.info( + 'No pre-generated documentation files found for the entity in the storage. Building docs...', + ); + await docsBuilder.build(); + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched + // on the user's page. If not, respond with a message asking them to check back later. + // The delay here is to make sure GCS registers newly uploaded files which is usually <1 second + let foundDocs = false; + for (let attempt = 0; attempt < 5; attempt++) { + if (await publisher.hasDocsBeenGenerated(entity)) { + foundDocs = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!foundDocs) { + logger.error( + 'Published files are taking longer to show up in storage. Something went wrong.', + ); + res + .status(408) + .send( + 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + ); + return; + } + } else { + logger.info( + 'Found pre-generated docs for this entity. Serving them.', + ); + // TODO: re-trigger build for cache invalidation. + // Compare the date modified of the requested file on storage and compare it against + // the last modified or last commit timestamp in the repository. + // Without this, docs will not be re-built once they have been generated. + } + } } res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); }); - if (publisher instanceof LocalPublish) { - router.use('/static/docs', express.static(staticDocsDir)); - } + // Route middleware which serves files from the storage set in the publisher. + router.use('/static/docs', publisher.docsRouter()); return router; } diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 79e8b0b945..c1c4dba8d1 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -27,8 +27,8 @@ import { DirectoryPreparer, Generators, TechdocsGenerator, - LocalPublish, -} from '../techdocs'; + Publisher, +} from '@backstage/techdocs-common'; import { ConfigReader } from '@backstage/config'; export interface ServerOptions { @@ -41,7 +41,18 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'techdocs-backend' }); - const config = ConfigReader.fromConfigs([]); + const config = ConfigReader.fromConfigs([ + { + context: '', + data: { + techdocs: { + publisher: { + type: 'local', + }, + }, + }, + }, + ]); const discovery = SingleHostDiscovery.fromConfig(config); logger.debug('Creating application...'); @@ -53,7 +64,7 @@ export async function startStandaloneServer( const techdocsGenerator = new TechdocsGenerator(logger, config); generators.register('techdocs', techdocsGenerator); - const publisher = new LocalPublish(logger); + const publisher = Publisher.fromConfig(config, logger, discovery); const dockerClient = new Docker(); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts deleted file mode 100644 index 283560f0ce..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts +++ /dev/null @@ -1,141 +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. - */ -import Stream, { PassThrough } from 'stream'; -import os from 'os'; -import Docker from 'dockerode'; -import { runDockerContainer, getGeneratorKey } from './helpers'; - -const mockEntity = { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'testName', - }, -}; - -const mockDocker = new Docker() as jest.Mocked; - -describe('helpers', () => { - describe('getGeneratorKey', () => { - it('should return techdocs as the only generator key', () => { - const key = getGeneratorKey(mockEntity); - expect(key).toBe('techdocs'); - }); - }); - - describe('runDockerContainer', () => { - beforeEach(() => { - jest.spyOn(mockDocker, 'pull').mockImplementation((async ( - _image: string, - _something: any, - handler: (err: Error | undefined, stream: PassThrough) => void, - ) => { - const mockStream = new PassThrough(); - handler(undefined, mockStream); - mockStream.end(); - }) as any); - - jest - .spyOn(mockDocker, 'run') - .mockResolvedValue([{ Error: null, StatusCode: 0 }]); - - jest - .spyOn(mockDocker, 'ping') - .mockResolvedValue(Buffer.from('OK', 'utf-8')); - }); - - const imageName = 'spotify/techdocs'; - const args = ['build', '-d', '/result']; - const docsDir = os.tmpdir(); - const resultDir = os.tmpdir(); - - it('should pull the techdocs docker container', async () => { - await runDockerContainer({ - imageName, - args, - docsDir, - resultDir, - dockerClient: mockDocker, - }); - - expect(mockDocker.pull).toHaveBeenCalledWith( - imageName, - {}, - expect.any(Function), - ); - }); - - it('should run the techdocs docker container', async () => { - await runDockerContainer({ - imageName, - args, - docsDir, - resultDir, - dockerClient: mockDocker, - }); - - expect(mockDocker.run).toHaveBeenCalledWith( - imageName, - args, - expect.any(Stream), - { - Volumes: { - '/content': {}, - '/result': {}, - }, - WorkingDir: '/content', - HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], - }, - }, - ); - }); - - it('should ping docker to test availability', async () => { - await runDockerContainer({ - imageName, - args, - docsDir, - resultDir, - dockerClient: mockDocker, - }); - - expect(mockDocker.ping).toHaveBeenCalled(); - }); - - describe('where docker is unavailable', () => { - const dockerError = 'a docker error'; - - beforeEach(() => { - jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => { - throw new Error(dockerError); - }); - }); - - it('should throw with a descriptive error message including the docker error message', async () => { - await expect( - runDockerContainer({ - imageName, - args, - docsDir, - resultDir, - dockerClient: mockDocker, - }), - ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); - }); - }); - }); -}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts deleted file mode 100644 index d68bb74aa6..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ /dev/null @@ -1,144 +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. - */ - -import { Entity } from '@backstage/catalog-model'; -import { Writable, PassThrough } from 'stream'; -import Docker from 'dockerode'; -import { SupportedGeneratorKey } from './types'; -import { spawn } from 'child_process'; - -// TODO: Implement proper support for more generators. -export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { - if (!entity) { - throw new Error('No entity provided'); - } - - return 'techdocs'; -} - -type RunDockerContainerOptions = { - imageName: string; - args: string[]; - logStream?: Writable; - docsDir: string; - resultDir: string; - dockerClient: Docker; - createOptions?: Docker.ContainerCreateOptions; -}; - -export type RunCommandOptions = { - command: string; - args: string[]; - options: object; - logStream?: Writable; -}; - -export async function runDockerContainer({ - imageName, - args, - logStream = new PassThrough(), - docsDir, - resultDir, - dockerClient, - createOptions, -}: RunDockerContainerOptions) { - try { - await dockerClient.ping(); - } catch (e) { - throw new Error( - `This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`, - ); - } - - await new Promise((resolve, reject) => { - dockerClient.pull(imageName, {}, (err, stream) => { - if (err) return reject(err); - stream.pipe(logStream, { end: false }); - stream.on('end', () => resolve()); - stream.on('error', (error: Error) => reject(error)); - return undefined; - }); - }); - - const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( - imageName, - args, - logStream, - { - Volumes: { - '/content': {}, - '/result': {}, - }, - WorkingDir: '/content', - HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], - }, - ...createOptions, - }, - ); - - if (error) { - throw new Error( - `Docker failed to run with the following error message: ${error}`, - ); - } - - if (statusCode !== 0) { - throw new Error( - `Docker container returned a non-zero exit code (${statusCode})`, - ); - } - - return { error, statusCode }; -} - -/** - * - * @param options the options object - * @param options.command the command to run - * @param options.args the arguments to pass the command - * @param options.options options used in spawn - * @param options.logStream the log streamer to capture log messages - */ -export const runCommand = async ({ - command, - args, - options, - logStream = new PassThrough(), -}: RunCommandOptions) => { - await new Promise((resolve, reject) => { - const process = spawn(command, args, options); - - process.stdout.on('data', stream => { - logStream.write(stream); - }); - - process.stderr.on('data', stream => { - logStream.write(stream); - }); - - process.on('error', error => { - return reject(error); - }); - - process.on('close', code => { - if (code !== 0) { - return reject(`Command ${command} failed, exit code: ${code}`); - } - return resolve(); - }); - }); -}; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts deleted file mode 100644 index 0029ea5c4e..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts +++ /dev/null @@ -1,17 +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 { LocalPublish } from './local'; -export type { PublisherBase } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts deleted file mode 100644 index 6d655d4632..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ /dev/null @@ -1,72 +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. - */ -import fs from 'fs-extra'; -import { Logger } from 'winston'; -import { Entity } from '@backstage/catalog-model'; -import { PublisherBase } from './types'; -import { resolvePackagePath } from '@backstage/backend-common'; - -export class LocalPublish implements PublisherBase { - private readonly logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - publish({ - entity, - directory, - }: { - entity: Entity; - directory: string; - }): - | Promise<{ - remoteUrl: string; - }> - | { remoteUrl: string } { - const entityNamespace = entity.metadata.namespace ?? 'default'; - - const publishDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', - entityNamespace, - entity.kind, - entity.metadata.name, - ); - - if (!fs.existsSync(publishDir)) { - this.logger.info( - `[TechDocs]: Could not find ${publishDir}, creates the directory.`, - ); - fs.mkdirSync(publishDir, { recursive: true }); - } - - return new Promise((resolve, reject) => { - fs.copy(directory, publishDir, err => { - if (err) { - this.logger.debug( - `[TechDocs]: Failed to copy docs from ${directory} to ${publishDir}`, - ); - reject(err); - } - - resolve({ - remoteUrl: `http://localhost:7000/api/techdocs/static/docs/${entity.metadata.name}`, - }); - }); - }); - } -} diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 387e4a8d90..26ee57fb86 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,105 @@ # @backstage/plugin-techdocs +## 0.4.0 + +### Minor Changes + +- 87a33d2fe: Removed modifyCss transformer and moved the css to injectCss transformer + Fixed issue where some internal doc links would cause a reload of the page + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/test-utils@0.1.5 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.1 + +### Patch Changes + +- da2ad65cb: Use type EntityName from catalog-model for entities +- Updated dependencies [b4488ddb0] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/core-api@0.2.4 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + - @backstage/test-utils@0.1.4 + +## 0.3.0 + +### Minor Changes + +- 4b53294a6: - Use techdocs annotation to add repo_url if missing in mkdocs.yml. Having repo_url creates a Edit button on techdocs pages. + - techdocs-backend: API endpoint `/metadata/mkdocs/*` renamed to `/metadata/techdocs/*` + +### Patch Changes + +- Updated dependencies [6f70ed7a9] +- Updated dependencies [ab94c9542] +- Updated dependencies [2daf18e80] +- Updated dependencies [069cda35f] +- Updated dependencies [700a212b4] + - @backstage/plugin-catalog@0.2.4 + - @backstage/catalog-model@0.3.1 + - @backstage/core-api@0.2.3 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + - @backstage/test-utils@0.1.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/techdocs/README.md b/plugins/techdocs/README.md index 2e9365f6c6..c5e8fa6887 100644 --- a/plugins/techdocs/README.md +++ b/plugins/techdocs/README.md @@ -6,9 +6,7 @@ Set up Backstage and TechDocs by follow our guide on [Getting Started](../../doc ## Configuration -### Custom Storage URL - -TechDocs will try to read your documentation from the URL you have specified in the `techdocs storageUrl` in `app-config.yml`. +http://backstage.io/docs/features/techdocs/configuration ### TechDocs Storage Api diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 60a17c3dc3..ae2b8c58ae 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,9 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { ParsedEntityId } from '../src/types'; - +import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; export class TechDocsDevStorageApi implements TechDocsStorage { @@ -25,7 +23,7 @@ export class TechDocsDevStorageApi implements TechDocsStorage { this.apiOrigin = apiOrigin; } - async getEntityDocs(entityId: ParsedEntityId, path: string) { + async getEntityDocs(entityId: EntityName, path: string) { const { name } = entityId; const url = `${this.apiOrigin}/${name}/${path}`; @@ -41,11 +39,7 @@ export class TechDocsDevStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl( - oldBaseUrl: string, - entityId: ParsedEntityId, - path: string, - ): string { + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { const { name } = entityId; return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString(); } diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ce0700c1a8..15325ed7bc 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.2.0", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,9 +10,18 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/techdocs" + }, + "keywords": [ + "backstage", + "techdocs" + ], "scripts": { "build": "backstage-cli plugin:build", - "export": "backstage-cli plugin:export", "start": "backstage-cli plugin:serve", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -22,12 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.2.0", - "@backstage/core-api": "^0.2.0", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/test-utils": "^0.1.2", - "@backstage/theme": "^0.2.0", + "@backstage/catalog-model": "^0.5.0", + "@backstage/core": "^0.4.0", + "@backstage/core-api": "^0.2.5", + "@backstage/plugin-catalog": "^0.2.6", + "@backstage/test-utils": "^0.1.5", + "@backstage/theme": "^0.2.2", + "@backstage/techdocs-common": "^0.1.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -40,9 +50,9 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -54,5 +64,84 @@ }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/techdocs", + "type": "object", + "properties": { + "techdocs": { + "type": "object", + "properties": { + "requestUrl": { + "type": "string", + "visibility": "frontend" + }, + "storageUrl": { + "type": "string", + "visibility": "backend" + }, + "generators": { + "type": "object", + "properties": { + "techdocs": { + "type": "string", + "visibility": "backend" + } + } + }, + "builder": { + "type": "string", + "visibility": "frontend" + }, + "publisher": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "local", + "visibility": "backend" + } + } + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "googleGcs", + "visibility": "backend" + }, + "googleGcs": { + "type": "object", + "properties": { + "credentials": { + "type": "string", + "visibility": "secret" + }, + "projectId": { + "type": "string", + "visibility": "secret" + }, + "bucketName": { + "type": "string", + "visibility": "secret" + } + } + } + } + } + ] + } + }, + "required": [ + "requestUrl", + "storageUrl", + "builder" + ] + } + } + } } diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 31e4a10e4c..c8581ec5d3 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -15,8 +15,7 @@ */ import { createApiRef } from '@backstage/core'; - -import { ParsedEntityId } from './types'; +import { EntityName } from '@backstage/catalog-model'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -29,18 +28,20 @@ export const techdocsApiRef = createApiRef({ }); export interface TechDocsStorage { - getEntityDocs(entityId: ParsedEntityId, path: string): Promise; - getBaseUrl( - oldBaseUrl: string, - entityId: ParsedEntityId, - path: string, - ): string; + getEntityDocs(entityId: EntityName, path: string): Promise; + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string; } export interface TechDocs { - getMetadata(metadataType: string, entityId: ParsedEntityId): Promise; + getTechDocsMetadata(entityId: EntityName): Promise; + getEntityMetadata(entityId: EntityName): Promise; } +/** + * API to talk to techdocs-backend. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ export class TechDocsApi implements TechDocs { public apiOrigin: string; @@ -48,10 +49,38 @@ export class TechDocsApi implements TechDocs { this.apiOrigin = apiOrigin; } - async getMetadata(metadataType: string, entityId: ParsedEntityId) { + /** + * Retrieve TechDocs metadata. + * + * When docs are built, we generate a techdocs_metadata.json and store it along with the generated + * static files. It includes necessary data about the docs site. This method requests techdocs-backend + * which retrieves the TechDocs metadata. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getTechDocsMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${namespace}/${kind}/${name}`; + const requestUrl = `${this.apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + + const request = await fetch(`${requestUrl}`); + const res = await request.json(); + + return res; + } + + /** + * Retrieve metadata about an entity. + * + * This method requests techdocs-backend which uses the catalog APIs to respond with filtered + * information required here. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getEntityMetadata(entityId: EntityName) { + const { kind, namespace, name } = entityId; + + const requestUrl = `${this.apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -60,6 +89,11 @@ export class TechDocsApi implements TechDocs { } } +/** + * API which talks to TechDocs storage to fetch files to render. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ export class TechDocsStorageApi implements TechDocsStorage { public apiOrigin: string; @@ -67,7 +101,15 @@ export class TechDocsStorageApi implements TechDocsStorage { this.apiOrigin = apiOrigin; } - async getEntityDocs(entityId: ParsedEntityId, path: string) { + /** + * Fetch HTML content as text for an individual docs page in an entity's docs site. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new + * @returns {string} HTML content of the docs page as string + * @throws {Error} Throws error when the page is not found. + */ + async getEntityDocs(entityId: EntityName, path: string) { const { kind, namespace, name } = entityId; const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; @@ -77,17 +119,19 @@ export class TechDocsStorageApi implements TechDocsStorage { ); if (request.status === 404) { - throw new Error('Page not found'); + let errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new Error(errorMessage); } return request.text(); } - getBaseUrl( - oldBaseUrl: string, - entityId: ParsedEntityId, - path: string, - ): string { + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { const { kind, namespace, name } = entityId; return new URL( diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index ee2cab60fa..576ba0a292 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -57,6 +57,7 @@ export const rootCatalogDocsRouteRef = createRouteRef({ title: 'Docs', }); +// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl export const plugin = createPlugin({ id: 'techdocs', apis: [ diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 785c6f1c6b..fb49b90096 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -13,16 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React from 'react'; -import { useApi } from '@backstage/core'; -import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import { techdocsStorageApiRef } from '../../api'; -import { useParams, useNavigate } from 'react-router-dom'; -import { ParsedEntityId } from '../../types'; import { useTheme } from '@material-ui/core'; +import { useParams, useNavigate } from 'react-router-dom'; +import { EntityName } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; +import { useShadowDom } from '..'; +import { techdocsStorageApiRef } from '../../api'; import TechDocsProgressBar from './TechDocsProgressBar'; import transformer, { @@ -31,7 +30,6 @@ import transformer, { addLinkClickListener, removeMkdocsHeader, simplifyMkdocsFooter, - modifyCss, onCssReady, sanitizeDOM, injectCss, @@ -39,7 +37,7 @@ import transformer, { import { TechDocsNotFound } from './TechDocsNotFound'; type Props = { - entityId: ParsedEntityId; + entityId: EntityName; onReady?: () => void; }; @@ -72,15 +70,6 @@ export const Reader = ({ entityId, onReady }: Props) => { path, }), rewriteDocLinks(), - modifyCss({ - cssTransforms: { - '.md-main__inner': [{ 'margin-top': '0' }], - '.md-sidebar': [{ top: '0' }, { width: '20rem' }], - '.md-typeset': [{ 'font-size': '1rem' }], - '.md-nav': [{ 'font-size': '1rem' }], - '.md-grid': [{ 'max-width': '80vw' }], - }, - }), removeMkdocsHeader(), simplifyMkdocsFooter(), injectCss({ @@ -93,6 +82,11 @@ export const Reader = ({ entityId, onReady }: Props) => { --md-code-fg-color: ${theme.palette.text.primary}; --md-code-bg-color: ${theme.palette.background.paper}; } + .md-main__inner { margin-top: 0; } + .md-sidebar { top: 0; width: 20rem; } + .md-typeset { font-size: 1rem; } + .md-nav { font-size: 1rem; } + .md-grid { max-width: 80vw; } `, }), ]); @@ -153,7 +147,7 @@ export const Reader = ({ entityId, onReady }: Props) => { ]); if (error) { - return ; + return ; } return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index d5d0e19e08..0924b90fc1 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core-api'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; import { wrapInTestApp } from '@backstage/test-utils'; @@ -24,7 +23,7 @@ import { TechDocsHome } from './TechDocsHome'; describe('TechDocs Home', () => { const catalogApi: Partial = { - getEntities: () => Promise.resolve([] as Entity[]), + getEntities: () => Promise.resolve({ items: [] }), }; const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 0408a540ca..9f569dd167 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ -import React from 'react'; -import { useAsync } from 'react-use'; -import { useNavigate, generatePath } from 'react-router-dom'; -import { Grid } from '@material-ui/core'; import { + Content, + Header, ItemCard, + Page, Progress, useApi, - Content, - Page, - Header, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; +import { useAsync } from 'react-use'; import { rootDocsRouteRef } from '../../plugin'; export const TechDocsHome = () => { @@ -34,8 +34,8 @@ export const TechDocsHome = () => { const navigate = useNavigate(); const { value, loading, error } = useAsync(async () => { - const entities = await catalogApi.getEntities(); - return entities.filter(entity => { + const response = await catalogApi.getEntities(); + return response.items.filter(entity => { return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx index 7b0ec80619..d3c98fec78 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx @@ -27,3 +27,17 @@ describe('', () => { expect(rendered.getByTestId('go-back-link')).toBeDefined(); }); }); + +describe('', () => { + it('should render with status code, custom error message and go back link', () => { + const rendered = render( + wrapInTestApp( + , + ), + ); + rendered.getByText(/This is a custom error message/i); + rendered.getByText(/404/i); + rendered.getByText(/Looks like someone dropped the mic!/i); + expect(rendered.getByTestId('go-back-link')).toBeDefined(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx index c774000bd5..cdacc1cb7e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -15,14 +15,31 @@ */ import React from 'react'; -import { ErrorPage } from '@backstage/core'; +import { ErrorPage, useApi, configApiRef } from '@backstage/core'; + +type Props = { + errorMessage?: string; +}; + +export const TechDocsNotFound = ({ errorMessage }: Props) => { + const techdocsBuilder = useApi(configApiRef).getOptionalString( + 'techdocs.builder', + ); + + let additionalInfo = ''; + if (techdocsBuilder !== 'local') { + additionalInfo = + "Note that techdocs.builder is not set to 'local' in your config, which means this Backstage app will not " + + "generate docs if they are not found. Make sure the project's docs are generated and published by some external " + + "process (e.g. CI/CD pipeline). Or change techdocs.builder to 'local' to generate docs from this Backstage " + + 'instance.'; + } -export const TechDocsNotFound = () => { return ( ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 464115bab7..c05c0488ff 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -50,17 +50,18 @@ describe('', () => { entityId: 'Component::backstage', }); - const techDocsApi: Partial = { - getMetadata: () => Promise.resolve([]), + const techdocsApi: Partial = { + getEntityMetadata: () => Promise.resolve([]), + getTechDocsMetadata: () => Promise.resolve([]), }; - const techDocsStorageApi: Partial = { + const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), getBaseUrl: (): string => '', }; const apiRegistry = ApiRegistry.from([ - [techdocsApiRef, techDocsApi], - [techdocsStorageApiRef, techDocsStorageApi], + [techdocsApiRef, techdocsApi], + [techdocsStorageApiRef, techdocsStorageApi], ]); await act(async () => { diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index 00f0b3c9d3..f2635c0dfb 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -26,19 +26,19 @@ export const TechDocsPage = () => { const [documentReady, setDocumentReady] = useState(false); const { namespace, kind, name } = useParams(); - const techDocsApi = useApi(techdocsApiRef); + const techdocsApi = useApi(techdocsApiRef); - const mkdocsMetadataRequest = useAsync(() => { + const techdocsMetadataRequest = useAsync(() => { if (documentReady) { - return techDocsApi.getMetadata('mkdocs', { kind, namespace, name }); + return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); } return Promise.resolve({ loading: true }); - }, [kind, namespace, name, techDocsApi, documentReady]); + }, [kind, namespace, name, techdocsApi, documentReady]); const entityMetadataRequest = useAsync(() => { - return techDocsApi.getMetadata('entity', { kind, namespace, name }); - }, [kind, namespace, name, techDocsApi]); + return techdocsApi.getEntityMetadata({ kind, namespace, name }); + }, [kind, namespace, name, techdocsApi]); const onReady = () => { setDocumentReady(true); @@ -48,7 +48,7 @@ export const TechDocsPage = () => { ', () => { }, }, }, - mkdocs: { + techdocs: { loading: false, value: { site_name: 'test-site-name', @@ -73,7 +73,7 @@ describe('', () => { entity: { loading: false, }, - mkdocs: { + techdocs: { loading: false, }, }} diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index f4c447bae2..ac9d21d319 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -15,17 +15,16 @@ */ import React from 'react'; -import CodeIcon from '@material-ui/icons/Code'; -import { Header, HeaderLabel, Link } from '@backstage/core'; -import { CircularProgress } from '@material-ui/core'; -import { ParsedEntityId } from '../../types'; import { AsyncState } from 'react-use/lib/useAsync'; +import CodeIcon from '@material-ui/icons/Code'; +import { EntityName } from '@backstage/catalog-model'; +import { Header, HeaderLabel, Link } from '@backstage/core'; type TechDocsPageHeaderProps = { - entityId: ParsedEntityId; + entityId: EntityName; metadataRequest: { entity: AsyncState; - mkdocs: AsyncState; + techdocs: AsyncState; }; }; @@ -33,15 +32,18 @@ export const TechDocsPageHeader = ({ entityId, metadataRequest, }: TechDocsPageHeaderProps) => { - const { mkdocs: mkdocsMetadata, entity: entityMetadata } = metadataRequest; + const { + techdocs: techdocsMetadata, + entity: entityMetadata, + } = metadataRequest; - const { value: mkDocsMetadataValues } = mkdocsMetadata; + const { value: techdocsMetadataValues } = techdocsMetadata; const { value: entityMetadataValues } = entityMetadata; const { kind, name } = entityId; const { site_name: siteName, site_description: siteDescription } = - mkDocsMetadataValues || {}; + techdocsMetadataValues || {}; const { locationMetadata, @@ -83,7 +85,7 @@ export const TechDocsPageHeader = ({ return (
} + title={siteName ? siteName : '.'} pageTitleOverride={siteName || name} subtitle={ siteDescription && siteDescription !== 'None' ? siteDescription : '' diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 7346ca7ce2..8d9d5a7294 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { EntityName } from '@backstage/catalog-model'; import type { Transformer } from './index'; import { TechDocsStorage } from '../../api'; -import { ParsedEntityId } from '../../types'; type AddBaseUrlOptions = { techdocsStorageApi: TechDocsStorage; - entityId: ParsedEntityId; + entityId: EntityName; path: string; }; diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index b6a6509550..7c5fd4d9f4 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -28,13 +28,13 @@ export const addLinkClickListener = ({ return dom => { Array.from(dom.getElementsByTagName('a')).forEach(elem => { elem.addEventListener('click', (e: MouseEvent) => { - const target = e.target as HTMLAnchorElement; - const href = target?.getAttribute('href'); + const target = elem as HTMLAnchorElement; + const href = target.getAttribute('href'); if (!href) return; if (href.startsWith(baseUrl)) { e.preventDefault(); - onClick(e, target.getAttribute('href')!); + onClick(e, href); } }); }); diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 0bab085abe..24f3976d75 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -19,7 +19,6 @@ export * from './rewriteDocLinks'; export * from './addLinkClickListener'; export * from './removeMkdocsHeader'; export * from './simplifyMkdocsFooter'; -export * from './modifyCss'; export * from './onCssReady'; export * from './sanitizeDOM'; export * from './injectCss'; diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx deleted file mode 100644 index fff3869c96..0000000000 --- a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx +++ /dev/null @@ -1,58 +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. - */ - -import { createTestShadowDom } from '../../test-utils'; -import { modifyCss } from '../transformers'; - -describe('modifyCss', () => { - it('does not modify css', () => { - const shadowDom = createTestShadowDom( - `
`, - { - preTransformers: [], - postTransformers: [], - }, - ); - - const { fontSize } = getComputedStyle( - shadowDom.querySelector('.md-typeset')!, - ); - - expect(fontSize).toBe('0.8em'); - }); - - it('does modify css', () => { - const shadowDom = createTestShadowDom( - `
`, - { - preTransformers: [ - modifyCss({ - cssTransforms: { - '.md-typeset': [{ 'font-size': '1em' }], - }, - }), - ], - postTransformers: [], - }, - ); - - const { fontSize } = getComputedStyle( - shadowDom.querySelector('.md-typeset')!, - ); - - expect(fontSize).toBe('1em'); - }); -}); diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.ts b/plugins/techdocs/src/reader/transformers/modifyCss.ts deleted file mode 100644 index 5116baac45..0000000000 --- a/plugins/techdocs/src/reader/transformers/modifyCss.ts +++ /dev/null @@ -1,43 +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. - */ - -import type { Transformer } from './index'; - -type ModifyCssOptions = { - // Example: { '.md-container': { 'marginTop': '10px' }} - cssTransforms: { [key: string]: { [key: string]: string }[] }; -}; - -export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => { - return dom => { - Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => { - const elementsToChange = Array.from( - dom.querySelectorAll(cssSelector), - ); - if (elementsToChange.length < 1) return; - - cssChanges.forEach(changes => { - elementsToChange.forEach((element: HTMLElement) => { - Object.entries(changes).forEach(([cssProperty, cssValue]) => { - element.style.setProperty(cssProperty, cssValue); - }); - }); - }); - }); - - return dom; - }; -}; diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index 2522a3af03..f213193728 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/plugin-user-settings +## 0.2.3 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] + - @backstage/core@0.3.1 + +## 0.2.1 + +### Patch Changes + +- 5a2705de2: Export `AuthProviders`, `DefaultProviderSettings` and `ProviderSettingsItem`. +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 2f098eae2e..57aff26d27 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-user-settings", - "version": "0.2.0", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/user-settings" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.2.0", - "@backstage/theme": "^0.2.0", + "@backstage/core": "^0.4.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.2.0", - "@backstage/dev-utils": "^0.1.2", - "@backstage/test-utils": "^0.1.2", + "@backstage/cli": "^0.4.1", + "@backstage/dev-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -45,5 +54,24 @@ }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/user-settings", + "type": "object", + "properties": { + "auth": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "visibility": "frontend" + } + } + } + } + } + } } diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index a1dab1e7d4..415d1eaad6 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -49,7 +49,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => ( )} {configuredProviders.includes('github') && ( ( )} {configuredProviders.includes('gitlab') && ( { .toString() .split('\n') .filter(function (el) { - return el != ''; + return el !== ''; }); }; diff --git a/scripts/isolated-release.js b/scripts/isolated-release.js new file mode 100755 index 0000000000..03c4e632c7 --- /dev/null +++ b/scripts/isolated-release.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const path = require('path'); +const childProcess = require('child_process'); +// eslint-disable-next-line import/no-extraneous-dependencies +const LernaProject = require('@lerna/project'); + +// Prepare a release of the provided packages, e.g. @backstage/core +async function main(args) { + if (args.includes('--help') || args.length === 0) { + const arg0 = path.relative(process.cwd(), process.argv[1]); + console.log(`Usage: ${process.argv0} ${arg0} ...`); + process.exit(1); + } + + const project = new LernaProject(__dirname); + const packages = await project.getPackages(); + const ignoreArgs = packages + .filter(p => !args.includes(p.name)) + .flatMap(p => ['--ignore', p.name]); + + const { status } = childProcess.spawnSync( + 'yarn', + ['changeset', 'version', ...ignoreArgs], + { + stdio: 'inherit', + }, + ); + if (status !== 0) { + return; + } + + childProcess.spawnSync( + 'yarn', + ['prettier', '--write', '{packages,plugins}/*/{package.json,CHANGELOG.md}'], + { + stdio: 'inherit', + }, + ); +} + +main(process.argv.slice(2)).catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/verify-links.js b/scripts/verify-links.js index 0422352d4e..75e7a8c1d0 100755 --- a/scripts/verify-links.js +++ b/scripts/verify-links.js @@ -74,6 +74,14 @@ async function verifyUrl(basePath, absUrl, docPages) { path = resolvePath(dirname(resolvePath(projectRoot, basePath)), url); } + if ( + absUrl === url && + basePath.match(/^(?:docs)\//) && + !path.startsWith(resolvePath(projectRoot, 'docs')) + ) { + return { url, basePath, problem: 'out-of-docs' }; + } + const exists = await fs.pathExists(path); if (!exists) { return { url, basePath, problem: 'missing' }; @@ -148,6 +156,18 @@ async function main() { console.error( `Unable to reach ${url} from root or microsite/static/, linked from ${basePath}`, ); + } else if (problem === 'out-of-docs') { + console.error( + 'Links in docs must use absolute URLs for targets outside of docs', + ); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + console.error( + ` Likely replace with: https://github.com/backstage/backstage/blob/master/${url.replace( + /^[./]+/, + '', + )}`, + ); } else if (problem === 'doc-missing') { const suggestion = docPages.get(url) || diff --git a/yarn.lock b/yarn.lock index 5ee42e7dd9..272f85a7a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,6 +87,24 @@ resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.5.tgz#3a516d198fc41a1103695bd889fdd4fbbebe7f5d" integrity sha512-T1Ham9sqZKCtSowXRPaBCRy2oz3KHglqqrKiaO7lEudpP6lwH5SwXaq4qliyKzWaqd22srJHE4szdsorbFZKlw== +"@azure/msal-common@^1.6.2": + version "1.6.2" + resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-1.6.2.tgz#91f3732866d727e20f1e142e6e88a981268fbff2" + integrity sha512-GShzp1q7Ld8SwYiDEjQZ9PmFOY4x+2stE86maiguylE9/d/c2muqKjc8aepmEqyjbV7o/omDvEf2Sr9QcIqkSA== + dependencies: + debug "^4.1.1" + +"@azure/msal-node@^1.0.0-alpha.8": + version "1.0.0-alpha.12" + resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-alpha.12.tgz#09d8d52f5cea90b133c3d48fe4ec477693040c91" + integrity sha512-uGLOJRWiEhfJIrTv/lwdm4RxQFm++00h83zNgDn0O3NkXlzAoCCq9QFYW84PjMR/Q2PUvVy7uW+6yKL/Nq3gBA== + dependencies: + "@azure/msal-common" "^1.6.2" + axios "^0.19.2" + debug "^4.1.1" + jsonwebtoken "^8.5.1" + uuid "^8.3.0" + "@babel/code-frame@7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -108,28 +126,24 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" - integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== - dependencies: - browserslist "^4.12.0" - invariant "^2.2.4" - semver "^5.5.0" +"@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== -"@babel/core@^7.0.0", "@babel/core@^7.9.0": - version "7.11.1" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643" - integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ== +"@babel/core@^7.0.0", "@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.4.4", "@babel/core@^7.7.5", "@babel/core@^7.9.0": + version "7.12.9" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" + integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.0" - "@babel/helper-module-transforms" "^7.11.0" - "@babel/helpers" "^7.10.4" - "@babel/parser" "^7.11.1" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.11.0" - "@babel/types" "^7.11.0" + "@babel/generator" "^7.12.5" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helpers" "^7.12.5" + "@babel/parser" "^7.12.7" + "@babel/template" "^7.12.7" + "@babel/traverse" "^7.12.9" + "@babel/types" "^7.12.7" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" @@ -139,28 +153,6 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.1.0", "@babel/core@^7.4.4", "@babel/core@^7.7.5": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.9.6.tgz#d9aa1f580abf3b2286ef40b6904d390904c63376" - integrity sha512-nD3deLvbsApbHAHttzIssYqgb883yU/d9roe4RZymBCDaZryMJDbptVpEpeQuRh4BJ+SYI8le9YGxKvFEvl1Wg== - dependencies: - "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.9.6" - "@babel/helper-module-transforms" "^7.9.0" - "@babel/helpers" "^7.9.6" - "@babel/parser" "^7.9.6" - "@babel/template" "^7.8.6" - "@babel/traverse" "^7.9.6" - "@babel/types" "^7.9.6" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.1" - json5 "^2.1.2" - lodash "^4.17.13" - resolve "^1.3.2" - semver "^5.4.1" - source-map "^0.5.0" - "@babel/generator@^7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.10.5.tgz#1b903554bc8c583ee8d25f1e8969732e6b829a69" @@ -188,6 +180,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" + integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== + dependencies: + "@babel/types" "^7.12.5" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" @@ -212,6 +213,15 @@ "@babel/helper-module-imports" "^7.10.4" "@babel/types" "^7.10.5" +"@babel/helper-builder-react-jsx-experimental@^7.12.4": + version "7.12.4" + resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz#55fc1ead5242caa0ca2875dcb8eed6d311e50f48" + integrity sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-module-imports" "^7.12.1" + "@babel/types" "^7.12.1" + "@babel/helper-builder-react-jsx@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" @@ -220,15 +230,14 @@ "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-compilation-targets@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" - integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ== +"@babel/helper-compilation-targets@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: - "@babel/compat-data" "^7.10.4" - browserslist "^4.12.0" - invariant "^2.2.4" - levenary "^1.1.1" + "@babel/compat-data" "^7.12.5" + "@babel/helper-validator-option" "^7.12.1" + browserslist "^4.14.5" semver "^5.5.0" "@babel/helper-create-class-features-plugin@^7.10.4", "@babel/helper-create-class-features-plugin@^7.10.5": @@ -243,6 +252,17 @@ "@babel/helper-replace-supers" "^7.10.4" "@babel/helper-split-export-declaration" "^7.10.4" +"@babel/helper-create-class-features-plugin@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" + integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.12.1" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.10.4" + "@babel/helper-create-regexp-features-plugin@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" @@ -252,6 +272,14 @@ "@babel/helper-regex" "^7.10.4" regexpu-core "^4.7.0" +"@babel/helper-create-regexp-features-plugin@^7.12.1": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" + integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + regexpu-core "^4.7.1" + "@babel/helper-define-map@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" @@ -299,6 +327,13 @@ dependencies: "@babel/types" "^7.10.5" +"@babel/helper-member-expression-to-functions@^7.12.1": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== + dependencies: + "@babel/types" "^7.12.7" + "@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" @@ -306,7 +341,14 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0", "@babel/helper-module-transforms@^7.9.0": +"@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" + integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== + dependencies: + "@babel/types" "^7.12.5" + +"@babel/helper-module-transforms@^7.10.4": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== @@ -319,6 +361,21 @@ "@babel/types" "^7.11.0" lodash "^4.17.19" +"@babel/helper-module-transforms@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" + integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== + dependencies: + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-simple-access" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/helper-validator-identifier" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.12.1" + "@babel/types" "^7.12.1" + lodash "^4.17.19" + "@babel/helper-optimise-call-expression@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" @@ -338,16 +395,14 @@ dependencies: lodash "^4.17.19" -"@babel/helper-remap-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5" - integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg== +"@babel/helper-remap-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" + integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-wrap-function" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.10.4": version "7.10.4" @@ -359,6 +414,16 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/helper-replace-supers@^7.12.1": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.5.tgz#f009a17543bbbbce16b06206ae73b63d3fca68d9" + integrity sha512-5YILoed0ZyIpF4gKcpZitEnXEJ9UoDRki1Ey6xz46rxOzfNMAhVIJMoune1hmPVxh40LRv1+oafz7UsWX+vyWA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.12.1" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" + "@babel/helper-simple-access@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" @@ -367,6 +432,13 @@ "@babel/template" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/helper-simple-access@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== + dependencies: + "@babel/types" "^7.12.1" + "@babel/helper-skip-transparent-expression-wrappers@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" @@ -374,6 +446,13 @@ dependencies: "@babel/types" "^7.11.0" +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== + dependencies: + "@babel/types" "^7.12.1" + "@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.8.3": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" @@ -386,6 +465,11 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-option@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" + integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== + "@babel/helper-wrap-function@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" @@ -396,14 +480,14 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helpers@^7.10.4", "@babel/helpers@^7.9.6": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" - integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== +"@babel/helpers@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" "@babel/highlight@^7.0.0", "@babel/highlight@^7.10.4", "@babel/highlight@^7.8.3": version "7.10.4" @@ -419,7 +503,7 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== -"@babel/parser@^7.0.0", "@babel/parser@^7.11.0", "@babel/parser@^7.11.1": +"@babel/parser@^7.0.0", "@babel/parser@^7.11.0": version "7.11.3" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== @@ -434,16 +518,21 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.10.5.tgz#e7c6bf5a7deff957cec9f04b551e2762909d826b" integrity sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ== -"@babel/plugin-proposal-async-generator-functions@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" - integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg== +"@babel/parser@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" + integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg== + +"@babel/plugin-proposal-async-generator-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" + integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" -"@babel/plugin-proposal-class-properties@^7.0.0", "@babel/plugin-proposal-class-properties@^7.10.4", "@babel/plugin-proposal-class-properties@^7.8.3": +"@babel/plugin-proposal-class-properties@^7.0.0", "@babel/plugin-proposal-class-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== @@ -451,6 +540,14 @@ "@babel/helper-create-class-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-proposal-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" + integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-proposal-decorators@^7.8.3": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.10.5.tgz#42898bba478bc4b1ae242a703a953a7ad350ffb4" @@ -460,10 +557,10 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-decorators" "^7.10.4" -"@babel/plugin-proposal-dynamic-import@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e" - integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ== +"@babel/plugin-proposal-dynamic-import@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" + integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-dynamic-import" "^7.8.0" @@ -476,31 +573,31 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-export-default-from" "^7.10.4" -"@babel/plugin-proposal-export-namespace-from@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" - integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg== +"@babel/plugin-proposal-export-namespace-from@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" + integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db" - integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw== +"@babel/plugin-proposal-json-strings@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" + integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-json-strings" "^7.8.0" -"@babel/plugin-proposal-logical-assignment-operators@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8" - integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q== +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" + integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": +"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.1": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== @@ -508,15 +605,23 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" -"@babel/plugin-proposal-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06" - integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA== +"@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" + integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-numeric-separator@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" + integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.6": +"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.9.6": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== @@ -525,15 +630,24 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-transform-parameters" "^7.10.4" -"@babel/plugin-proposal-optional-catch-binding@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" - integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g== +"@babel/plugin-proposal-object-rest-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" + integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-transform-parameters" "^7.12.1" + +"@babel/plugin-proposal-optional-catch-binding@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" + integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.10.1", "@babel/plugin-proposal-optional-chaining@^7.11.0": +"@babel/plugin-proposal-optional-chaining@^7.10.1": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== @@ -542,7 +656,24 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-private-methods@^7.10.4", "@babel/plugin-proposal-private-methods@^7.8.3": +"@babel/plugin-proposal-optional-chaining@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-proposal-private-methods@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" + integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-private-methods@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== @@ -550,7 +681,15 @@ "@babel/helper-create-class-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": +"@babel/plugin-proposal-unicode-property-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" + integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== @@ -572,13 +711,20 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3": +"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-syntax-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-decorators@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.10.4.tgz#6853085b2c429f9d322d02f5a635018cdeb2360c" @@ -642,6 +788,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-syntax-jsx@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" + integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -691,10 +844,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d" - integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ== +"@babel/plugin-syntax-top-level-await@^7.12.1", "@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" + integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -705,29 +858,43 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.10.4", "@babel/plugin-transform-arrow-functions@^7.8.3": +"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37" - integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ== +"@babel/plugin-transform-arrow-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" + integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== dependencies: - "@babel/helper-module-imports" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" -"@babel/plugin-transform-block-scoped-functions@^7.0.0", "@babel/plugin-transform-block-scoped-functions@^7.10.4": +"@babel/plugin-transform-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" + integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== + dependencies: + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" + +"@babel/plugin-transform-block-scoped-functions@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8" integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-transform-block-scoped-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" + integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-transform-block-scoping@^7.0.0", "@babel/plugin-transform-block-scoping@^7.8.3": version "7.11.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" @@ -735,14 +902,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.5.tgz#b81b8aafefbfe68f0f65f7ef397b9ece68a6037d" - integrity sha512-6Ycw3hjpQti0qssQcA6AMSFDHeNJ++R6dIMnpRqUjFeBBTmTDPa8zgF90OVfTvAo11mXZTlVUViY1g8ffrURLg== +"@babel/plugin-transform-block-scoping@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" + integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.10.4", "@babel/plugin-transform-classes@^7.9.5": +"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.9.5": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== @@ -756,21 +923,57 @@ "@babel/helper-split-export-declaration" "^7.10.4" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.0.0", "@babel/plugin-transform-computed-properties@^7.10.4": +"@babel/plugin-transform-classes@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" + integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-define-map" "^7.10.4" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.10.4" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb" integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.10.4", "@babel/plugin-transform-destructuring@^7.9.5": +"@babel/plugin-transform-computed-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" + integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.9.5": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4": +"@babel/plugin-transform-destructuring@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" + integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-dotall-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" + integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-dotall-regex@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== @@ -778,17 +981,17 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-duplicate-keys@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" - integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA== +"@babel/plugin-transform-duplicate-keys@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" + integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-exponentiation-operator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e" - integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw== +"@babel/plugin-transform-exponentiation-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" + integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== dependencies: "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" @@ -809,14 +1012,21 @@ "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-syntax-flow" "^7.8.3" -"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.10.4", "@babel/plugin-transform-for-of@^7.9.0": +"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.9.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-function-name@^7.0.0", "@babel/plugin-transform-function-name@^7.10.4": +"@babel/plugin-transform-for-of@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" + integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-function-name@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7" integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg== @@ -824,30 +1034,52 @@ "@babel/helper-function-name" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-literals@^7.0.0", "@babel/plugin-transform-literals@^7.10.4": +"@babel/plugin-transform-function-name@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" + integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-literals@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c" integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-member-expression-literals@^7.0.0", "@babel/plugin-transform-member-expression-literals@^7.10.4": +"@babel/plugin-transform-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" + integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-member-expression-literals@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7" integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-modules-amd@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1" - integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw== +"@babel/plugin-transform-member-expression-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" + integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== dependencies: - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-modules-amd@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" + integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== + dependencies: + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.10.4", "@babel/plugin-transform-modules-commonjs@^7.4.4": +"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== @@ -857,39 +1089,50 @@ "@babel/helper-simple-access" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" - integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw== +"@babel/plugin-transform-modules-commonjs@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" + integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== dependencies: - "@babel/helper-hoist-variables" "^7.10.4" - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-simple-access" "^7.12.1" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e" - integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA== +"@babel/plugin-transform-modules-systemjs@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" + integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== dependencies: - "@babel/helper-module-transforms" "^7.10.4" + "@babel/helper-hoist-variables" "^7.10.4" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-identifier" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-umd@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" + integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== + dependencies: + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6" - integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA== +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" + integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" -"@babel/plugin-transform-new-target@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888" - integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw== +"@babel/plugin-transform-new-target@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" + integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-object-super@^7.0.0", "@babel/plugin-transform-object-super@^7.10.4": +"@babel/plugin-transform-object-super@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894" integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ== @@ -897,6 +1140,14 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-replace-supers" "^7.10.4" +"@babel/plugin-transform-object-super@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" + integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.10.4", "@babel/plugin-transform-parameters@^7.9.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" @@ -905,53 +1156,72 @@ "@babel/helper-get-function-arity" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-property-literals@^7.0.0", "@babel/plugin-transform-property-literals@^7.10.4": +"@babel/plugin-transform-parameters@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" + integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-property-literals@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0" integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-constant-elements@^7.7.4", "@babel/plugin-transform-react-constant-elements@^7.9.0": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.10.4.tgz#0f485260bf1c29012bb973e7e404749eaac12c9e" - integrity sha512-cYmQBW1pXrqBte1raMkAulXmi7rjg3VI6ZLg9QIic8Hq7BtYXaWuZSxsr2siOMI6SWwpxjWfnwhTUrd7JlAV7g== +"@babel/plugin-transform-property-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" + integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-display-name@^7.0.0", "@babel/plugin-transform-react-display-name@^7.10.4": +"@babel/plugin-transform-react-constant-elements@^7.12.1", "@babel/plugin-transform-react-constant-elements@^7.9.0": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.12.1.tgz#4471f0851feec3231cc9aaa0dccde39947c1ac1e" + integrity sha512-KOHd0tIRLoER+J+8f9DblZDa1fLGPwaaN1DI1TVHuQFOpjHV22C3CUB3obeC4fexHY9nx+fH0hQNvLFFfA1mxA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-react-display-name@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.10.4.tgz#b5795f4e3e3140419c3611b7a2a3832b9aef328d" integrity sha512-Zd4X54Mu9SBfPGnEcaGcOrVAYOtjT2on8QZkLKEq1S/tHexG39d9XXGZv19VfRrDjPJzFmPfTAqOQS1pfFOujw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-jsx-development@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.10.4.tgz#6ec90f244394604623880e15ebc3c34c356258ba" - integrity sha512-RM3ZAd1sU1iQ7rI2dhrZRZGv0aqzNQMbkIUCS1txYpi9wHQ2ZHNjo5TwX+UD6pvFW4AbWqLVYvKy5qJSAyRGjQ== - dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" - -"@babel/plugin-transform-react-jsx-self@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369" - integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg== +"@babel/plugin-transform-react-display-name@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" + integrity sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-jsx-source@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.10.5.tgz#34f1779117520a779c054f2cdd9680435b9222b4" - integrity sha512-wTeqHVkN1lfPLubRiZH3o73f4rfon42HpgxUSs86Nc+8QIcm/B9s8NNVXu/gwGcOyd7yDib9ikxoDLxJP0UiDA== +"@babel/plugin-transform-react-jsx-development@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.7.tgz#4c2a647de79c7e2b16bfe4540677ba3121e82a08" + integrity sha512-Rs3ETtMtR3VLXFeYRChle5SsP/P9Jp/6dsewBQfokDSzKJThlsuFcnzLTDRALiUmTC48ej19YD9uN1mupEeEDg== + dependencies: + "@babel/helper-builder-react-jsx-experimental" "^7.12.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.12.1" + +"@babel/plugin-transform-react-jsx-self@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz#ef43cbca2a14f1bd17807dbe4376ff89d714cf28" + integrity sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-jsx@^7.0.0", "@babel/plugin-transform-react-jsx@^7.10.4": +"@babel/plugin-transform-react-jsx-source@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz#d07de6863f468da0809edcf79a1aa8ce2a82a26b" + integrity sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-react-jsx@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz#673c9f913948764a4421683b2bef2936968fddf2" integrity sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A== @@ -961,36 +1231,53 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-pure-annotations@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.10.4.tgz#3eefbb73db94afbc075f097523e445354a1c6501" - integrity sha512-+njZkqcOuS8RaPakrnR9KvxjoG1ASJWpoIv/doyWngId88JoFlPlISenGXjrVacZUIALGUr6eodRs1vmPnF23A== +"@babel/plugin-transform-react-jsx@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.7.tgz#8b14d45f6eccd41b7f924bcb65c021e9f0a06f7f" + integrity sha512-YFlTi6MEsclFAPIDNZYiCRbneg1MFGao9pPG9uD5htwE0vDbPaMUMeYd6itWjw7K4kro4UbdQf3ljmFl9y48dQ== + dependencies: + "@babel/helper-builder-react-jsx" "^7.10.4" + "@babel/helper-builder-react-jsx-experimental" "^7.12.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.12.1" + +"@babel/plugin-transform-react-pure-annotations@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" + integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-regenerator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63" - integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw== +"@babel/plugin-transform-regenerator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" + integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== dependencies: regenerator-transform "^0.14.2" -"@babel/plugin-transform-reserved-words@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd" - integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ== +"@babel/plugin-transform-reserved-words@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" + integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.10.4", "@babel/plugin-transform-shorthand-properties@^7.8.3": +"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.11.0", "@babel/plugin-transform-spread@^7.8.3": +"@babel/plugin-transform-shorthand-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" + integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.8.3": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== @@ -998,15 +1285,22 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" -"@babel/plugin-transform-sticky-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" - integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ== +"@babel/plugin-transform-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" + integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-regex" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" -"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3": +"@babel/plugin-transform-sticky-regex@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" + integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.8.3": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== @@ -1014,10 +1308,17 @@ "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-typeof-symbol@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc" - integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA== +"@babel/plugin-transform-template-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" + integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-typeof-symbol@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" + integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -1030,45 +1331,46 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-typescript" "^7.10.4" -"@babel/plugin-transform-unicode-escapes@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" - integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg== +"@babel/plugin-transform-unicode-escapes@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" + integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-unicode-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8" - integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A== +"@babel/plugin-transform-unicode-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" + integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/preset-env@^7.9.5", "@babel/preset-env@^7.9.6": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" - integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== +"@babel/preset-env@^7.12.1", "@babel/preset-env@^7.9.5", "@babel/preset-env@^7.9.6": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.7.tgz#54ea21dbe92caf6f10cb1a0a576adc4ebf094b55" + integrity sha512-OnNdfAr1FUQg7ksb7bmbKoby4qFOHw6DKWWUNB9KqnnCldxhxJlP+21dpyaWFmf2h0rTbOkXJtAGevY3XW1eew== dependencies: - "@babel/compat-data" "^7.11.0" - "@babel/helper-compilation-targets" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" + "@babel/compat-data" "^7.12.7" + "@babel/helper-compilation-targets" "^7.12.5" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-proposal-async-generator-functions" "^7.10.4" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-dynamic-import" "^7.10.4" - "@babel/plugin-proposal-export-namespace-from" "^7.10.4" - "@babel/plugin-proposal-json-strings" "^7.10.4" - "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" - "@babel/plugin-proposal-numeric-separator" "^7.10.4" - "@babel/plugin-proposal-object-rest-spread" "^7.11.0" - "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" - "@babel/plugin-proposal-optional-chaining" "^7.11.0" - "@babel/plugin-proposal-private-methods" "^7.10.4" - "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" + "@babel/helper-validator-option" "^7.12.1" + "@babel/plugin-proposal-async-generator-functions" "^7.12.1" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-dynamic-import" "^7.12.1" + "@babel/plugin-proposal-export-namespace-from" "^7.12.1" + "@babel/plugin-proposal-json-strings" "^7.12.1" + "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-numeric-separator" "^7.12.7" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.7" + "@babel/plugin-proposal-private-methods" "^7.12.1" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" - "@babel/plugin-syntax-class-properties" "^7.10.4" + "@babel/plugin-syntax-class-properties" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.0" @@ -1078,45 +1380,42 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" - "@babel/plugin-syntax-top-level-await" "^7.10.4" - "@babel/plugin-transform-arrow-functions" "^7.10.4" - "@babel/plugin-transform-async-to-generator" "^7.10.4" - "@babel/plugin-transform-block-scoped-functions" "^7.10.4" - "@babel/plugin-transform-block-scoping" "^7.10.4" - "@babel/plugin-transform-classes" "^7.10.4" - "@babel/plugin-transform-computed-properties" "^7.10.4" - "@babel/plugin-transform-destructuring" "^7.10.4" - "@babel/plugin-transform-dotall-regex" "^7.10.4" - "@babel/plugin-transform-duplicate-keys" "^7.10.4" - "@babel/plugin-transform-exponentiation-operator" "^7.10.4" - "@babel/plugin-transform-for-of" "^7.10.4" - "@babel/plugin-transform-function-name" "^7.10.4" - "@babel/plugin-transform-literals" "^7.10.4" - "@babel/plugin-transform-member-expression-literals" "^7.10.4" - "@babel/plugin-transform-modules-amd" "^7.10.4" - "@babel/plugin-transform-modules-commonjs" "^7.10.4" - "@babel/plugin-transform-modules-systemjs" "^7.10.4" - "@babel/plugin-transform-modules-umd" "^7.10.4" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" - "@babel/plugin-transform-new-target" "^7.10.4" - "@babel/plugin-transform-object-super" "^7.10.4" - "@babel/plugin-transform-parameters" "^7.10.4" - "@babel/plugin-transform-property-literals" "^7.10.4" - "@babel/plugin-transform-regenerator" "^7.10.4" - "@babel/plugin-transform-reserved-words" "^7.10.4" - "@babel/plugin-transform-shorthand-properties" "^7.10.4" - "@babel/plugin-transform-spread" "^7.11.0" - "@babel/plugin-transform-sticky-regex" "^7.10.4" - "@babel/plugin-transform-template-literals" "^7.10.4" - "@babel/plugin-transform-typeof-symbol" "^7.10.4" - "@babel/plugin-transform-unicode-escapes" "^7.10.4" - "@babel/plugin-transform-unicode-regex" "^7.10.4" + "@babel/plugin-syntax-top-level-await" "^7.12.1" + "@babel/plugin-transform-arrow-functions" "^7.12.1" + "@babel/plugin-transform-async-to-generator" "^7.12.1" + "@babel/plugin-transform-block-scoped-functions" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.1" + "@babel/plugin-transform-classes" "^7.12.1" + "@babel/plugin-transform-computed-properties" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-dotall-regex" "^7.12.1" + "@babel/plugin-transform-duplicate-keys" "^7.12.1" + "@babel/plugin-transform-exponentiation-operator" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-function-name" "^7.12.1" + "@babel/plugin-transform-literals" "^7.12.1" + "@babel/plugin-transform-member-expression-literals" "^7.12.1" + "@babel/plugin-transform-modules-amd" "^7.12.1" + "@babel/plugin-transform-modules-commonjs" "^7.12.1" + "@babel/plugin-transform-modules-systemjs" "^7.12.1" + "@babel/plugin-transform-modules-umd" "^7.12.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" + "@babel/plugin-transform-new-target" "^7.12.1" + "@babel/plugin-transform-object-super" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-property-literals" "^7.12.1" + "@babel/plugin-transform-regenerator" "^7.12.1" + "@babel/plugin-transform-reserved-words" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@babel/plugin-transform-spread" "^7.12.1" + "@babel/plugin-transform-sticky-regex" "^7.12.7" + "@babel/plugin-transform-template-literals" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.1" + "@babel/plugin-transform-unicode-escapes" "^7.12.1" + "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.11.5" - browserslist "^4.12.0" - core-js-compat "^3.6.2" - invariant "^2.2.2" - levenary "^1.1.1" + "@babel/types" "^7.12.7" + core-js-compat "^3.7.0" semver "^5.5.0" "@babel/preset-flow@^7.0.0": @@ -1138,18 +1437,18 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.0.0", "@babel/preset-react@^7.8.3", "@babel/preset-react@^7.9.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" - integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== +"@babel/preset-react@^7.0.0", "@babel/preset-react@^7.12.5", "@babel/preset-react@^7.8.3", "@babel/preset-react@^7.9.4": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.7.tgz#36d61d83223b07b6ac4ec55cf016abb0f70be83b" + integrity sha512-wKeTdnGUP5AEYCYQIMeXMMwU7j+2opxrG0WzuZfxuuW9nhKvvALBjl67653CWamZJVefuJGI219G591RSldrqQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-react-display-name" "^7.10.4" - "@babel/plugin-transform-react-jsx" "^7.10.4" - "@babel/plugin-transform-react-jsx-development" "^7.10.4" - "@babel/plugin-transform-react-jsx-self" "^7.10.4" - "@babel/plugin-transform-react-jsx-source" "^7.10.4" - "@babel/plugin-transform-react-pure-annotations" "^7.10.4" + "@babel/plugin-transform-react-display-name" "^7.12.1" + "@babel/plugin-transform-react-jsx" "^7.12.7" + "@babel/plugin-transform-react-jsx-development" "^7.12.7" + "@babel/plugin-transform-react-jsx-self" "^7.12.1" + "@babel/plugin-transform-react-jsx-source" "^7.12.1" + "@babel/plugin-transform-react-pure-annotations" "^7.12.1" "@babel/preset-typescript@^7.9.0": version "7.10.4" @@ -1170,7 +1469,7 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime-corejs2@^7.10.4", "@babel/runtime-corejs2@^7.8.7": +"@babel/runtime-corejs2@^7.8.7": version "7.11.2" resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.11.2.tgz#700a03945ebad0d31ba6690fc8a6bcc9040faa47" integrity sha512-AC/ciV28adSSpEkBglONBWq4/Lvm6GAZuxIoyVtsnUpZMl0bxLtoChEnYAkP+47KyOCayZanojtflUEUJtR/6Q== @@ -1178,18 +1477,18 @@ core-js "^2.6.5" regenerator-runtime "^0.13.4" -"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.8.3": - version "7.10.3" - resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" - integrity sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw== +"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.11.2": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" + integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== dependencies: core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.11.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" @@ -1202,7 +1501,16 @@ "@babel/parser" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/template@^7.3.3", "@babel/template@^7.8.6": +"@babel/template@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" + +"@babel/template@^7.3.3": version "7.8.6" resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg== @@ -1226,7 +1534,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.0.0", "@babel/traverse@^7.11.0": +"@babel/traverse@^7.0.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24" integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg== @@ -1241,7 +1549,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.1.0", "@babel/traverse@^7.9.6": +"@babel/traverse@^7.1.0": version "7.9.6" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz#5540d7577697bf619cc57b92aa0f1c231a94f442" integrity sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg== @@ -1271,6 +1579,21 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": + version "7.12.9" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" + integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.12.5" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + "@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.5", "@babel/types@^7.9.6": version "7.11.5" resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" @@ -1289,15 +1612,84 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" + integrity sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@backstage/catalog-model@^0.2.0": + version "0.5.0" + dependencies: + "@backstage/config" "^0.1.2" + "@types/json-schema" "^7.0.5" + "@types/yup" "^0.29.8" + json-schema "^0.2.5" + lodash "^4.17.15" + uuid "^8.0.0" + yup "^0.29.3" + +"@backstage/catalog-model@^0.3.0": + version "0.5.0" + dependencies: + "@backstage/config" "^0.1.2" + "@types/json-schema" "^7.0.5" + "@types/yup" "^0.29.8" + json-schema "^0.2.5" + lodash "^4.17.15" + uuid "^8.0.0" + yup "^0.29.3" + +"@backstage/core@^0.3.0": + version "0.4.0" + dependencies: + "@backstage/config" "^0.1.2" + "@backstage/core-api" "^0.2.5" + "@backstage/theme" "^0.2.2" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@types/dagre" "^0.7.44" + "@types/prop-types" "^15.7.3" + "@types/react" "^16.9" + "@types/react-sparklines" "^1.7.0" + classnames "^2.2.6" + clsx "^1.1.0" + d3-selection "^2.0.0" + d3-shape "^2.0.0" + d3-zoom "^2.0.0" + dagre "^0.8.5" + immer "^7.0.9" + lodash "^4.17.15" + material-table "^1.69.1" + prop-types "^15.7.2" + qs "^6.9.4" + rc-progress "^3.0.0" + react "^16.12.0" + react-dom "^16.12.0" + react-helmet "6.1.0" + react-hook-form "^6.6.0" + react-markdown "^5.0.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-sparklines "^1.7.0" + react-syntax-highlighter "^13.5.1" + react-use "^15.3.3" + remark-gfm "^1.0.0" + zen-observable "^0.8.15" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@braintree/sanitize-url@^4.0.0": - version "4.1.1" - resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-4.1.1.tgz#671b3cfdbcc40d1449036ce586e882ab6150828e" - integrity sha512-epVksusKVEpwBs2vRg3SWssxn9KXs9CxEYNOcgeSRLRjq070ABj5bLPxkmtQpVeSPCHj8kfAE9J6z2WsLr4wZg== +"@braintree/sanitize-url@^5.0.0": + version "5.0.0" + resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-5.0.0.tgz#3ba791f37b90e7f6170d252b63aacfcae943c039" + integrity sha512-WmKrB/575EJCzbeSJR3YQ5sET5FaizeljLRw1382qVUeGqzuWBgIS+AF5a0FO51uQTrDpoRgvuHC2IWVsgwkkA== "@changesets/apply-release-plan@^4.0.0": version "4.0.0" @@ -1365,19 +1757,7 @@ term-size "^2.1.0" tty-table "^2.7.0" -"@changesets/config@^1.2.0": - version "1.3.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.3.0.tgz#82fcbf572b00ba16636be9ea45167983f1fc203b" - integrity sha512-IeAHmN5kI7OywBUNJXsk/v4vcXDDscwgTe/K5D3FSng5QTvzbgiMAe5K1iwBxBvuT4u/33n89kxSJdg4TTTFfA== - dependencies: - "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/logger" "^0.0.5" - "@changesets/types" "^3.1.0" - "@manypkg/get-packages" "^1.0.1" - fs-extra "^7.0.1" - -"@changesets/config@^1.4.0": +"@changesets/config@^1.2.0", "@changesets/config@^1.4.0": version "1.4.0" resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== @@ -1763,11 +2143,11 @@ yaml-ast-parser "0.0.43" "@gitbeaker/core@^25.2.0": - version "25.2.0" - resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-25.2.0.tgz#c2f46b65ed88aebfa69afd2e58dbf4ebe2efb2c7" - integrity sha512-dhCvZItI8FIzHtJ9EySQ43GmNg3j39MxDGMCpDHn+Qb1o54QS+J6XPVQrMRmJioZIyj+WZQPX/IP+/mRkx8vhg== + version "25.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-25.6.0.tgz#97d5ccc5d61bab6b678bec280036d594d275931e" + integrity sha512-+CohJNsbZiPl7jPgw7PHt5t0JIIV9NngObOskY1Ww8jef7SqaKpz0NsbSDawuWFBdmXApMpK81AEfASKtVI+cw== dependencies: - "@gitbeaker/requester-utils" "^25.2.0" + "@gitbeaker/requester-utils" "^25.6.0" form-data "^3.0.0" li "^1.3.0" xcase "^2.0.1" @@ -1782,15 +2162,74 @@ got "^11.7.0" xcase "^2.0.1" -"@gitbeaker/requester-utils@^25.2.0": - version "25.2.0" - resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-25.2.0.tgz#f71c44e0073617877f9875dd00c4ae0d74897b09" - integrity sha512-pjuFIVlbxSTPdN+zFT/LBP4ym8k0OBYwUpc5WkzoOtvdTGuDX05r8ufnV07kibLDJkwDmjwnH4Hsc66yevSQTw== +"@gitbeaker/requester-utils@^25.2.0", "@gitbeaker/requester-utils@^25.6.0": + version "25.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-25.6.0.tgz#001a432a48460bb5196a02ed71763eb707a1a01e" + integrity sha512-jD8cHbAZPR6+cB3HiukQxcqIKF5VkHtqg2m+Ns6ROE1pb0oRn10D/a9J1lZOXC9Jz2tQOBMWfHlplbmFFdB6Og== dependencies: form-data "^3.0.0" query-string "^6.13.3" xcase "^2.0.1" +"@google-cloud/common@^3.5.0": + version "3.5.0" + resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.5.0.tgz#0959e769e8075a06eb0823cc567eef00fd0c2d02" + integrity sha512-10d7ZAvKhq47L271AqvHEd8KzJqGU45TY+rwM2Z3JHuB070FeTi7oJJd7elfrnKaEvaktw3hH2wKnRWxk/3oWQ== + dependencies: + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^2.0.0" + arrify "^2.0.1" + duplexify "^4.1.1" + ent "^2.2.0" + extend "^3.0.2" + google-auth-library "^6.1.1" + retry-request "^4.1.1" + teeny-request "^7.0.0" + +"@google-cloud/paginator@^3.0.0": + version "3.0.5" + resolved "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-3.0.5.tgz#9d6b96c421a89bd560c1bc2c197c7611ef21db6c" + integrity sha512-N4Uk4BT1YuskfRhKXBs0n9Lg2YTROZc6IMpkO/8DIHODtm5s3xY8K5vVBo23v/2XulY3azwITQlYWgT4GdLsUw== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^2.0.0": + version "2.0.1" + resolved "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-2.0.1.tgz#13350ee609346435c795bbfe133a08dfeab78d65" + integrity sha512-ZDG38U/Yy6Zr21LaR3BTiiLtpJl6RkPS/JwoRT453G+6Q1DhlV0waNf8Lfu+YVYGIIxgKnLayJRfYlFJfiI8iQ== + +"@google-cloud/promisify@^2.0.0": + version "2.0.3" + resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.3.tgz#f934b5cdc939e3c7039ff62b9caaf59a9d89e3a8" + integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw== + +"@google-cloud/storage@^5.6.0": + version "5.6.0" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.6.0.tgz#bc6925c7970c375212a4da21c123298fc9665dec" + integrity sha512-nLcym8IuCzy1O7tNTXNFuMHfX900sTM3kSTqbKe7oFSoKUiaIM+FHuuuDimMMlieY6StA1xYNPRFFHz57Nv8YQ== + dependencies: + "@google-cloud/common" "^3.5.0" + "@google-cloud/paginator" "^3.0.0" + "@google-cloud/promisify" "^2.0.0" + arrify "^2.0.0" + compressible "^2.0.12" + date-and-time "^0.14.0" + duplexify "^4.0.0" + extend "^3.0.2" + gaxios "^4.0.0" + gcs-resumable-upload "^3.1.0" + get-stream "^6.0.0" + hash-stream-validation "^0.2.2" + mime "^2.2.0" + mime-types "^2.0.8" + onetime "^5.1.0" + p-limit "^3.0.1" + pumpify "^2.0.0" + snakeize "^0.1.0" + stream-events "^1.0.1" + xdg-basedir "^4.0.0" + "@graphql-codegen/cli@^1.17.7": version "1.17.10" resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.17.10.tgz#efebf9b887fdb94dd26dbf3eb1950e832efcda0e" @@ -1851,10 +2290,10 @@ "@graphql-tools/utils" "^6.0.18" tslib "~2.0.0" -"@graphql-codegen/plugin-helpers@^1.17.8", "@graphql-codegen/plugin-helpers@^1.17.9": - version "1.17.9" - resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.17.9.tgz#429f7f75b9f09f5229e42546027613f62ef0d4b1" - integrity sha512-kyj+qsnLGd1JLqXuLpvI6Q/VW7frhoHHNxYJWerpwsSV9m4cttmFj8d9JTfYPZbg6cLIRR7+10lVugzMKozjzA== +"@graphql-codegen/plugin-helpers@^1.17.8", "@graphql-codegen/plugin-helpers@^1.17.9", "@graphql-codegen/plugin-helpers@^1.18.2": + version "1.18.2" + resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.2.tgz#57011076cb8b8f5d04d37d226a5eda300c01be94" + integrity sha512-SvX+Ryq2naLcoD6jJNxtzc/moWTgHJ+X0KRfvhGWTa+xtFTS02i+PWOR89YYPcD8+LF6GmyFBjx2FmLCx4JwMg== dependencies: "@graphql-tools/utils" "^6" camel-case "4.1.1" @@ -1869,41 +2308,42 @@ upper-case "2.0.1" "@graphql-codegen/typescript-resolvers@^1.17.7": - version "1.17.8" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.17.8.tgz#c10af67e54c00520aa33be0403c9e19981d820b5" - integrity sha512-0uMipQjqdRWCW/4I9ZmuZmPuR9jMqKqKph+G/9iAVkfRz3XEa8v1ho+pZ7NhJLWIeiW9NC7B/BKB1UUhKecysg== + version "1.17.12" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.17.12.tgz#1f8f608d68b46780351c8224a61750b9b9307497" + integrity sha512-qICKxl06R1yCBh03cdyiOYlHTebQ1n/FOiQPCxo4bsiF6HfbrgpMS8fdGEZ8v+VBPxQFZZ7HQ2KcuPNsuw5R6g== dependencies: - "@graphql-codegen/plugin-helpers" "^1.17.8" - "@graphql-codegen/typescript" "^1.17.8" - "@graphql-codegen/visitor-plugin-common" "^1.17.13" - "@graphql-tools/utils" "^6.0.18" + "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-codegen/typescript" "^1.18.1" + "@graphql-codegen/visitor-plugin-common" "^1.17.20" + "@graphql-tools/utils" "^6" auto-bind "~4.0.0" - tslib "~2.0.0" + tslib "~2.0.1" -"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.17.8": - version "1.17.8" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.17.8.tgz#08477e532d8342c8a74f75f46f97c85831418e23" - integrity sha512-OQCD4CbnKfHG63u1Sh+UsSKdzkoRHI8NmqnV0Z5ECO4Mm3mrm6fGoYZkKmHbA2tgWIFEsQLevDW+CfduyMd9BA== +"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.18.1": + version "1.18.1" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.18.1.tgz#3d730472a01f18aea6331046f4ebfe3d91326801" + integrity sha512-Ee37NutKmaNrgAo2d5mv42RqPd8jJ6zyUKAH669Gbv0dFn2EK3sdC9PYQC9gXptv+H/eQn2gYgaa2nVpEPAIzg== dependencies: - "@graphql-codegen/plugin-helpers" "^1.17.8" - "@graphql-codegen/visitor-plugin-common" "^1.17.13" + "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-codegen/visitor-plugin-common" "^1.17.20" auto-bind "~4.0.0" - tslib "~2.0.0" + tslib "~2.0.1" -"@graphql-codegen/visitor-plugin-common@^1.17.13": - version "1.17.13" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.17.13.tgz#88dfd8b266aab140a36018807449a1162612e393" - integrity sha512-RWL5VcUQh1mcNb+nr7pDnfoMZ9cn2MurDcZMYGTblIXox+FT33y9Cg4erRjzkVuTvxSf7xXc2TSrSkt9MeZRCw== +"@graphql-codegen/visitor-plugin-common@^1.17.20": + version "1.17.20" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.17.20.tgz#cff95cdd49cef270b3811fdb141a412ffe2bdfd7" + integrity sha512-buIpcNNyTqVubknancX8m9jARCZsUA5eKuskg+CylWKL/8CSaD2Tiq7CfbbNO10o7XIgRrPtJMl1c9hQ6N4ytw== dependencies: - "@graphql-codegen/plugin-helpers" "^1.17.8" - "@graphql-tools/relay-operation-optimizer" "^6.0.18" - array.prototype.flatmap "^1.2.3" + "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-tools/optimize" "^1.0.1" + "@graphql-tools/relay-operation-optimizer" "^6" + array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" dependency-graph "^0.9.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" pascal-case "^3.1.1" - tslib "~2.0.0" + tslib "~2.0.1" "@graphql-modules/core@^0.7.17": version "0.7.17" @@ -2079,6 +2519,13 @@ "@graphql-tools/utils" "^6.2.4" tslib "~2.0.1" +"@graphql-tools/optimize@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-1.0.1.tgz#9933fffc5a3c63f95102b1cb6076fb16ac7bb22d" + integrity sha512-cRlUNsbErYoBtzzS6zXahXeTBZGPVlPHXCpnEZ0XiK/KY/sQL96cyzak0fM/Gk6qEI9/l32MYEICjasiBQrl5w== + dependencies: + tslib "~2.0.1" + "@graphql-tools/prisma-loader@^6": version "6.2.4" resolved "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-6.2.4.tgz#3f902b9f1d36ae0c4731e1fe963178bea300af49" @@ -2109,13 +2556,14 @@ tslib "~2.0.1" yaml-ast-parser "^0.0.43" -"@graphql-tools/relay-operation-optimizer@^6.0.18": - version "6.0.18" - resolved "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.0.18.tgz#0089d4f222d323aae6d5b266daa5f2bc3689a14e" - integrity sha512-X11M/njvdeGUvER+oJCCFMmnoPPq1Y+W6AXWX8WKeaM4LxdMeYY9mAWfhUyNOX4XZlxUA6zNNH+QwBAyUyZvRA== +"@graphql-tools/relay-operation-optimizer@^6": + version "6.3.0" + resolved "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.3.0.tgz#f8c7f6c8aa4a9cf50ab151fbc5db4f4282a79532" + integrity sha512-Or3UgRvkY9Fq1AAx7q38oPqFmTepLz7kp6wDHKyR0ceG7AvHv5En22R12mAeISInbhff4Rpwgf6cE8zHRu6bCw== dependencies: - "@graphql-tools/utils" "6.0.18" - relay-compiler "10.0.1" + "@graphql-tools/utils" "^7.1.0" + relay-compiler "10.1.0" + tslib "~2.0.1" "@graphql-tools/schema@6.0.15": version "6.0.15" @@ -2181,6 +2629,15 @@ camel-case "4.1.1" tslib "~2.0.1" +"@graphql-tools/utils@^7.1.0": + version "7.1.0" + resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.1.0.tgz#0cbcfa7b6e7741650f78e8bde4823780ed9e8c57" + integrity sha512-lMTgy08BdqQ31zyyCRrxcKZ6gAKQB9amGKJGg0mb3b4I3iJQKeaw9a7T96yGe1frGak1UK9y7OoYqx8RG7KitA== + dependencies: + "@ardatan/aggregate-error" "0.0.6" + camel-case "4.1.1" + tslib "~2.0.1" + "@graphql-tools/wrap@^6.2.4": version "6.2.4" resolved "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-6.2.4.tgz#2709817da6e469753735a9fe038c9e99736b2c57" @@ -2262,46 +2719,46 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jest/console@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/console/-/console-26.5.2.tgz#94fc4865b1abed7c352b5e21e6c57be4b95604a6" - integrity sha512-lJELzKINpF1v74DXHbCRIkQ/+nUV1M+ntj+X1J8LxCgpmJZjfLmhFejiMSbjjD66fayxl5Z06tbs3HMyuik6rw== +"@jest/console@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" + integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^26.5.2" - jest-util "^26.5.2" + jest-message-util "^26.6.2" + jest-util "^26.6.2" slash "^3.0.0" -"@jest/core@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/core/-/core-26.5.3.tgz#712ed4adb64c3bda256a3f400ff1d3eb2a031f13" - integrity sha512-CiU0UKFF1V7KzYTVEtFbFmGLdb2g4aTtY0WlyUfLgj/RtoTnJFhh50xKKr7OYkdmBUlGFSa2mD1TU3UZ6OLd4g== +"@jest/core@^26.6.3": + version "26.6.3" + resolved "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" + integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== dependencies: - "@jest/console" "^26.5.2" - "@jest/reporters" "^26.5.3" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/reporters" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" - jest-changed-files "^26.5.2" - jest-config "^26.5.3" - jest-haste-map "^26.5.2" - jest-message-util "^26.5.2" + jest-changed-files "^26.6.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-resolve-dependencies "^26.5.3" - jest-runner "^26.5.3" - jest-runtime "^26.5.3" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" - jest-watcher "^26.5.2" + jest-resolve "^26.6.2" + jest-resolve-dependencies "^26.6.3" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + jest-watcher "^26.6.2" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" @@ -2313,47 +2770,47 @@ 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" - integrity sha512-YjhCD/Zhkz0/1vdlS/QN6QmuUdDkpgBdK4SdiVg4Y19e29g4VQYN5Xg8+YuHjdoWGY7wJHMxc79uDTeTOy9Ngw== +"@jest/environment@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" + integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== dependencies: - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" + jest-mock "^26.6.2" -"@jest/fake-timers@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.5.2.tgz#1291ac81680ceb0dc7daa1f92c059307eea6400a" - integrity sha512-09Hn5Oraqt36V1akxQeWMVL0fR9c6PnEhpgLaYvREXZJAh2H2Y+QLCsl0g7uMoJeoWJAuz4tozk1prbR1Fc1sw== +"@jest/fake-timers@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" + integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@sinonjs/fake-timers" "^6.0.1" "@types/node" "*" - jest-message-util "^26.5.2" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" -"@jest/globals@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.5.3.tgz#90769b40e0af3fa0b28f6d8c5bbe3712467243fd" - integrity sha512-7QztI0JC2CuB+Wx1VdnOUNeIGm8+PIaqngYsZXQCkH2QV0GFqzAYc9BZfU0nuqA6cbYrWh5wkuMzyii3P7deug== +"@jest/globals@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" + integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== dependencies: - "@jest/environment" "^26.5.2" - "@jest/types" "^26.5.2" - expect "^26.5.3" + "@jest/environment" "^26.6.2" + "@jest/types" "^26.6.2" + expect "^26.6.2" -"@jest/reporters@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.5.3.tgz#e810e9c2b670f33f1c09e9975749260ca12f1c17" - integrity sha512-X+vR0CpfMQzYcYmMFKNY9n4jklcb14Kffffp7+H/MqitWnb0440bW2L76NGWKAa+bnXhNoZr+lCVtdtPmfJVOQ== +"@jest/reporters@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" + integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^26.5.2" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" @@ -2364,80 +2821,70 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" - jest-haste-map "^26.5.2" - jest-resolve "^26.5.2" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-haste-map "^26.6.2" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" - v8-to-istanbul "^6.0.1" + v8-to-istanbul "^7.0.0" optionalDependencies: node-notifier "^8.0.0" -"@jest/source-map@^26.5.0": - version "26.5.0" - resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.5.0.tgz#98792457c85bdd902365cd2847b58fff05d96367" - integrity sha512-jWAw9ZwYHJMe9eZq/WrsHlwF8E3hM9gynlcDpOyCb9bR8wEd9ZNBZCi7/jZyzHxC7t3thZ10gO2IDhu0bPKS5g== +"@jest/source-map@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" + integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.5.2.tgz#cc1a44cfd4db2ecee3fb0bc4e9fe087aa54b5230" - integrity sha512-E/Zp6LURJEGSCWpoMGmCFuuEI1OWuI3hmZwmULV0GsgJBh7u0rwqioxhRU95euUuviqBDN8ruX/vP/4bwYolXw== +"@jest/test-result@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" + integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== dependencies: - "@jest/console" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/types" "^26.6.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.5.3.tgz#9ae0ab9bc37d5171b28424029192e50229814f8d" - integrity sha512-Wqzb7aQ13L3T47xHdpUqYMOpiqz6Dx2QDDghp5AV/eUDXR7JieY+E1s233TQlNyl+PqtqgjVokmyjzX/HA51BA== +"@jest/test-sequencer@^26.6.3": + version "26.6.3" + resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" + integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== dependencies: - "@jest/test-result" "^26.5.2" + "@jest/test-result" "^26.6.2" graceful-fs "^4.2.4" - jest-haste-map "^26.5.2" - jest-runner "^26.5.3" - jest-runtime "^26.5.3" + jest-haste-map "^26.6.2" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" -"@jest/transform@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.5.2.tgz#6a0033a1d24316a1c75184d010d864f2c681bef5" - integrity sha512-AUNjvexh+APhhmS8S+KboPz+D3pCxPvEAGduffaAJYxIFxGi/ytZQkrqcKDUU0ERBAo5R7087fyOYr2oms1seg== +"@jest/transform@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" + integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" - jest-haste-map "^26.5.2" + jest-haste-map "^26.6.2" jest-regex-util "^26.0.0" - jest-util "^26.5.2" + jest-util "^26.6.2" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" -"@jest/types@^25.5.0": - version "25.5.0" - resolved "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" - integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^1.1.1" - "@types/yargs" "^15.0.0" - chalk "^3.0.0" - -"@jest/types@^26.5.2", "@jest/types@^26.6.1": +"@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== @@ -2448,6 +2895,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + 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" @@ -3336,6 +3794,11 @@ resolved "https://registry.npmjs.org/@mdx-js/react/-/react-1.5.9.tgz#31873ab097fbe58c61c7585fc0be64e83182b6df" integrity sha512-rengdUSedIdIQbXPSeafItCacTYocARAjUA51b6R1KNHmz+59efz7UmyTKr73viJQZ98ouu7iRGmOTtjRrbbWA== +"@microsoft/microsoft-graph-types@^1.25.0": + version "1.25.0" + resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-1.25.0.tgz#1f543ebc029a115dd1d48a1ae99d7ddd5ee9af57" + integrity sha512-RsuA+ROaU3voWzG9TVBkRKxmLatteRGduFDi5p0k3FUHho49rm9SvrA7DUyYbSXLy2xXRx9AnjKM9klYBeKEiQ== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -3463,6 +3926,14 @@ "@octokit/types" "^5.4.1" deprecation "^2.3.1" +"@octokit/plugin-rest-endpoint-methods@4.2.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.2.0.tgz#c5a0691b3aba5d8b4ef5dffd6af3649608f167ba" + integrity sha512-1/qn1q1C1hGz6W/iEDm9DoyNoG/xdFDt78E3eZ5hHeUfJTLJgyAMdj9chL/cNBHjcjd+FH5aO1x0VCqR2RE0mw== + dependencies: + "@octokit/types" "^5.5.0" + deprecation "^2.3.1" + "@octokit/request-error@^1.0.2": version "1.2.1" resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801" @@ -3527,6 +3998,16 @@ "@octokit/plugin-request-log" "^1.0.0" "@octokit/plugin-rest-endpoint-methods" "4.1.4" +"@octokit/rest@^18.0.6": + version "18.0.6" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.6.tgz#76c274f1a68f40741a131768ef483f041e7b98b6" + integrity sha512-ES4lZBKPJMX/yUoQjAZiyFjei9pJ4lTTfb9k7OtYoUzKPDLl/M8jiHqt6qeSauyU4eZGLw0sgP1WiQl9FYeM5w== + dependencies: + "@octokit/core" "^3.0.0" + "@octokit/plugin-paginate-rest" "^2.2.0" + "@octokit/plugin-request-log" "^1.0.0" + "@octokit/plugin-rest-endpoint-methods" "4.2.0" + "@octokit/types@^2.0.0", "@octokit/types@^2.0.1": version "2.5.0" resolved "https://registry.npmjs.org/@octokit/types/-/types-2.5.0.tgz#f1bbd147e662ae2c79717d518aac686e58257773" @@ -3548,6 +4029,13 @@ dependencies: "@types/node" ">= 8" +"@octokit/types@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b" + integrity sha512-UZ1pErDue6bZNjYOotCNveTXArOMZQFG6hKJfOnGnulVCMcVVi7YIIuuR4WfBhjo7zgpmzn/BkPDnUXtNx+PcQ== + dependencies: + "@types/node" ">= 8" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -3651,14 +4139,33 @@ resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.4.0.tgz#1b5859298bf3f61137d7b05084f058a775d6fd73" integrity sha512-U8F/suzg4MuV+8mK1/ufs0Y6c3O8hc1wnuD2IKoOVJvegGfz5JCafyoyGAW6iyuT1DZBMPzVWEqfiuYPmoE7pw== -"@roadiehq/backstage-plugin-github-insights@^0.2.12": - version "0.2.12" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.12.tgz#aeda6306769f376cf6b5b9d74268b060a0f4e764" - integrity sha512-f9g5ajVWQkywoYo0zDUZV3g0SPxOTi3MvMuhUvgaZ/XKJr9mBZTe5qHnEYBSHOCB88l0Y2g0qHNdbjXhbFpokQ== +"@roadiehq/backstage-plugin-buildkite@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-buildkite/-/backstage-plugin-buildkite-0.1.3.tgz#5a116bf677dfad22088212dde398cf3e9b48d6e4" + integrity sha512-q+cnAvZmjLu0DcqRKJZJhWTVcKaA9j7cM44tx6JcshEyb3UgzrlECLvx4ethnYyrmhM1/FSpMLa+t3N5A1Xz4g== dependencies: "@backstage/catalog-model" "^0.2.0" - "@backstage/core" "^0.2.0" - "@backstage/theme" "^0.2.0" + "@backstage/core" "^0.3.0" + "@backstage/plugin-catalog" "^0.2.1" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-lazylog "^4.5.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + +"@roadiehq/backstage-plugin-github-insights@^0.2.16": + version "0.2.16" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.16.tgz#bf7a391cf1a4024a511af90e740fabecb55eaea4" + integrity sha512-9P5L2JZ6Pd1asbIFFnA6ZSmaTRO7wfue22pFRrpPdp/NF++8LEdHFtxUZLH4/jjUtCHHRmD7VPjT9TRaZ9Fb6w== + dependencies: + "@backstage/catalog-model" "^0.3.0" + "@backstage/core" "^0.3.0" + "@backstage/theme" "^0.2.1" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" @@ -3667,18 +4174,16 @@ history "^5.0.0" react "^16.13.1" react-dom "^16.13.1" - react-markdown "^5.0.0" react-router "^6.0.0-beta.0" react-use "^15.3.3" - remark-gfm "^1.0.0" -"@roadiehq/backstage-plugin-github-pull-requests@^0.6.2": - version "0.6.2" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.6.2.tgz#02f4a7a03e1a7dc24342ec695130017ce7d3ad8d" - integrity sha512-OdBWO6NdiBKlol1WlbFQGeHxV2C8wl8EiI2NgA15lozsqFA22kWis6Z16cAHe4ZdRCjihDmqINtAIJzeZZ1gOg== +"@roadiehq/backstage-plugin-github-pull-requests@^0.6.3": + version "0.6.3" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.6.3.tgz#46b63e90f3f5412a4b8f0df96ada62cff7046478" + integrity sha512-ofWH9k4WVVwTbK/XAVqmtH03QW3rsT822p4neOse0Wy0dAKlqlSU4nwl5jBKMJXsf8lfc0c7gbc50R7VumzoOQ== dependencies: "@backstage/catalog-model" "^0.2.0" - "@backstage/core" "^0.2.0" + "@backstage/core" "^0.3.0" "@backstage/plugin-catalog" "^0.2.0" "@backstage/theme" "^0.2.0" "@material-ui/core" "^4.11.0" @@ -3694,16 +4199,16 @@ react-router "6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-travis-ci@^0.2.7": - version "0.2.7" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.7.tgz#bc7968b461016b2710794d10766266de9ab4f759" - integrity sha512-uXF5t2uZqd9TNGFSMYlyk6NAweVb5KLlM4GMiltattzxRWiqbCp5VumubrLTTsA8dvsWCrXWfhe95MStcUnl+A== +"@roadiehq/backstage-plugin-travis-ci@^0.2.8": + version "0.2.8" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.8.tgz#c730531519bf3e3dba35522d5e6c822176ddb9c8" + integrity sha512-8R8waHoviT5chHGybAMVVRHHmeytn+8Po7sU08WCYIt4Vkw2/gwu36fd90y+lW4x6T0s3ZiaJrsQ3KlA7LmRnQ== dependencies: "@backstage/catalog-model" "^0.2.0" - "@backstage/core" "^0.2.0" - "@backstage/core-api" "^0.2.0" - "@backstage/plugin-catalog" "^0.2.0" - "@backstage/theme" "^0.2.0" + "@backstage/core" "^0.3.0" + "@backstage/core-api" "^0.2.1" + "@backstage/plugin-catalog" "^0.2.1" + "@backstage/theme" "^0.2.1" "@material-ui/core" "^4.9.1" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" @@ -3767,6 +4272,14 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@rollup/pluginutils@^4.1.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.1.0.tgz#0dcc61c780e39257554feb7f77207dceca13c838" + integrity sha512-TrBhfJkFxA+ER+ew2U2/fHbebhLT/l/2pRk0hfj9KusXUuRXd2v0R58AfaZK9VXDQ4TogOSEmICVrQAA3zFnHQ== + dependencies: + estree-walker "^2.0.1" + picomatch "^2.2.2" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -4570,6 +5083,11 @@ resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.4.0.tgz#a2212b4d018e6075a058bb7e220a66959ef7a03c" integrity sha512-zLl4Fl3NvKxxjWNkqEcpdSOpQ3LGVH2BNFQ6vjaK6sFo2IrSznrhURIPI0HAphKiiIwNYjAfE0TNoQDSZv0U9A== +"@svgr/babel-plugin-transform-svg-component@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz#583a5e2a193e214da2f3afeb0b9e8d3250126b4a" + integrity sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ== + "@svgr/babel-preset@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.4.0.tgz#da21854643e1c4ad2279239baa7d5a8b128c1f15" @@ -4584,14 +5102,28 @@ "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" "@svgr/babel-plugin-transform-svg-component" "^5.4.0" -"@svgr/core@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/core/-/core-5.4.0.tgz#655378ee43679eb94fee3d4e1976e38252dff8e7" - integrity sha512-hWGm1DCCvd4IEn7VgDUHYiC597lUYhFau2lwJBYpQWDirYLkX4OsXu9IslPgJ9UpP7wsw3n2Ffv9sW7SXJVfqQ== +"@svgr/babel-preset@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz#8af54f3e0a8add7b1e2b0fcd5a882c55393df327" + integrity sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig== dependencies: - "@svgr/plugin-jsx" "^5.4.0" - camelcase "^6.0.0" - cosmiconfig "^6.0.0" + "@svgr/babel-plugin-add-jsx-attribute" "^5.4.0" + "@svgr/babel-plugin-remove-jsx-attribute" "^5.4.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "^5.0.1" + "@svgr/babel-plugin-replace-jsx-attribute-value" "^5.0.1" + "@svgr/babel-plugin-svg-dynamic-title" "^5.4.0" + "@svgr/babel-plugin-svg-em-dimensions" "^5.4.0" + "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" + "@svgr/babel-plugin-transform-svg-component" "^5.5.0" + +"@svgr/core@^5.4.0", "@svgr/core@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz#82e826b8715d71083120fe8f2492ec7d7874a579" + integrity sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ== + dependencies: + "@svgr/plugin-jsx" "^5.5.0" + camelcase "^6.2.0" + cosmiconfig "^7.0.0" "@svgr/hast-util-to-babel-ast@^5.4.0": version "5.4.0" @@ -4600,7 +5132,14 @@ dependencies: "@babel/types" "^7.9.5" -"@svgr/plugin-jsx@5.4.x", "@svgr/plugin-jsx@^5.4.0": +"@svgr/hast-util-to-babel-ast@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz#5ee52a9c2533f73e63f8f22b779f93cd432a5461" + integrity sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ== + dependencies: + "@babel/types" "^7.12.6" + +"@svgr/plugin-jsx@5.4.x": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.4.0.tgz#ab47504c55615833c6db70fca2d7e489f509787c" integrity sha512-SGzO4JZQ2HvGRKDzRga9YFSqOqaNrgLlQVaGvpZ2Iht2gwRp/tq+18Pvv9kS9ZqOMYgyix2LLxZMY1LOe9NPqw== @@ -4610,7 +5149,17 @@ "@svgr/hast-util-to-babel-ast" "^5.4.0" svg-parser "^2.0.2" -"@svgr/plugin-svgo@5.4.x", "@svgr/plugin-svgo@^5.4.0": +"@svgr/plugin-jsx@^5.4.0", "@svgr/plugin-jsx@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz#1aa8cd798a1db7173ac043466d7b52236b369000" + integrity sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA== + dependencies: + "@babel/core" "^7.12.3" + "@svgr/babel-preset" "^5.5.0" + "@svgr/hast-util-to-babel-ast" "^5.5.0" + svg-parser "^2.0.2" + +"@svgr/plugin-svgo@5.4.x": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.4.0.tgz#45d9800b7099a6f7b4d85ebac89ab9abe8592f64" integrity sha512-3Cgv3aYi1l6SHyzArV9C36yo4kgwVdF3zPQUC6/aCDUeXAofDYwE5kk3e3oT5ZO2a0N3lB+lLGvipBG6lnG8EA== @@ -4619,18 +5168,27 @@ merge-deep "^3.0.2" svgo "^1.2.2" -"@svgr/rollup@5.4.x": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/rollup/-/rollup-5.4.0.tgz#b1946c8d2c13870397af014a105d40945b7371e5" - integrity sha512-hwYjrTddW6mFU9vwqRr1TULNvxiIxGdIbqrD5J7vtoATSfWazq/2JSnT4BmiH+/4kFXLEtjKuSKoDUotkOIAkg== +"@svgr/plugin-svgo@^5.4.0", "@svgr/plugin-svgo@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz#02da55d85320549324e201c7b2e53bf431fcc246" + integrity sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ== dependencies: - "@babel/core" "^7.7.5" - "@babel/plugin-transform-react-constant-elements" "^7.7.4" - "@babel/preset-env" "^7.9.5" - "@babel/preset-react" "^7.9.4" - "@svgr/core" "^5.4.0" - "@svgr/plugin-jsx" "^5.4.0" - "@svgr/plugin-svgo" "^5.4.0" + cosmiconfig "^7.0.0" + deepmerge "^4.2.2" + svgo "^1.2.2" + +"@svgr/rollup@5.5.x": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/rollup/-/rollup-5.5.0.tgz#9ceaaa6d463916e69aff8a9e10b3bb8fbb94688a" + integrity sha512-EiZmH2VTr+Xzyb6Ga8XtGa9MEbiU3WQnB5vHmqhwAUqibU3uwuwr7MN+QwIh/gtBk1ucMim8BCfcRTlLVREM8A== + dependencies: + "@babel/core" "^7.12.3" + "@babel/plugin-transform-react-constant-elements" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.5" + "@svgr/core" "^5.5.0" + "@svgr/plugin-jsx" "^5.5.0" + "@svgr/plugin-svgo" "^5.5.0" rollup-pluginutils "^2.8.2" "@svgr/webpack@5.4.x", "@svgr/webpack@^5.4.0": @@ -4661,16 +5219,15 @@ dependencies: defer-to-connect "^2.0.0" -"@testing-library/cypress@^6.0.0": - version "6.0.1" - resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-6.0.1.tgz#c924a69617db6a403c498abcb63759b79318fc76" - integrity sha512-hcPu2OnVuSTX1yDubEe7TBRD6mP2VgyopP1WTBIrJP79INPZdgOAX+TMNH68uZ/r5b4C7100IB4hjPIEQ+/Xog== +"@testing-library/cypress@^7.0.1": + version "7.0.1" + resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-7.0.1.tgz#2843033acaefe96cb4cf789f16e98d957383e59d" + integrity sha512-LtggqG/7Hdc1EiKdmqXQwxWOO3ET1dkZtq0S8mIe8o+xaOtaVLrdCn0dE8Bi4Aj7z3w51w6wN9STdYymnUPlnQ== dependencies: "@babel/runtime" "^7.11.2" "@testing-library/dom" "^7.22.2" - "@types/testing-library__cypress" "^5.0.6" -"@testing-library/dom@^7.11.0", "@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": +"@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": version "7.23.0" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.23.0.tgz#c54c0fa53705ad867bcefb52fc0c96487fbc10f6" integrity sha512-H5m090auYH+obdZmsaYLrSWC5OauWD2CvNbz88KBxQJoXgkJzbU0DpAG8BS7Evj5WqCC3nAAKrLS6vw0ljUYLg== @@ -4798,6 +5355,13 @@ resolved "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== +"@types/archiver@^3.1.1": + version "3.1.1" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-3.1.1.tgz#10cc1be44af8911e57484342c7b3b32a5f178a1a" + integrity sha512-TzVZ9204sH1TuFylfr1cw/AA/3/VldAAXswEwKLXUOzA9mDg+m6gHF9EaqKNlozcjc6knX5m1KAqJzksPLSEfw== + dependencies: + "@types/glob" "*" + "@types/aria-query@^4.2.0": version "4.2.0" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" @@ -4872,9 +5436,9 @@ integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== "@types/classnames@^2.2.9": - version "2.2.10" - resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" - integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== + version "2.2.11" + resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" + integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== "@types/clean-css@*": version "4.2.1" @@ -4883,10 +5447,10 @@ dependencies: "@types/node" "*" -"@types/codemirror@^0.0.97": - version "0.0.97" - resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.97.tgz#6f2d8266b7f1b34aacfe8c77221fafe324c3d081" - integrity sha512-n5d7o9nWhC49DjfhsxANP7naWSeTzrjXASkUDQh7626sM4zK9XP2EVcHp1IcCf/IPV6c7ORzDUDF3Bkt231VKg== +"@types/codemirror@^0.0.100": + version "0.0.100" + resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.100.tgz#3ebdf1514208d73f38c25925d9cf9399a42a7471" + integrity sha512-4jGmu1T8vpQrJCe8cbe3KveiJmK2UAt3rZO2qE2sPoMhGLuwW0cMzFYJLyXebbRJg5G3RbuUXLip1IHPUESkFA== dependencies: "@types/tern" "*" @@ -5053,10 +5617,10 @@ resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ== -"@types/dockerode@^2.5.32", "@types/dockerode@^2.5.34": - version "2.5.34" - resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.34.tgz#9adb884f7cc6c012a6eb4b2ad794cc5d01439959" - integrity sha512-LcbLGcvcBwBAvjH9UrUI+4qotY+A5WCer5r43DR5XHv2ZIEByNXFdPLo1XxR+v/BjkGjlggW8qUiXuVEhqfkpA== +"@types/dockerode@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.1.tgz#f713a8f6f1017c227845ab33239383da721207b9" + integrity sha512-AeZpdQMNqM8dtrEaaP81CbjdRVKNmFIMzgz5IlKIeS5uWEmjlEmENP444AGTEEF71r5TFuY9E4SkzZAO8lOF1A== dependencies: "@types/node" "*" @@ -5092,6 +5656,13 @@ "@types/qs" "*" "@types/range-parser" "*" +"@types/express-session@^1.17.2": + version "1.17.2" + resolved "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.2.tgz#ed6a36dd9f267c7fef86004f653bfb9b5cea3c21" + integrity sha512-QRm/fUuvr/BAosL9CvK351SDQP7wpD8+h3S8ZEE/8IvHJ/ZqHrjZbjx/flYfazyPw7yNi9O5fbjFZbh0vZ1ccg== + dependencies: + "@types/express" "*" + "@types/express@*", "@types/express@4.17.7", "@types/express@^4.17.6", "@types/express@^4.17.7": version "4.17.7" resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" @@ -5123,6 +5694,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.5": + version "9.0.5" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.5.tgz#2afb76a43a4bef80a363b94b314d0ca1694fc4f8" + integrity sha512-wr3t7wIW1c0A2BIJtdVp4EflriVaVVAsCAIHVzzh8B+GiFv9X1xeJjCs4upRXtzp7kQ6lP5xvskjoD4awJ1ZeA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -5168,6 +5746,13 @@ "@types/koa" "*" graphql "^14.5.3" +"@types/hast@^2.0.0": + version "2.3.1" + resolved "https://registry.npmjs.org/@types/hast/-/hast-2.3.1.tgz#b16872f2a6144c7025f296fb9636a667ebb79cd9" + integrity sha512-viwwrB+6xGzw+G1eWpF9geV3fnsDgXqHG+cqgiHrvQfDUW5hzhCyV7Sy3UJxhfRFBsgky2SSW33qi/YrIkjX5Q== + dependencies: + "@types/unist" "*" + "@types/helmet@^0.0.48": version "0.0.48" resolved "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz#e754399d2f4672ba63962e8490efd3edd31d9799" @@ -5266,14 +5851,6 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a" - integrity sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA== - dependencies: - "@types/istanbul-lib-coverage" "*" - "@types/istanbul-lib-report" "*" - "@types/istanbul-reports@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821" @@ -5281,15 +5858,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@*", "@types/jest@^26.0.7": - version "26.0.14" - resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.14.tgz#078695f8f65cb55c5a98450d65083b2b73e5a3f3" - integrity sha512-Hz5q8Vu0D288x3iWXePSn53W7hAjP0H7EQ6QvDO9c7t46mR0lNOLlfuwQ+JkVxuhygHzlzPX+0jKdA3ZgSh+Vg== - dependencies: - jest-diff "^25.2.1" - pretty-format "^25.2.1" - -"@types/jest@26.x": +"@types/jest@*", "@types/jest@26.x", "@types/jest@^26.0.7": version "26.0.15" resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== @@ -5314,7 +5883,14 @@ resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb" integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww== -"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5": +"@types/json-schema-merge-allof@^0.6.0": + version "0.6.0" + resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#0f587d8a3bcb41a55ef2e91d3ba96430c9bc1813" + integrity sha512-v6iCEk4Sxy1twlCTtrRxMqSHX0vuLJ7Ql4hiIUZRMOswxtlUeybIfYaZIj7pX747RBczG2YtBm4Fn6sqKzeY2Q== + dependencies: + "@types/json-schema" "*" + +"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6": version "7.0.6" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== @@ -5336,10 +5912,12 @@ dependencies: "@types/node" "*" -"@types/jwt-decode@2.2.1": - version "2.2.1" - resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" - integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A== +"@types/jwt-decode@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-3.1.0.tgz#eb1b24f436962b8857beaf8addfd542de56670a2" + integrity sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w== + dependencies: + jwt-decode "*" "@types/keygrip@*": version "1.0.2" @@ -5381,9 +5959,9 @@ "@types/node" "*" "@types/lodash@^4.14.151": - version "4.14.161" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18" - integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA== + version "4.14.165" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" + integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== "@types/long@^4.0.0": version "4.0.1" @@ -5461,6 +6039,13 @@ dependencies: "@types/node" "*" +"@types/nock@^11.1.0": + version "11.1.0" + resolved "https://registry.npmjs.org/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538" + integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw== + dependencies: + nock "*" + "@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" @@ -5496,6 +6081,13 @@ dependencies: "@types/node" "*" +"@types/nodegit@^0.26.12": + version "0.26.12" + resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.12.tgz#93afb4cb85d3a48d392c3232699c9c07d8251a36" + integrity sha512-4YpeTImFZNJ1cve4lEueHFVS8rAs8XpZqlmx+Bm9bMc+XMiCrcwaUf6peN7pod7Rl3esVlGP1zdBB7Z12eMVAA== + dependencies: + "@types/node" "*" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -5513,6 +6105,13 @@ dependencies: "@types/node" "*" +"@types/openid-client@^3.7.0": + version "3.7.0" + resolved "https://registry.npmjs.org/@types/openid-client/-/openid-client-3.7.0.tgz#8406e12798d16083df09cc3625973f5a00dd57fa" + integrity sha512-hW+QbAeUyfUHABwUjUECOcv56Mf5tN29xK3GPN7wy3R3XfIQdkm37XELA4wbe2RNG9GYUpN8l2ytfoW05XmpgQ== + dependencies: + openid-client "*" + "@types/ora@^3.2.0": version "3.2.0" resolved "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz#b2f65d1283a8f36d8b0f9ee767e0732a2f429362" @@ -5579,12 +6178,17 @@ dependencies: "@types/express" "*" +"@types/pluralize@^0.0.29": + version "0.0.29" + resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c" + integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA== + "@types/prettier@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4" integrity sha512-/rM+sWiuOZ5dvuVzV37sUuklsbg+JPOP8d+nNFlo2ZtfpzPiPvh1/gc8liWOLBqe+sR+ZM7guPaIcTt6UZTo7Q== -"@types/prop-types@*": +"@types/prop-types@*", "@types/prop-types@^15.7.3": version "15.7.3" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== @@ -5884,18 +6488,10 @@ dependencies: "@types/estree" "*" -"@types/testing-library__cypress@^5.0.6": - version "5.0.6" - resolved "https://registry.npmjs.org/@types/testing-library__cypress/-/testing-library__cypress-5.0.6.tgz#9015f575c1a98f05996a4fe769071134ee488c26" - integrity sha512-TUp5wfanU7zUZigKqIeQDChnHQ1MEzbYqrI5iCQMFiesWNOASWm/el1lFBh1JPqmd6GkdDdDiHYJnkqd9le2ww== - dependencies: - "@testing-library/dom" "^7.11.0" - cypress "*" - "@types/testing-library__jest-dom@^5.9.1": - version "5.9.1" - resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5" - integrity sha512-yYn5EKHO3MPEMSOrcAb1dLWY+68CG29LiXKsWmmpVHqoP5+ZRiAVLyUHvPNrO2dABDdUGZvavMsaGpWNjM6N2g== + version "5.9.5" + resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0" + integrity sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ== dependencies: "@types/jest" "*" @@ -5942,6 +6538,13 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== +"@types/unzipper@^0.10.3": + version "0.10.3" + resolved "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.3.tgz#9eea872fb1fa460da76f253878b6275af588f464" + integrity sha512-01mQdTLp3/KuBVDhP82FNBf+enzVOjJ9dGsCWa5z8fcYAFVgA9bqIQ2NmsgNFzN/DhD0PUQj4n5p7k6I9mq80g== + dependencies: + "@types/node" "*" + "@types/uuid@^8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" @@ -6036,6 +6639,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/yarnpkg__lockfile@^1.1.4": + version "1.1.4" + resolved "https://registry.npmjs.org/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.4.tgz#445251eb00bd9c1e751f82c7c6bf4f714edfd464" + integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw== + "@types/yup@^0.29.8": version "0.29.8" resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" @@ -6332,6 +6940,11 @@ resolved "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + "@zkochan/cmd-shim@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz#2ab8ed81f5bb5452a85f25758eb9b8681982fd2e" @@ -6359,6 +6972,13 @@ abbrev@1: resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + abstract-logging@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.0.tgz#08a85814946c98ef06f4256ad470aba1886d4490" @@ -6853,6 +7473,35 @@ arch@^2.1.2: resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== +archiver-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" + integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== + dependencies: + glob "^7.1.4" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^2.0.0" + +archiver@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/archiver/-/archiver-5.0.2.tgz#b2c435823499b1f46eb07aa18e7bcb332f6ca3fc" + integrity sha512-Tq3yV/T4wxBsD2Wign8W9VQKhaUxzzRmjEiSoOK0SLqPgDP/N1TKdYyBeIEu56T4I9iO4fKTTR0mN9NWkBA0sg== + dependencies: + archiver-utils "^2.1.0" + async "^3.2.0" + buffer-crc32 "^0.2.1" + readable-stream "^3.6.0" + readdir-glob "^1.0.0" + tar-stream "^2.1.4" + zip-stream "^4.0.0" + are-we-there-yet@~1.1.2: version "1.1.5" resolved "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" @@ -6992,6 +7641,16 @@ array.prototype.flatmap@^1.2.1, array.prototype.flatmap@^1.2.3: es-abstract "^1.17.0-next.1" function-bind "^1.1.1" +array.prototype.flatmap@^1.2.4: + version "1.2.4" + resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.4.tgz#94cfd47cc1556ec0747d97f7c7738c58122004c9" + integrity sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + function-bind "^1.1.1" + array.prototype.map@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.2.tgz#9a4159f416458a23e9483078de1106b2ef68f8ec" @@ -7007,6 +7666,11 @@ arrify@^1.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= +arrify@^2.0.0, arrify@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + asap@^2.0.0, asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -7232,16 +7896,16 @@ babel-helper-to-multiple-sequence-expressions@^0.5.0: resolved "https://registry.npmjs.org/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d" integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA== -babel-jest@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.5.2.tgz#164f367a35946c6cf54eaccde8762dec50422250" - integrity sha512-U3KvymF3SczA3vOL/cgiUFOznfMET+XDIXiWnoJV45siAp2pLMG8i2+/MGZlAC3f/F6Q40LR4M4qDrWZ9wkK8A== +babel-jest@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" + integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== dependencies: - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^26.5.0" + babel-preset-jest "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" @@ -7296,10 +7960,10 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.5.0.tgz#3916b3a28129c29528de91e5784a44680db46385" - integrity sha512-ck17uZFD3CDfuwCLATWZxkkuGGFhMij8quP8CNhwj8ek1mqFgbFzRJ30xwC04LLscj/aKsVFfRST+b5PT7rSuw== +babel-plugin-jest-hoist@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" + integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" @@ -7474,10 +8138,10 @@ babel-plugin-transform-undefined-to-void@^6.9.4: resolved "https://registry.npmjs.org/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280" integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA= -babel-preset-current-node-syntax@^0.1.3: - version "0.1.3" - resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.3.tgz#b4b547acddbf963cba555ba9f9cbbb70bfd044da" - integrity sha512-uyexu1sVwcdFnyq9o8UQYsXwXflIh8LvrF5+cKrYam93ned1CStffB3+BEcsxGSgagoA3GEyjDqO4a/58hyPYQ== +babel-preset-current-node-syntax@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.0.tgz#cf5feef29551253471cfa82fc8e0f5063df07a77" + integrity sha512-mGkvkpocWJes1CmMKtgGUwCeeq0pOhALyymozzDWYomHTbDLwueDYG6p4TK1YOeYHCzBzYPsWkgTto10JubI1Q== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" @@ -7490,6 +8154,7 @@ babel-preset-current-node-syntax@^0.1.3: "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" babel-preset-fbjs@^3.3.0: version "3.3.0" @@ -7524,13 +8189,13 @@ babel-preset-fbjs@^3.3.0: "@babel/plugin-transform-template-literals" "^7.0.0" babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" -babel-preset-jest@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.5.0.tgz#f1b166045cd21437d1188d29f7fba470d5bdb0e7" - integrity sha512-F2vTluljhqkiGSJGBg/jOruA8vIIIL11YrxRcO7nviNTMbbofPSHwnm8mgP7d/wS7wRSexRoI6X1A6T74d4LQA== +babel-preset-jest@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" + integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== dependencies: - babel-plugin-jest-hoist "^26.5.0" - babel-preset-current-node-syntax "^0.1.3" + babel-plugin-jest-hoist "^26.6.2" + babel-preset-current-node-syntax "^1.0.0" "babel-preset-minify@^0.5.0 || 0.6.0-alpha.5": version "0.5.1" @@ -7601,10 +8266,10 @@ base64-arraybuffer@^0.2.0: resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45" integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ== -base64-js@^1.0.2, base64-js@^1.2.0: - version "1.3.1" - resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base64url@3.x.x, base64url@^3.0.1: version "3.0.1" @@ -7677,11 +8342,21 @@ bfj@^7.0.2: hoopy "^0.1.4" tryer "^1.0.1" +big-integer@^1.6.17: + version "1.6.48" + resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" + integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@^9.0.0: + version "9.0.1" + resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" + integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -7692,6 +8367,14 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== +binary@~0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" + integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk= + dependencies: + buffers "~0.1.1" + chainsaw "~0.1.0" + bindings@^1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -7699,11 +8382,6 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bintrees@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" - integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= - bl@^1.0.0: version "1.2.2" resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" @@ -7712,7 +8390,7 @@ bl@^1.0.0: readable-stream "^2.3.5" safe-buffer "^5.1.1" -bl@^4.0.1: +bl@^4.0.1, bl@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== @@ -7733,6 +8411,11 @@ bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5 resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bluebird@~3.4.1: + version "3.4.7" + resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" + integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM= + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" @@ -7920,7 +8603,7 @@ browserslist@4.10.0: node-releases "^1.1.52" pkg-up "^3.1.0" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.3: +browserslist@^4.0.0, browserslist@^4.12.0: version "4.13.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz#42556cba011e1b0a2775b611cba6a8eca18e940d" integrity sha512-MINatJ5ZNrLnQ6blGvePd/QOz9Xtu+Ne+x29iQSCHfkU5BugKVJwZKn/iiL8UbpIpa3JhviKjz+XxMo0m2caFQ== @@ -7930,6 +8613,17 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.3: escalade "^3.0.1" node-releases "^1.1.58" +browserslist@^4.14.5, browserslist@^4.15.0: + version "4.15.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.15.0.tgz#3d48bbca6a3f378e86102ffd017d9a03f122bdb0" + integrity sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ== + dependencies: + caniuse-lite "^1.0.30001164" + colorette "^1.2.1" + electron-to-chromium "^1.3.612" + escalade "^3.1.1" + node-releases "^1.1.67" + bs-logger@0.x: version "0.2.6" resolved "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" @@ -7967,7 +8661,7 @@ buffer-alloc@^1.2.0: buffer-alloc-unsafe "^1.1.0" buffer-fill "^1.0.0" -buffer-crc32@~0.2.3: +buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= @@ -7987,6 +8681,11 @@ buffer-from@1.x, buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +buffer-indexof-polyfill@~1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" + integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== + buffer-indexof@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" @@ -8016,7 +8715,15 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.5.0, buffer@^5.6.0: +buffer@^5.1.0: + version "5.7.1" + resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +buffer@^5.5.0: version "5.6.0" resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== @@ -8024,6 +8731,19 @@ buffer@^5.5.0, buffer@^5.6.0: base64-js "^1.0.2" ieee754 "^1.1.4" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +buffers@~0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" + integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s= + bufferutil@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.1.tgz#3a177e8e5819a1243fe16b63a199951a7ad8d4a7" @@ -8176,6 +8896,14 @@ cachedir@^2.3.0: resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== +call-bind@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce" + integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.0" + call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -8264,6 +8992,11 @@ camelcase@^6.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== +camelcase@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -8279,6 +9012,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001093, can resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065" integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA== +caniuse-lite@^1.0.30001164: + version "1.0.30001165" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f" + integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA== + canvas@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/canvas/-/canvas-2.6.1.tgz#0d087dd4d60f5a5a9efa202757270abea8bef89e" @@ -8317,6 +9055,13 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chainsaw@~0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" + integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg= + dependencies: + traverse ">=0.3.0 <0.4" + chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -8408,9 +9153,9 @@ chokidar@^2.1.8: fsevents "^1.2.7" chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2: - version "3.4.2" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" - integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + version "3.4.3" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" + integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== dependencies: anymatch "~3.1.1" braces "~3.0.2" @@ -8418,7 +9163,7 @@ chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3. is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.4.0" + readdirp "~3.5.0" optionalDependencies: fsevents "~2.1.2" @@ -8459,6 +9204,11 @@ circleci-api@^4.0.0: dependencies: axios "^0.20.0" +cjs-module-lexer@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" + integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== + class-utils@^0.3.5: version "0.3.6" resolved "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -8604,10 +9354,10 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" -cliui@^7.0.0: - version "7.0.1" - resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.1.tgz#a4cb67aad45cd83d8d05128fc9f4d8fbb887e6b3" - integrity sha512-rcvHOWyGyid6I1WjT/3NatKj2kDt9OdSHSXpyLXaMWFbKpGACNW8pRhhdPUq9MWUOdwn8Rz9AVETjF4105rZZQ== +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== dependencies: string-width "^4.2.0" strip-ansi "^6.0.0" @@ -8697,11 +9447,6 @@ codeowners-utils@^1.0.2: ignore "^5.1.4" locate-path "^5.0.0" -collapse-white-space@^1.0.2: - version "1.0.6" - resolved "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" - integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ== - collect-v8-coverage@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1" @@ -8864,7 +9609,17 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== -compressible@~2.0.16: +compress-commons@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.0.1.tgz#c5fa908a791a0c71329fba211d73cd2a32005ea8" + integrity sha512-xZm9o6iikekkI0GnXCmAl3LQGZj5TBDj0zLowsqi7tJtEa3FMGSEcHcqrSJIrOAk1UG/NBbDn/F1q+MG/p/EsA== + dependencies: + buffer-crc32 "^0.2.13" + crc32-stream "^4.0.0" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + +compressible@^2.0.12, compressible@~2.0.16: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== @@ -8958,7 +9713,7 @@ config-chain@^1.1.11: ini "^1.3.4" proto-list "~1.2.1" -configstore@^5.0.1: +configstore@^5.0.0, configstore@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== @@ -9163,12 +9918,12 @@ copy-to-clipboard@^3, copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.1.0, copy-t dependencies: toggle-selection "^1.0.6" -core-js-compat@^3.6.2: - version "3.6.4" - resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.4.tgz#938476569ebb6cda80d339bcf199fae4f16fff17" - integrity sha512-zAa3IZPvsJ0slViBQ2z+vgyyTuhd3MFn1rBQjZSKVEgB0UMYhUkCj9jJUVPgGTGqWvsBVmfnruXgTcNyTlEiSA== +core-js-compat@^3.7.0: + version "3.8.1" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.1.tgz#8d1ddd341d660ba6194cbe0ce60f4c794c87a36e" + integrity sha512-a16TLmy9NVD1rkjUGbwuyWkiDoN0FDpAwrfLONvHFQx0D9k7J9y0srwMT8QP/Z6HE3MIFaVynEeYwZwPX1o5RQ== dependencies: - browserslist "^4.8.3" + browserslist "^4.15.0" semver "7.0.0" core-js-pure@^3.0.0, core-js-pure@^3.0.1: @@ -9181,7 +9936,7 @@ core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core- resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== -core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5: +core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -9231,6 +9986,21 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +crc32-stream@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.0.tgz#05b7ca047d831e98c215538666f372b756d91893" + integrity sha512-tyMw2IeUX6t9jhgXI6um0eKfWq4EIDpfv5m7GX4Jzp7eVelQ360xd8EPXJhp2mHwLQIkqlnMLjzqSZI3a+0wRw== + dependencies: + crc "^3.4.4" + readable-stream "^3.4.0" + +crc@^3.4.4: + version "3.8.0" + resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" + integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== + dependencies: + buffer "^5.1.0" + create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -9262,6 +10032,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-react-class@^15.5.1: + version "15.7.0" + resolved "https://registry.npmjs.org/create-react-class/-/create-react-class-15.7.0.tgz#7499d7ca2e69bb51d13faf59bd04f0c65a1d6c1e" + integrity sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng== + dependencies: + loose-envify "^1.3.1" + object-assign "^4.1.1" + create-react-context@0.3.0, create-react-context@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" @@ -9277,7 +10055,7 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.6, cross-fetch@^3.0.5, cross-fetch@^3.0.6: +cross-fetch@3.0.6, cross-fetch@^3.0.4, cross-fetch@^3.0.5, cross-fetch@^3.0.6: version "3.0.6" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== @@ -9643,7 +10421,7 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@*, cypress@^4.2.0: +cypress@^4.2.0: version "4.12.1" resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== @@ -9900,6 +10678,11 @@ dataloader@2.0.0: resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ== +date-and-time@^0.14.0: + version "0.14.1" + resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.1.tgz#969634697b78956fb66b8be6fb0f39fbd631f2f6" + integrity sha512-M4RggEH5OF2ZuCOxgOU67R6Z9ohjKbxGvAQz48vj53wLmL0bAgumkBvycR32f30pK+Og9pIR+RFDyChbaE4oLA== + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" @@ -10267,16 +11050,16 @@ dicer@0.3.0: dependencies: streamsearch "0.1.2" -diff-sequences@^25.2.6: - version "25.2.6" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" - integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== - diff-sequences@^26.5.0: version "26.5.0" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== +diff-sequences@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" + integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== + diff@^4.0.1, diff@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -10343,7 +11126,7 @@ docker-modem@^2.1.0: split-ca "^1.0.1" ssh2 "^0.8.7" -dockerode@^3.2.0, dockerode@^3.2.1: +dockerode@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.2.1.tgz#4a2222e3e1df536bf595e78e76d3cfbf6d4d93b9" integrity sha512-XsSVB5Wu5HWMg1aelV5hFSqFJaKS5x1aiV/+sT7YOzOq1IRl49I/UwV8Pe4x6t0iF9kiGkWu5jwfvbkcFVupBw== @@ -10456,20 +11239,10 @@ domhandler@^3.0, domhandler@^3.0.0: dependencies: domelementtype "^2.0.1" -dompurify@^2.0.12: - version "2.1.1" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" - integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg== - -dompurify@^2.0.7: - version "2.0.12" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.0.12.tgz#284a2b041e1c60b8e72d7b4d2fadad36141254ae" - integrity sha512-Fl8KseK1imyhErHypFPA8qpq9gPzlsJ/EukA6yk9o0gX23p1TzC+rh9LqNg1qvErRTc0UNMYlKxEGSfSh43NDg== - -dompurify@^2.1.1: - version "2.2.0" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.0.tgz#51d34e76faa38b5d6b4e83a0678530f27fe3965c" - integrity sha512-bqFOQ7XRmmozp0VsKdIEe8UwZYxj0yttz7l80GBtBqdVRY48cOpXH2J/CVO7AEkV51qY0EBVXfilec18mdmQ/w== +dompurify@^2.0.12, dompurify@^2.1.1, dompurify@^2.2.3: + version "2.2.3" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.3.tgz#ec653ba521b39f397c2ca045769438d593ea8a9f" + integrity sha512-8Hv7Q0FuwD9rWoB6qI2eZsfKbGXfoUVuGHHrE15vgk4ReOKwOkSgbqb2OMFtc0d5besOEkoLkcyuV10zQ2X5gw== domutils@1.5.1: version "1.5.1" @@ -10554,6 +11327,13 @@ dotenv@^8.0.0, dotenv@^8.2.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +duplexer2@~0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" + integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= + dependencies: + readable-stream "^2.0.2" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -10574,6 +11354,16 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +duplexify@^4.0.0, duplexify@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz#7027dc374f157b122a8ae08c2d3ea4d2d953aa61" + integrity sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA== + dependencies: + end-of-stream "^1.4.1" + inherits "^2.0.3" + readable-stream "^3.1.1" + stream-shift "^1.0.0" + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -10582,7 +11372,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -10606,6 +11396,11 @@ electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.488: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.509.tgz#830fcb89cd66dc2984d18d794973b99e3f00584c" integrity sha512-cN4lkjNRuTG8rtAqTOVgwpecEC2kbKA04PG6YijcKGHK/kD0xLjiqExcAOmLUwtXZRF8cBeam2I0VZcih919Ug== +electron-to-chromium@^1.3.612: + version "1.3.620" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.620.tgz#c6f36a7e398acc9d7d12743a6f58d536fbc58700" + integrity sha512-YbgWXUR2Mu+Fp6rm3GZ5YJdNo8SgZKLUTNSl2PNvdOcM8OIz07jRJnRkIaV9vdszFv9UUuGChh19w9qSuoLJgw== + elegant-spinner@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" @@ -10726,6 +11521,11 @@ enquirer@^2.3.0, enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +ent@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" + integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= + entities@^1.1.1, entities@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -10794,6 +11594,41 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es- string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" +es-abstract@^1.17.5: + version "1.17.7" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" @@ -10844,11 +11679,6 @@ es6-iterator@^2.0.3, es6-iterator@~2.0.3: es5-ext "^0.10.35" es6-symbol "^3.1.1" -es6-object-assign@^1.0.3: - version "1.1.0" - resolved "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" - integrity sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw= - es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" @@ -10884,20 +11714,20 @@ es6-weak-map@^2.0.2: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -esbuild@^0.7.7: - version "0.7.7" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.7.7.tgz#fd86332d1c0a047231bd6da930666028c40c14bf" - integrity sha512-1Ub4BBsWwPdxkwjyuXdKrgMIZROZ82ULIRFclOzXXVrqKOv9rMDoShRf23WEbPfeEA94z/BScKyUOyooyQ3XnQ== +esbuild@^0.8.16: + version "0.8.16" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.8.16.tgz#8ae34b15d938e8b8b5ac2459414fe3e7fd7dd6b2" + integrity sha512-HMvPNxDIhEGO/YUh8oO8oxQ1g+ttWz2anUF7NJmQglj2XfJS8zd8mP0Sb2y+jE1SVk3UjD/rYhdsEOFULN9/xw== escalade@^3.0.1: version "3.0.2" resolved "https://registry.npmjs.org/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== -escalade@^3.0.2: - version "3.1.0" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.0.tgz#e8e2d7c7a8b76f6ee64c2181d6b8151441602d4e" - integrity sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig== +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-goat@^2.0.0: version "2.1.1" @@ -10966,9 +11796,9 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-cypress@^2.10.3: - version "2.11.1" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862" - integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ== + version "2.11.2" + resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.2.tgz#a8f3fe7ec840f55e4cea37671f93293e6c3e76a0" + integrity sha512-1SergF1sGbVhsf7MYfOLiBhdOg6wqyeV9pXUAIDIffYTGMN3dTBQS9nFAzhLsHhO+Bn0GaVM1Ecm71XUidQ7VA== dependencies: globals "^11.12.0" @@ -11046,27 +11876,26 @@ eslint-plugin-notice@^0.9.10: metric-lcs "^0.1.2" eslint-plugin-react-hooks@^4.0.0: - version "4.1.1" - resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.1.1.tgz#b37e1ff622b7800cd673901de32db1103d48411d" - integrity sha512-vFJNwsf4MLYS0lHYfz9LqxQ+GccYsGGeKPHDxgkIaKbAhNuTLnST+q44pt81MzJZyFU5K4XLF6WiCGv3Yb6tCg== + version "4.2.0" + resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" + integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== eslint-plugin-react@^7.12.4: - version "7.19.0" - resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.19.0.tgz#6d08f9673628aa69c5559d33489e855d83551666" - integrity sha512-SPT8j72CGuAP+JFbT0sJHOB80TX/pu44gQ4vXH/cq+hQTiY2PuZ6IHkqXJV6x1b28GDdo1lbInjKUrrdUf0LOQ== + version "7.21.5" + resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.21.5.tgz#50b21a412b9574bfe05b21db176e8b7b3b15bff3" + integrity sha512-8MaEggC2et0wSF6bUeywF7qQ46ER81irOdWS4QWxnnlAEsnzeBevk1sWh7fhpCghPpXb+8Ks7hvaft6L/xsR6g== dependencies: array-includes "^3.1.1" + array.prototype.flatmap "^1.2.3" doctrine "^2.1.0" has "^1.0.3" - jsx-ast-utils "^2.2.3" - object.entries "^1.1.1" + jsx-ast-utils "^2.4.1 || ^3.0.0" + object.entries "^1.1.2" object.fromentries "^2.0.2" object.values "^1.1.1" prop-types "^15.7.2" - resolve "^1.15.1" - semver "^6.3.0" + resolve "^1.18.1" string.prototype.matchall "^4.0.2" - xregexp "^4.3.0" eslint-scope@^4.0.3: version "4.0.3" @@ -11245,6 +12074,11 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter2@^6.4.2: version "6.4.3" resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" @@ -11379,26 +12213,18 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/expect/-/expect-26.5.3.tgz#89d9795036f7358b0a9a5243238eb8086482d741" - integrity sha512-kkpOhGRWGOr+TEFUnYAjfGvv35bfP+OlPtqPIJpOCR9DVtv8QV+p8zG0Edqafh80fsjeE+7RBcVUq1xApnYglw== +expect@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" + integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" ansi-styles "^4.0.0" jest-get-type "^26.3.0" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" -express-prom-bundle@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.1.0.tgz#8fd72e5bedbbd686b8e7c49e0aecbc1b14b28743" - integrity sha512-krlvp5r6sgJ1IwL6M6/coMrNbAlwtpk+uyivfeyRMCupTK4HzIEQHH0gwrNhLiKyPmSbtZcSmtO6s+PRumRp5g== - dependencies: - on-finished "^2.3.0" - url-value-parser "^2.0.0" - express-promise-router@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-3.0.3.tgz#5e6d22a5a3f013d71833172fe8d7ab780c3f6b70" @@ -11408,6 +12234,20 @@ express-promise-router@^3.0.3: lodash.flattendeep "^4.0.0" methods "^1.0.0" +express-session@^1.17.1: + version "1.17.1" + resolved "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" + integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~2.0.0" + on-headers "~1.0.2" + parseurl "~1.3.3" + safe-buffer "5.2.0" + uid-safe "~2.1.5" + express@^4.0.0, express@^4.17.0, express@^4.17.1: version "4.17.1" resolved "https://registry.npmjs.org/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -11466,7 +12306,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@3.0.2, extend@^3.0.0, extend@~3.0.2: +extend@3.0.2, extend@^3.0.0, extend@^3.0.2, extend@~3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -11529,7 +12369,7 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -fast-deep-equal@2.0.1, fast-deep-equal@^2.0.1: +fast-deep-equal@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= @@ -11568,12 +12408,10 @@ fast-json-parse@^1.0.3: resolved "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d" integrity sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw== -fast-json-patch@^2.2.1: - version "2.2.1" - resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-2.2.1.tgz#18150d36c9ab65c7209e7d4eb113f4f8eaabe6d9" - integrity sha512-4j5uBaTnsYAV5ebkidvxiLUYOwjQ+JSFljeqfTxCrH9bDmlCQaOJFS84oDJ2rAXZq2yskmk3ORfoP9DCwqFNig== - dependencies: - fast-deep-equal "^2.0.1" +fast-json-patch@^3.0.0-1: + version "3.0.0-1" + resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.0.0-1.tgz#4c68f2e7acfbab6d29d1719c44be51899c93dabb" + integrity sha512-6pdFb07cknxvPzCeLsFHStEy+MysPJPgZQ9LbQ/2O67unQF93SNqfdSqnPPl71YMHX+AD8gbl7iuoGFzHEdDuw== fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -11595,6 +12433,11 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== +fast-text-encoding@^1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" + integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== + fastest-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" @@ -11645,14 +12488,13 @@ fbjs-css-vars@^1.0.0: resolved "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== -fbjs@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fbjs/-/fbjs-1.0.0.tgz#52c215e0883a3c86af2a7a776ed51525ae8e0a5a" - integrity sha512-MUgcMEJaFhCaF1QtWGnmq9ZDRAzECTCRAF7O6UZIlAlkTs1SasiX9aP0Iw7wfD2mJ7wDTNfg2w7u5fSCwJk1OA== +fbjs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/fbjs/-/fbjs-3.0.0.tgz#0907067fb3f57a78f45d95f1eacffcacd623c165" + integrity sha512-dJd4PiDOFuhe7vk4F80Mba83Vr2QuK86FoxtgPmzBqEJahncp+13YCmfoa53KHCo6OnlXLG7eeMWPfB5CrpVKg== dependencies: - core-js "^2.4.1" + cross-fetch "^3.0.4" fbjs-css-vars "^1.0.0" - isomorphic-fetch "^2.1.1" loose-envify "^1.0.0" object-assign "^4.1.0" promise "^7.1.1" @@ -12192,6 +13034,49 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +gaxios@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-3.2.0.tgz#11b6f0e8fb08d94a10d4d58b044ad3bec6dd486a" + integrity sha512-+6WPeVzPvOshftpxJwRi2Ozez80tn/hdtOUag7+gajDHRJvAblKxTFSSMPtr2hmnLy7p0mvYz0rMXLBl8pSO7Q== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.3.0" + +gaxios@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-4.0.1.tgz#bc7b205a89d883452822cc75e138620c35e3291e" + integrity sha512-jOin8xRZ/UytQeBpSXFqIzqU7Fi5TqgPNLlUsSB8kjJ76+FiGBfImF8KJu++c6J4jOldfJUtt0YmkRj2ZpSHTQ== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.3.0" + +gcp-metadata@^4.2.0: + version "4.2.1" + resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz#31849fbcf9025ef34c2297c32a89a1e7e9f2cd62" + integrity sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw== + dependencies: + gaxios "^4.0.0" + json-bigint "^1.0.0" + +gcs-resumable-upload@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.1.tgz#67c766a0555d6a352f9651b7603337207167d0de" + integrity sha512-RS1osvAicj9+MjCc6jAcVL1Pt3tg7NK2C2gXM5nqD1Gs0klF2kj5nnAFSBy97JrtslMIQzpb7iSuxaG8rFWd2A== + dependencies: + abort-controller "^3.0.0" + configstore "^5.0.0" + extend "^3.0.2" + gaxios "^3.0.0" + google-auth-library "^6.0.0" + pumpify "^2.0.0" + stream-events "^1.0.4" + generic-names@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872" @@ -12224,6 +13109,15 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.1.tgz#94a9768fcbdd0595a1c9273aacf4c89d075631be" + integrity sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + get-monorepo-packages@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/get-monorepo-packages/-/get-monorepo-packages-1.2.0.tgz#3eee88d30b11a5f65955dec6ae331958b2a168e4" @@ -12287,6 +13181,11 @@ get-stream@^5.0.0, get-stream@^5.1.0: dependencies: pump "^3.0.0" +get-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" + integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -12581,6 +13480,28 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" +google-auth-library@^6.0.0, google-auth-library@^6.1.1: + version "6.1.3" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.3.tgz#39d868140b70d0c4b32c6f6d8f4ccc1400d84dca" + integrity sha512-m9mwvY3GWbr7ZYEbl61isWmk+fvTmOt0YNUfPOUY2VH8K5pZlAIWJjxEi0PqR3OjMretyiQLI6GURMrPSwHQ2g== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-p12-pem@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz#673ac3a75d3903a87f05878f3c75e06fc151669e" + integrity sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA== + dependencies: + node-forge "^0.10.0" + got@^10.7.0: version "10.7.0" resolved "https://registry.npmjs.org/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" @@ -12636,7 +13557,7 @@ got@^11.6.2: p-cancelable "^2.0.0" responselike "^2.0.0" -got@^11.7.0: +got@^11.7.0, got@^11.8.0: version "11.8.0" resolved "https://registry.npmjs.org/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f" integrity sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ== @@ -12846,6 +13767,16 @@ growly@^1.3.0: resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= +gtoken@^5.0.4: + version "5.1.0" + resolved "https://registry.npmjs.org/gtoken/-/gtoken-5.1.0.tgz#4ba8d2fc9a8459098f76e7e8fd7beaa39fda9fe4" + integrity sha512-4d8N6Lk8TEAHl9vVoRVMh9BNOKWVgl2DdNtr3428O75r3QFrF/a5MMu851VmK0AA8+iSvbwRv69k5XnMLURGhg== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.0.3" + jws "^4.0.0" + mime "^2.2.0" + gud@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -12977,6 +13908,11 @@ hash-base@^3.0.0: inherits "^2.0.1" safe-buffer "^5.0.1" +hash-stream-validation@^0.2.2: + version "0.2.4" + resolved "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz#ee68b41bf822f7f44db1142ec28ba9ee7ccb7512" + integrity sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ== + hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" @@ -13000,6 +13936,17 @@ hastscript@^5.0.0: property-information "^5.0.0" space-separated-tokens "^1.0.0" +hastscript@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" + integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== + dependencies: + "@types/hast" "^2.0.0" + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + he@^1.1.0, he@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -13025,6 +13972,11 @@ highlight.js@^10.1.1, highlight.js@~10.1.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.1.2.tgz#c20db951ba1c22c055010648dfffd7b2a968e00c" integrity sha512-Q39v/Mn5mfBlMff9r+zzA+gWxRsCRKwEMvYTiisLr/XUiFI/4puWt0Ojdko3R3JCNWGdOWaA5g/Yxqa23kC5AA== +highlight.js@^10.4.1, highlight.js@~10.4.0: + version "10.4.1" + resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" + integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== + highlight.js@~9.15.0, highlight.js@~9.15.1: version "9.15.10" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" @@ -13269,7 +14221,7 @@ http-proxy-agent@^2.1.0: agent-base "4" debug "3.1.0" -http-proxy-agent@^4.0.1: +http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== @@ -13409,10 +14361,10 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@^1.1.13, ieee754@^1.1.4: - version "1.1.13" - resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== iferr@^0.1.5: version "0.1.5" @@ -13571,7 +14523,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, inherits@2.0.4, 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== @@ -13587,9 +14539,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== init-package-json@^1.10.3: version "1.10.3" @@ -13697,7 +14649,7 @@ interpret@^2.0.0, interpret@^2.2.0: resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.0.0, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -13798,7 +14750,7 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-buffer@^1.0.2, is-buffer@^1.1.4, is-buffer@^1.1.5: +is-buffer@^1.0.2, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -13813,6 +14765,11 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== +is-callable@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -13832,6 +14789,13 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" +is-core-module@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz#a4cc031d9b1aca63eecbd18a650e13cb4eeab946" + integrity sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -14000,6 +14964,11 @@ is-module@^1.0.0: resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= +is-negative-zero@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" + integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= + is-npm@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" @@ -14150,7 +15119,7 @@ is-ssh@^1.3.0: dependencies: protocols "^1.1.0" -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -14210,11 +15179,6 @@ is-utf8@^0.2.0: resolved "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= -is-whitespace-character@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" - integrity sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w== - is-window@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz#2c896ca53db97de45d3c33133a65d8c9f563480d" @@ -14225,11 +15189,6 @@ is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2: resolved "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== -is-word-character@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230" - integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA== - is-wsl@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" @@ -14284,14 +15243,6 @@ isobject@^4.0.0: resolved "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== -isomorphic-fetch@^2.1.1: - version "2.2.1" - resolved "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" - integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= - dependencies: - node-fetch "^1.0.1" - whatwg-fetch ">=0.10.0" - isomorphic-fetch@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4" @@ -14393,57 +15344,57 @@ jenkins@^0.28.0: dependencies: papi "^0.29.0" -jest-changed-files@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.5.2.tgz#330232c6a5c09a7f040a5870e8f0a9c6abcdbed5" - integrity sha512-qSmssmiIdvM5BWVtyK/nqVpN3spR5YyvkvPqz1x3BR1bwIxsWmU/MGwLoCrPNLbkG2ASAKfvmJpOduEApBPh2w== +jest-changed-files@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" + integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" execa "^4.0.0" throat "^5.0.0" -jest-cli@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.5.3.tgz#f936b98f247b76b7bc89c7af50af82c88e356a80" - integrity sha512-HkbSvtugpSXBf2660v9FrNVUgxvPkssN8CRGj9gPM8PLhnaa6zziFiCEKQAkQS4uRzseww45o0TR+l6KeRYV9A== +jest-cli@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" + integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== dependencies: - "@jest/core" "^26.5.3" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/core" "^26.6.3" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-config "^26.6.3" + jest-util "^26.6.2" + jest-validate "^26.6.2" prompts "^2.0.1" yargs "^15.4.1" -jest-config@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.5.3.tgz#baf51c9be078c2c755c8f8a51ec0f06c762c1d3f" - integrity sha512-NVhZiIuN0GQM6b6as4CI5FSCyXKxdrx5ACMCcv/7Pf+TeCajJhJc+6dwgdAVPyerUFB9pRBIz3bE7clSrRge/w== +jest-config@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" + integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^26.5.3" - "@jest/types" "^26.5.2" - babel-jest "^26.5.2" + "@jest/test-sequencer" "^26.6.3" + "@jest/types" "^26.6.2" + babel-jest "^26.6.3" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" - jest-environment-jsdom "^26.5.2" - jest-environment-node "^26.5.2" + jest-environment-jsdom "^26.6.2" + jest-environment-node "^26.6.2" jest-get-type "^26.3.0" - jest-jasmine2 "^26.5.3" + jest-jasmine2 "^26.6.3" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" micromatch "^4.0.2" - pretty-format "^26.5.2" + pretty-format "^26.6.2" jest-css-modules@^2.1.0: version "2.1.0" @@ -14452,17 +15403,7 @@ jest-css-modules@^2.1.0: dependencies: identity-obj-proxy "3.0.0" -jest-diff@^25.2.1: - version "25.5.0" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" - integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== - dependencies: - chalk "^3.0.0" - diff-sequences "^25.2.6" - jest-get-type "^25.2.6" - pretty-format "^25.5.0" - -jest-diff@^26.0.0, jest-diff@^26.5.2: +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== @@ -14472,6 +15413,16 @@ jest-diff@^26.0.0, jest-diff@^26.5.2: jest-get-type "^26.3.0" pretty-format "^26.6.1" +jest-diff@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" + integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== + dependencies: + chalk "^4.0.0" + diff-sequences "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -14479,41 +15430,41 @@ jest-docblock@^26.0.0: dependencies: detect-newline "^3.0.0" -jest-each@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.5.2.tgz#35e68d6906a7f826d3ca5803cfe91d17a5a34c31" - integrity sha512-w7D9FNe0m2D3yZ0Drj9CLkyF/mGhmBSULMQTypzAKR746xXnjUrK8GUJdlLTWUF6dd0ks3MtvGP7/xNFr9Aphg== +jest-each@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" + integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" jest-get-type "^26.3.0" - jest-util "^26.5.2" - pretty-format "^26.5.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" -jest-environment-jsdom@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.5.2.tgz#5feab05b828fd3e4b96bee5e0493464ddd2bb4bc" - integrity sha512-fWZPx0bluJaTQ36+PmRpvUtUlUFlGGBNyGX1SN3dLUHHMcQ4WseNEzcGGKOw4U5towXgxI4qDoI3vwR18H0RTw== +jest-environment-jsdom@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" + integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== dependencies: - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" jsdom "^16.4.0" -jest-environment-node@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.5.2.tgz#275a0f01b5e47447056f1541a15ed4da14acca03" - integrity sha512-YHjnDsf/GKFCYMGF1V+6HF7jhY1fcLfLNBDjhAOvFGvt6d8vXvNdJGVM7uTZ2VO/TuIyEFhPGaXMX5j3h7fsrA== +jest-environment-node@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" + integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== dependencies: - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" jest-esm-transformer@^1.0.0: version "1.0.0" @@ -14523,99 +15474,95 @@ jest-esm-transformer@^1.0.0: "@babel/core" "^7.4.4" "@babel/plugin-transform-modules-commonjs" "^7.4.4" -jest-get-type@^25.2.6: - version "25.2.6" - resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" - integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== - jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== -jest-haste-map@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.5.2.tgz#a15008abfc502c18aa56e4919ed8c96304ceb23d" - integrity sha512-lJIAVJN3gtO3k4xy+7i2Xjtwh8CfPcH08WYjZpe9xzveDaqGw9fVNCpkYu6M525wKFVkLmyi7ku+DxCAP1lyMA== +jest-haste-map@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" + integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/graceful-fs" "^4.1.2" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" jest-regex-util "^26.0.0" - jest-serializer "^26.5.0" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-serializer "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.5.3.tgz#baad2114ce32d16aff25aeb877d18bb4e332dc4c" - integrity sha512-nFlZOpnGlNc7y/+UkkeHnvbOM+rLz4wB1AimgI9QhtnqSZte0wYjbAm8hf7TCwXlXgDwZxAXo6z0a2Wzn9FoOg== +jest-jasmine2@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" + integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^26.5.2" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - expect "^26.5.3" + expect "^26.6.2" is-generator-fn "^2.0.0" - jest-each "^26.5.2" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" - jest-runtime "^26.5.3" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - pretty-format "^26.5.2" + jest-each "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" throat "^5.0.0" -jest-leak-detector@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.5.2.tgz#83fcf9a4a6ef157549552cb4f32ca1d6221eea69" - integrity sha512-h7ia3dLzBFItmYERaLPEtEKxy3YlcbcRSjj0XRNJgBEyODuu+3DM2o62kvIFvs3PsaYoIIv+e+nLRI61Dj1CNw== +jest-leak-detector@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" + integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== dependencies: jest-get-type "^26.3.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-matcher-utils@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.5.2.tgz#6aa2c76ce8b9c33e66f8856ff3a52bab59e6c85a" - integrity sha512-W9GO9KBIC4gIArsNqDUKsLnhivaqf8MSs6ujO/JDcPIQrmY+aasewweXVET8KdrJ6ADQaUne5UzysvF/RR7JYA== +jest-matcher-utils@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" + integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== dependencies: chalk "^4.0.0" - jest-diff "^26.5.2" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-message-util@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.5.2.tgz#6c4c4c46dcfbabb47cd1ba2f6351559729bc11bb" - integrity sha512-Ocp9UYZ5Jl15C5PNsoDiGEk14A4NG0zZKknpWdZGoMzJuGAkVt10e97tnEVMYpk7LnQHZOfuK2j/izLBMcuCZw== +jest-message-util@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" + integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== dependencies: "@babel/code-frame" "^7.0.0" - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.4" micromatch "^4.0.2" + pretty-format "^26.6.2" slash "^3.0.0" stack-utils "^2.0.2" -jest-mock@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.5.2.tgz#c9302e8ef807f2bfc749ee52e65ad11166a1b6a1" - integrity sha512-9SiU4b5PtO51v0MtJwVRqeGEroH66Bnwtq4ARdNP7jNXbpT7+ByeWNAk4NeT/uHfNSVDXEXgQo1XRuwEqS6Rdw== +jest-mock@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" + integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/node" "*" jest-pnp-resolver@^1.2.2: @@ -14628,118 +15575,119 @@ jest-regex-util@^26.0.0: resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== -jest-resolve-dependencies@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.5.3.tgz#11483f91e534bdcd257ab21e8622799e59701aba" - integrity sha512-+KMDeke/BFK+mIQ2IYSyBz010h7zQaVt4Xie6cLqUGChorx66vVeQVv4ErNoMwInnyYHi1Ud73tDS01UbXbfLQ== +jest-resolve-dependencies@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" + integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" jest-regex-util "^26.0.0" - jest-snapshot "^26.5.3" + jest-snapshot "^26.6.2" -jest-resolve@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.5.2.tgz#0d719144f61944a428657b755a0e5c6af4fc8602" - integrity sha512-XsPxojXGRA0CoDD7Vis59ucz2p3cQFU5C+19tz3tLEAlhYKkK77IL0cjYjikY9wXnOaBeEdm1rOgSJjbZWpcZg== +jest-resolve@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" + integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.2" - jest-util "^26.5.2" + jest-util "^26.6.2" read-pkg-up "^7.0.1" - resolve "^1.17.0" + resolve "^1.18.1" slash "^3.0.0" -jest-runner@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.5.3.tgz#800787459ea59c68e7505952933e33981dc3db38" - integrity sha512-qproP0Pq7IIule+263W57k2+8kWCszVJTC9TJWGUz0xJBr+gNiniGXlG8rotd0XxwonD5UiJloYoSO5vbUr5FQ== +jest-runner@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" + integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== dependencies: - "@jest/console" "^26.5.2" - "@jest/environment" "^26.5.2" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" - jest-config "^26.5.3" + jest-config "^26.6.3" jest-docblock "^26.0.0" - jest-haste-map "^26.5.2" - jest-leak-detector "^26.5.2" - jest-message-util "^26.5.2" - jest-resolve "^26.5.2" - jest-runtime "^26.5.3" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-haste-map "^26.6.2" + jest-leak-detector "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + jest-runtime "^26.6.3" + jest-util "^26.6.2" + jest-worker "^26.6.2" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.5.3.tgz#5882ae91fd88304310f069549e6bf82f3f198bea" - integrity sha512-IDjalmn2s/Tc4GvUwhPHZ0iaXCdMRq5p6taW9P8RpU+FpG01O3+H8z+p3rDCQ9mbyyyviDgxy/LHPLzrIOKBkQ== +jest-runtime@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" + integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== dependencies: - "@jest/console" "^26.5.2" - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/globals" "^26.5.3" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/globals" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/yargs" "^15.0.0" chalk "^4.0.0" + cjs-module-lexer "^0.6.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" - jest-config "^26.5.3" - jest-haste-map "^26.5.2" - jest-message-util "^26.5.2" - jest-mock "^26.5.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-resolve "^26.6.2" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" slash "^3.0.0" strip-bom "^4.0.0" yargs "^15.4.1" -jest-serializer@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.5.0.tgz#f5425cc4c5f6b4b355f854b5f0f23ec6b962bc13" - integrity sha512-+h3Gf5CDRlSLdgTv7y0vPIAoLgX/SI7T4v6hy+TEXMgYbv+ztzbg5PSN6mUXAT/hXYHvZRWm+MaObVfqkhCGxA== +jest-serializer@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" + integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== dependencies: "@types/node" "*" graceful-fs "^4.2.4" -jest-snapshot@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.5.3.tgz#f6b4b4b845f85d4b0dadd7cf119c55d0c1688601" - integrity sha512-ZgAk0Wm0JJ75WS4lGaeRfa0zIgpL0KD595+XmtwlIEMe8j4FaYHyZhP1LNOO+8fXq7HJ3hll54+sFV9X4+CGVw== +jest-snapshot@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" + integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/babel__traverse" "^7.0.4" "@types/prettier" "^2.0.0" chalk "^4.0.0" - expect "^26.5.3" + expect "^26.6.2" graceful-fs "^4.2.4" - jest-diff "^26.5.2" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - jest-haste-map "^26.5.2" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" - jest-resolve "^26.5.2" + jest-haste-map "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" natural-compare "^1.4.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" semver "^7.3.2" -jest-util@^26.1.0, jest-util@^26.5.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== @@ -14751,29 +15699,41 @@ jest-util@^26.1.0, jest-util@^26.5.2: is-ci "^2.0.0" micromatch "^4.0.2" -jest-validate@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.5.3.tgz#eefd5a5c87059550548c5ad8d6589746c66929e3" - integrity sha512-LX07qKeAtY+lsU0o3IvfDdN5KH9OulEGOMN1sFo6PnEf5/qjS1LZIwNk9blcBeW94pQUI9dLN9FlDYDWI5tyaA== +jest-util@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" + integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + graceful-fs "^4.2.4" + is-ci "^2.0.0" + micromatch "^4.0.2" + +jest-validate@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" + integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== + dependencies: + "@jest/types" "^26.6.2" camelcase "^6.0.0" chalk "^4.0.0" jest-get-type "^26.3.0" leven "^3.1.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-watcher@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.5.2.tgz#2957f4461007e0769d74b537379ecf6b7c696916" - integrity sha512-i3m1NtWzF+FXfJ3ljLBB/WQEp4uaNhX7QcQUWMokcifFTUQBDFyUMEwk0JkJ1kopHbx7Een3KX0Q7+9koGM/Pw== +jest-watcher@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" + integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== dependencies: - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - jest-util "^26.5.2" + jest-util "^26.6.2" string-length "^4.0.1" jest-worker@^26.2.1: @@ -14785,23 +15745,23 @@ jest-worker@^26.2.1: merge-stream "^2.0.0" supports-color "^7.0.0" -jest-worker@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.5.0.tgz#87deee86dbbc5f98d9919e0dadf2c40e3152fa30" - integrity sha512-kTw66Dn4ZX7WpjZ7T/SUDgRhapFRKWmisVAF0Rv4Fu8SLFD7eLbqpLvbxVqYhSgaWa7I+bW7pHnbyfNsH6stug== +jest-worker@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== dependencies: "@types/node" "*" merge-stream "^2.0.0" supports-color "^7.0.0" jest@^26.0.1: - version "26.5.3" - resolved "https://registry.npmjs.org/jest/-/jest-26.5.3.tgz#5e7a322d16f558dc565ca97639e85993ef5affe6" - integrity sha512-uJi3FuVSLmkZrWvaDyaVTZGLL8WcfynbRnFXyAHuEtYiSZ+ijDDIMOw1ytmftK+y/+OdAtsG9QrtbF7WIBmOyA== + version "26.6.3" + resolved "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" + integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== dependencies: - "@jest/core" "^26.5.3" + "@jest/core" "^26.6.3" import-local "^3.0.2" - jest-cli "^26.5.3" + jest-cli "^26.6.3" jose@^1.27.1: version "1.27.1" @@ -14817,6 +15777,11 @@ jose@^2.0.2: dependencies: "@panva/asn1.js" "^1.0.0" +joycon@^2.2.5: + version "2.2.5" + resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" + integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== + js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -14929,6 +15894,13 @@ jsesc@~0.5.0: resolved "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + json-buffer@3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" @@ -14967,6 +15939,15 @@ json-schema-merge-allof@^0.6.0: json-schema-compare "^0.2.2" lodash "^4.17.4" +json-schema-merge-allof@^0.7.0: + version "0.7.0" + resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.7.0.tgz#84d3e8c3e03d3060014286958eb8834fa9d76304" + integrity sha512-kvsuSVnl1n5xnNEu5ed4o8r8ujSA4/IgRtHmpgfMfa7FOMIRAzN4F9qbuklouTn5J8bi83y6MQ11n+ERMMTXZg== + dependencies: + compute-lcm "^1.1.0" + json-schema-compare "^0.2.2" + lodash "^4.17.4" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -15195,7 +16176,7 @@ jss@10.1.1, jss@^10.0.3: is-in-browser "^1.1.3" tiny-warning "^1.0.2" -jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: +jsx-ast-utils@^2.2.1: version "2.2.3" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f" integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA== @@ -15203,6 +16184,14 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.1.0" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.1.0.tgz#642f1d7b88aa6d7eb9d8f2210e166478444fa891" + integrity sha512-d4/UOjg+mxAWxCiF0c5UTSwyqbchkbqCvK87aBovhnh8GtysTjWmgC63tY0cJx/HzGgm9qnA147jVBdpOiQ2RA== + dependencies: + array-includes "^3.1.1" + object.assign "^4.1.1" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -15212,6 +16201,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -15220,10 +16218,18 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" -jwt-decode@2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" - integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk= +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + +jwt-decode@*, jwt-decode@^3.1.0: + version "3.1.2" + resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== keyv@^3.0.0: version "3.1.0" @@ -15346,6 +16352,13 @@ lazy-universal-dotenv@^3.0.1: dotenv "^8.0.0" dotenv-expand "^5.1.0" +lazystream@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" + integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ= + dependencies: + readable-stream "^2.0.5" + lcid@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -15408,13 +16421,6 @@ leven@^3.1.0: resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== -levenary@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" - integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== - dependencies: - leven "^3.1.0" - levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -15490,6 +16496,11 @@ lint-staged@^10.1.0: string-argv "0.3.1" stringify-object "^3.3.0" +listenercount@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" + integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc= + listr-silent-renderer@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" @@ -15662,7 +16673,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.11: +lodash-es@^4.17.11, lodash-es@^4.2.1: version "4.17.15" resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== @@ -15692,6 +16703,16 @@ lodash.debounce@^4, lodash.debounce@^4.0.8: resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw= + lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" @@ -15787,6 +16808,11 @@ lodash.throttle@^4.1.1: resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= +lodash.union@^4.6.0: + version "4.6.0" + resolved "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg= + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -15936,6 +16962,14 @@ lowlight@^1.14.0: fault "^1.0.0" highlight.js "~10.1.0" +lowlight@^1.17.0: + version "1.17.0" + resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.17.0.tgz#a1143b2fba8239df8cd5893f9fe97aaf8465af4a" + integrity sha512-vmtBgYKD+QVNy7tIa7ulz5d//Il9R4MooOVh4nkOf9R9Cb/Dk5TXMSTieg/vDulkBkIWj59/BIlyFQxT9X1oAQ== + dependencies: + fault "^1.0.0" + highlight.js "~10.4.0" + lru-cache@^4.0.1: version "4.1.5" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -16072,11 +17106,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -markdown-escapes@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" - integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== - markdown-it@^10.0.0: version "10.0.0" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" @@ -16465,7 +17494,7 @@ mime-db@1.44.0, "mime-db@>= 1.43.0 < 2": resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== -mime-types@^2.1.12, mime-types@^2.1.26, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.26, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== @@ -16477,6 +17506,11 @@ mime@1.6.0, mime@^1.4.1: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^2.2.0: + version "2.4.6" + resolved "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" + integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + mime@^2.3.1, mime@^2.4.4: version "2.4.4" resolved "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" @@ -16562,7 +17596,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -16695,9 +17729,9 @@ modify-values@^1.0.0: integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: - version "2.27.0" - resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" - integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== + version "2.29.1" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== monaco-editor@^0.20.0: version "0.20.0" @@ -16934,6 +17968,16 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +nock@*, nock@^13.0.5: + version "13.0.5" + resolved "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414" + integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-addon-api@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" @@ -16960,14 +18004,6 @@ node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node- resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - node-forge@^0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -17123,6 +18159,11 @@ node-releases@^1.1.52, node-releases@^1.1.58: resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== +node-releases@^1.1.67: + version "1.1.67" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" + integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== + node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" @@ -17157,9 +18198,9 @@ nodegit@0.27.0, nodegit@^0.27.0: tar-fs "^1.16.3" nodemon@^2.0.2: - version "2.0.4" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz#55b09319eb488d6394aa9818148c0c2d1c04c416" - integrity sha512-Ltced+hIfTmaS28Zjv1BM552oQ3dbwPqI4+zI0SLgq+wpJhSyqgYude/aZa/3i31VCQWMfXJVxvu86abcam3uQ== + version "2.0.6" + resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" + integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== dependencies: chokidar "^3.2.2" debug "^3.2.6" @@ -17169,8 +18210,8 @@ nodemon@^2.0.2: semver "^5.7.1" supports-color "^5.5.0" touch "^3.1.0" - undefsafe "^2.0.2" - update-notifier "^4.0.0" + undefsafe "^2.0.3" + update-notifier "^4.1.0" "nopt@2 || 3": version "3.0.6" @@ -17385,6 +18426,11 @@ object-inspect@^1.7.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== +object-inspect@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + object-is@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" @@ -17417,6 +18463,16 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" +object.assign@^4.1.1: + version "4.1.2" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + object.defaults@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" @@ -17427,14 +18483,13 @@ object.defaults@^1.1.0: for-own "^1.0.0" isobject "^3.0.0" -object.entries@^1.1.0, object.entries@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b" - integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ== +object.entries@^1.1.0, object.entries@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" + integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== dependencies: define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.17.5" has "^1.0.3" "object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.2: @@ -17500,7 +18555,7 @@ oidc-token-hash@^5.0.0: resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888" integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg== -on-finished@^2.3.0, on-finished@~2.3.0: +on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= @@ -17563,6 +18618,20 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw== +openid-client@*, openid-client@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" + integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== + dependencies: + base64url "^3.0.1" + got "^11.8.0" + jose "^2.0.2" + lru-cache "^6.0.0" + make-error "^1.3.6" + object-hash "^2.0.1" + oidc-token-hash "^5.0.0" + p-any "^3.0.0" + openid-client@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a" @@ -17750,6 +18819,13 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^3.0.1: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -17934,7 +19010,7 @@ parse-asn1@^5.0.0: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-entities@^1.1.0, parse-entities@^1.1.2: +parse-entities@^1.1.2: version "1.2.2" resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50" integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg== @@ -18494,6 +19570,11 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + pn@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" @@ -19024,17 +20105,7 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^25.2.1, pretty-format@^25.5.0: - version "25.5.0" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" - integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== - dependencies: - "@jest/types" "^25.5.0" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^16.12.0" - -pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-format@^26.6.1: +pretty-format@^26.0.0, pretty-format@^26.4.2, 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== @@ -19044,6 +20115,16 @@ pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-form ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" @@ -19056,6 +20137,13 @@ prismjs@^1.21.0, prismjs@^1.8.4, prismjs@~1.21.0: optionalDependencies: clipboard "^2.0.0" +prismjs@^1.22.0, prismjs@~1.22.0: + version "1.22.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa" + integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w== + optionalDependencies: + clipboard "^2.0.0" + prismjs@~1.17.0: version "1.17.1" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.17.1.tgz#e669fcbd4cdd873c35102881c33b14d0d68519be" @@ -19083,13 +20171,6 @@ progress@^2.0.0: resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prom-client@^12.0.0: - version "12.0.0" - resolved "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz#9689379b19bd3f6ab88a9866124db9da3d76c6ed" - integrity sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ== - dependencies: - tdigest "^0.1.1" - promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -19166,6 +20247,11 @@ prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, object-assign "^4.1.1" react-is "^16.8.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + property-expr@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" @@ -19275,6 +20361,15 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" +pumpify@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" + integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== + dependencies: + duplexify "^4.1.1" + inherits "^2.0.3" + pump "^3.0.0" + punycode@1.3.2: version "1.3.2" resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" @@ -19396,6 +20491,11 @@ ramda@^0.26, ramda@~0.26.1: resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -19435,10 +20535,11 @@ raw-loader@^4.0.1: schema-utils "^2.6.5" rc-progress@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/rc-progress/-/rc-progress-3.0.0.tgz#cea324ce8fc31421cd815d94a4649a8a29f8f8db" - integrity sha512-dQv1KU3o6Vay604FMYMF4S0x4GNXAgXf1tbQ1QoxeIeQt4d5fUeB7Ri82YPu+G+aRvH/AtxYAlEcnxyVZ1/4Hw== + version "3.1.1" + resolved "https://registry.npmjs.org/rc-progress/-/rc-progress-3.1.1.tgz#f7ba5eb33cc438862513ab5a71739feaf6b3ef4f" + integrity sha512-1ns3pW7ll9bHfdXtlVLF+vngdvlxiCDtiqwXnZFEdurst11JTiPxVdeqnCNbhWx5hP4kCKkAPqG1N0FVfTSUGA== dependencies: + "@babel/runtime" "^7.10.1" classnames "^2.2.6" rc@^1.2.7, rc@^1.2.8: @@ -19689,7 +20790,7 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -19714,26 +20815,12 @@ react-lazylog@^4.5.2, react-lazylog@^4.5.3: text-encoding-utf-8 "^1.0.1" whatwg-fetch "^2.0.4" -react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-markdown@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-4.3.1.tgz#39f0633b94a027445b86c9811142d05381300f2f" - integrity sha512-HQlWFTbDxTtNY6bjgp3C3uv1h2xcjCSi1zAEzfBW9OwJJvENSYiLXWNXN5hHLsoqai7RnZiiHzcnWdXk2Splzw== - dependencies: - html-to-react "^1.3.4" - mdast-add-list-metadata "1.0.1" - prop-types "^15.7.2" - react-is "^16.8.6" - remark-parse "^5.0.0" - unified "^6.1.5" - unist-util-visit "^1.3.0" - xtend "^4.0.1" - -react-markdown@^5.0.0, react-markdown@^5.0.2: +react-markdown@^5.0.2: version "5.0.2" resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-5.0.2.tgz#d15a8beb37b4ec34fc23dd892e7755eb7040b8db" integrity sha512-kmkB4JbV7LqkDAjvaKRKtodB3n3Id76/DalaDun1U8FuLB0SenPfvH+jAQ5Pcpo54cACRQc1LB1yXmuuuIVecw== @@ -19779,18 +20866,17 @@ react-popper@^1.3.7: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz#b19cf9e21d694422727bf798e934a916c4080f57" - integrity sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q== +react-redux@=4.4.10: + version "4.4.10" + resolved "https://registry.npmjs.org/react-redux/-/react-redux-4.4.10.tgz#ad57bd1db00c2d0aa7db992b360ce63dd0b80ec5" + integrity sha512-tjL0Bmpkj75Td0k+lXlF8Fc8a9GuXFv/3ahUOCXExWs/jhsKiQeTffdH0j5byejCGCRL4tvGFYlrwBF1X/Aujg== dependencies: - "@babel/runtime" "^7.1.2" + create-react-class "^15.5.1" hoist-non-react-statics "^3.3.0" - invariant "^2.2.4" - loose-envify "^1.1.0" - prop-types "^15.6.1" - react-is "^16.6.0" - react-lifecycles-compat "^3.0.0" + invariant "^2.0.0" + lodash "^4.17.11" + loose-envify "^1.4.0" + prop-types "^15.7.2" react-redux@^7.1.1: version "7.2.0" @@ -19896,7 +20982,7 @@ react-string-replace@^0.4.1: dependencies: lodash "^4.17.4" -react-syntax-highlighter@=12.2.1, react-syntax-highlighter@^12.2.1: +react-syntax-highlighter@^12.2.1: version "12.2.1" resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz#14d78352da1c1c3f93c6698b70ec7c706b83493e" integrity sha512-CTsp0ZWijwKRYFg9xhkWD4DSpQqE4vb2NKVMdPAkomnILSmsNBHE0n5GuI5zB+PU3ySVvXvdt9jo+ViD9XibCA== @@ -19918,6 +21004,17 @@ react-syntax-highlighter@^13.5.1: prismjs "^1.21.0" refractor "^3.1.0" +react-syntax-highlighter@^15.4.3: + version "15.4.3" + resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.3.tgz#fffe3286677ac470b963b364916d16374996f3a6" + integrity sha512-TnhGgZKXr5o8a63uYdRTzeb8ijJOgRGe0qjrE0eK/gajtdyqnSO6LqB3vW16hHB0cFierYSoy/AOJw8z1Dui8g== + dependencies: + "@babel/runtime" "^7.3.1" + highlight.js "^10.4.1" + lowlight "^1.17.0" + prismjs "^1.22.0" + refractor "^3.2.0" + react-test-renderer@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" @@ -20156,7 +21253,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -20169,7 +21266,7 @@ read@1, read@~1.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -"readable-stream@2 || 3", readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0: +"readable-stream@2 || 3", readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -20178,6 +21275,13 @@ read@1, read@~1.0.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdir-glob@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4" + integrity sha512-91/k1EzZwDx6HbERR+zucygRFfiPl2zkIYZtv3Jjr6Mn7SkKcVct8aVO+sSRiGMc6fLf72du3d92/uY63YPdEA== + dependencies: + minimatch "^3.0.4" + readdir-scoped-modules@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" @@ -20197,10 +21301,10 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -readdirp@~3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" - integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== dependencies: picomatch "^2.2.1" @@ -20299,7 +21403,17 @@ redux-immutable@3.1.0: dependencies: immutable "^3.8.1" -redux@^4.0.4, redux@^4.0.5: +redux@=3.7.2: + version "3.7.2" + resolved "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" + integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== + dependencies: + lodash "^4.2.1" + lodash-es "^4.2.1" + loose-envify "^1.1.0" + symbol-observable "^1.0.3" + +redux@^4.0.4: version "4.0.5" resolved "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -20330,6 +21444,15 @@ refractor@^3.1.0: parse-entities "^2.0.0" prismjs "~1.21.0" +refractor@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/refractor/-/refractor-3.2.0.tgz#bc46f7cfbb6adbf45cd304e8e299b7fa854804e0" + integrity sha512-hSo+EyMIZTLBvNNgIU5lW4yjCzNYMZ4dcEhBq/3nReGfqzd2JfVhdlPDfU9rEsgcAyWx+OimIIUoL4ZU7NtYHQ== + dependencies: + hastscript "^6.0.0" + parse-entities "^2.0.0" + prismjs "~1.22.0" + regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -20393,6 +21516,18 @@ regexpu-core@^4.6.0, regexpu-core@^4.7.0: unicode-match-property-ecmascript "^1.0.4" unicode-match-property-value-ecmascript "^1.2.0" +regexpu-core@^4.7.1: + version "4.7.1" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.2.0" + regjsgen "^0.5.1" + regjsparser "^0.6.4" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.2.0" + registry-auth-token@^4.0.0: version "4.1.1" resolved "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479" @@ -20429,10 +21564,10 @@ relateurl@^0.2.7: resolved "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= -relay-compiler@10.0.1: - version "10.0.1" - resolved "https://registry.npmjs.org/relay-compiler/-/relay-compiler-10.0.1.tgz#d3029a5121cad52e1e55073210365b827cee5f3b" - integrity sha512-hrTqh81XXxPB4EgvxPmvojICr0wJnRoumxOsMZnS9dmhDHSqcBAT7+C3+rdGm5sSdNH8mbMcZM7YSPDh8ABxQw== +relay-compiler@10.1.0: + version "10.1.0" + resolved "https://registry.npmjs.org/relay-compiler/-/relay-compiler-10.1.0.tgz#fb4672cdbe9b54869a3a79759edd8c2d91609cbe" + integrity sha512-HPqc3N3tNgEgUH5+lTr5lnLbgnsZMt+MRiyS0uAVNhuPY2It0X1ZJG+9qdA3L9IqKFUNwVn6zTO7RArjMZbARQ== dependencies: "@babel/core" "^7.0.0" "@babel/generator" "^7.5.0" @@ -20443,21 +21578,21 @@ relay-compiler@10.0.1: babel-preset-fbjs "^3.3.0" chalk "^4.0.0" fb-watchman "^2.0.0" - fbjs "^1.0.0" + fbjs "^3.0.0" glob "^7.1.1" immutable "~3.7.6" nullthrows "^1.1.1" - relay-runtime "10.0.1" + relay-runtime "10.1.0" signedsource "^1.0.0" yargs "^15.3.1" -relay-runtime@10.0.1: - version "10.0.1" - resolved "https://registry.npmjs.org/relay-runtime/-/relay-runtime-10.0.1.tgz#c83bd7e6e37234ece2a62a254e37dd199a4f74f9" - integrity sha512-sPYiuosq+5gQ7zXs2EKg2O8qRSsF8vmMYo6SIHEi4juBLg1HrdTEvqcaNztc2ZFmUc4vYZpTbbS4j/TZCtHuyA== +relay-runtime@10.1.0: + version "10.1.0" + resolved "https://registry.npmjs.org/relay-runtime/-/relay-runtime-10.1.0.tgz#4753bf36e95e8d862cef33608e3d98b4ed730d16" + integrity sha512-bxznLnQ1ST6APN/cFi7l0FpjbZVchWQjjhj9mAuJBuUqNNCh9uV+UTRhpQF7Q8ycsPp19LHTpVyGhYb0ustuRQ== dependencies: "@babel/runtime" "^7.0.0" - fbjs "^1.0.0" + fbjs "^3.0.0" remark-gfm@^1.0.0: version "1.0.0" @@ -20467,27 +21602,6 @@ remark-gfm@^1.0.0: mdast-util-gfm "^0.1.0" micromark-extension-gfm "^0.3.0" -remark-parse@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" - integrity sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA== - dependencies: - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^1.1.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^1.0.0" - vfile-location "^2.0.0" - xtend "^4.0.1" - remark-parse@^9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" @@ -20534,7 +21648,7 @@ repeat-element@^1.1.2: resolved "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== -repeat-string@^1.0.0, repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1: +repeat-string@^1.0.0, repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= @@ -20696,13 +21810,21 @@ resolve-url@^0.2.1: resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@1.17.0, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.15.1, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.3.2: +resolve@1.17.0: version "1.17.0" resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== dependencies: path-parse "^1.0.6" +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.3.2: + version "1.19.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== + dependencies: + is-core-module "^2.1.0" + path-parse "^1.0.6" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -20746,6 +21868,13 @@ ret@~0.1.10: resolved "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry-request@^4.1.1: + version "4.1.3" + resolved "https://registry.npmjs.org/retry-request/-/retry-request-4.1.3.tgz#d5f74daf261372cff58d08b0a1979b4d7cab0fde" + integrity sha512-QnRZUpuPNgX0+D1xVxul6DbJ9slvo4Rm6iV/dn63e048MvGbUZiKySVt6Tenp04JqmchxjiLltGerOJys7kJYQ== + dependencies: + debug "^4.1.1" + retry@0.12.0, retry@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" @@ -20824,12 +21953,14 @@ rollup-plugin-dts@1.4.13: optionalDependencies: "@babel/code-frame" "^7.10.4" -rollup-plugin-esbuild@^2.0.0: - version "2.3.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.3.0.tgz#7c107d4508af80d30966a148b306cb7d5b36b258" - integrity sha512-lNbWDRaClwXauaIybuaiOBRWgxcp3GkZu0UUix9yH/OHFgVohKmzlU0NtNbvfbxAcfEooyuBRAv6YCaJl1Mbpw== +rollup-plugin-esbuild@2.6.x: + version "2.6.0" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.0.tgz#80336399b113a179ccb2af5bdf7c03f061f37146" + integrity sha512-wtyDAX8kNABrBfwkrrG2xLRKRGSWUyMBURS067IRvRMpgJlLRo14WcFl95uGc4iYWfdAx436/z1LMzYqdlY4ig== dependencies: - "@rollup/pluginutils" "^3.1.0" + "@rollup/pluginutils" "^4.1.0" + joycon "^2.2.5" + strip-json-comments "^3.1.1" rollup-plugin-peer-deps-external@^2.2.2: version "2.2.3" @@ -20874,10 +22005,10 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@2.23.x: - version "2.23.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.23.0.tgz#b7ab1fee0c0e60132fd0553c4df1e9cdacfada9d" - integrity sha512-vLNmZFUGVwrnqNAJ/BvuLk1MtWzu4IuoqsH9UWK5AIdO3rt8/CSiJNvPvCIvfzrbNsqKbNzPAG1V2O4eTe2XZg== +rollup@2.33.x: + version "2.33.3" + resolved "https://registry.npmjs.org/rollup/-/rollup-2.33.3.tgz#ae72ce31f992b09a580072951bfea76e9df17342" + integrity sha512-RpayhPTe4Gu/uFGCmk7Gp5Z9Qic2VsqZ040G+KZZvsZYdcuWaJg678JeDJJvJeEQXminu24a2au+y92CUWVd+w== optionalDependencies: fsevents "~2.1.2" @@ -20937,7 +22068,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== @@ -21188,7 +22319,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -21267,7 +22398,7 @@ shell-quote@1.7.2: resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== -shelljs@^0.8.1, shelljs@^0.8.2, shelljs@^0.8.3: +shelljs@^0.8.2, shelljs@^0.8.3, shelljs@^0.8.4: version "0.8.4" resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== @@ -21289,13 +22420,12 @@ shortid@^2.2.14: nanoid "^2.1.0" shx@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/shx/-/shx-0.3.2.tgz#40501ce14eb5e0cbcac7ddbd4b325563aad8c123" - integrity sha512-aS0mWtW3T2sHAenrSrip2XGv39O9dXIFUqxAEWHEOS1ePtGIBavdPJY1kE2IHl14V/4iCbUiNDPGdyYTtmhSoA== + version "0.3.3" + resolved "https://registry.npmjs.org/shx/-/shx-0.3.3.tgz#681a88c7c10db15abe18525349ed474f0f1e7b9f" + integrity sha512-nZJ3HFWVoTSyyB+evEKjJ1STiixGztlqwKLTUNV5KqMWtGey9fTd4KU1gdZ1X9BV6215pswQ/Jew9NsuS/fNDA== dependencies: - es6-object-assign "^1.0.3" - minimist "^1.2.0" - shelljs "^0.8.1" + minimist "^1.2.3" + shelljs "^0.8.4" side-channel@^1.0.2: version "1.0.2" @@ -21409,6 +22539,11 @@ smartwrap@^1.2.3: wcwidth "^1.0.1" yargs "^15.1.0" +snakeize@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d" + integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0= + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -21791,11 +22926,6 @@ start-server-webpack-plugin@^2.2.5: resolved "https://registry.npmjs.org/start-server-webpack-plugin/-/start-server-webpack-plugin-2.2.5.tgz#4a2838759b0f36acd11b0b2f5f196f289ae29d31" integrity sha512-DRCkciwCJoCFZ+wt3wWMkR1M2mpVhJbUKFXqhK3FWyIUKYb42NnocH5sMwqgo+nPNHupqNwK/v8lgfBbr2NKdg== -state-toggle@^1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" - integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== - static-extend@^0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -21865,6 +22995,13 @@ stream-each@^1.1.0: end-of-stream "^1.1.0" stream-shift "^1.0.0" +stream-events@^1.0.1, stream-events@^1.0.4, stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + stream-http@^2.7.2: version "2.8.3" resolved "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" @@ -21996,6 +23133,14 @@ string.prototype.padstart@^3.0.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +string.prototype.trimend@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" + integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -22012,6 +23157,14 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" +string.prototype.trimstart@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" + integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -22128,6 +23281,11 @@ strong-log-transformer@^2.0.0: minimist "^1.2.0" through "^2.3.4" +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha1-6NK6H6nJBXAwPAMLaQD31fiavls= + style-inject@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" @@ -22297,18 +23455,18 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" -swagger-client@=3.10.12: - version "3.10.12" - resolved "https://registry.npmjs.org/swagger-client/-/swagger-client-3.10.12.tgz#f49beb7a6f91b6e5778cfbd3fd4064413be6e791" - integrity sha512-h2o7axvFViMc5sxwTjjza84Rhfz+C52wgMKPOT0P05jODjZhldBK7y9EvGt4zvqgzBJHS+FDQBmOT/dGf9SWdw== +swagger-client@^3.12.1: + version "3.12.1" + resolved "https://registry.npmjs.org/swagger-client/-/swagger-client-3.12.1.tgz#bbe4521c0f662e72ad84fa9dbe3eb42c9960d901" + integrity sha512-jxhf7yvMq1SIlaMBlL0S+2mk5eovdYNzVAn1Ewrpe1pkby2pm5PnnP8XiSImCuX7d+BxUfVFkOgCTeK38+fBlA== dependencies: - "@babel/runtime-corejs2" "^7.10.4" + "@babel/runtime-corejs3" "^7.11.2" btoa "^1.2.1" - buffer "^5.6.0" + buffer "^6.0.3" cookie "~0.4.1" - cross-fetch "^3.0.5" + cross-fetch "^3.0.6" deep-extend "~0.6.0" - fast-json-patch "^2.2.1" + fast-json-patch "^3.0.0-1" isomorphic-form-data "~2.0.0" js-yaml "^3.14.0" lodash "^4.17.19" @@ -22317,21 +23475,20 @@ swagger-client@=3.10.12: traverse "~0.6.6" url "~0.11.0" -swagger-ui-react@^3.31.1: - version "3.31.1" - resolved "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-3.31.1.tgz#1770cfa1dc390f79887d641ea35ee654f4bf7d80" - integrity sha512-bGpNzizMT/7N4LTdw6yx+t3rpsEoalrn0VIfhUzv9zXVrUTl+3AKPLHpLBr0r4NvzvaZLrYuSjkatnLfngJ4vg== +swagger-ui-react@^3.37.2: + version "3.38.0" + resolved "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-3.38.0.tgz#eebe7f03ba18e2b12cf921319da868729aadd3df" + integrity sha512-jkTRzKEWhilsHexZw1t9JvrDAInaGK9o1rF9EOXYqlb6wXeFVn5kK11eP6RhTRbYW6gM+unY2y10KV3dNbV5Mw== dependencies: - "@babel/runtime-corejs2" "^7.10.4" - "@braintree/sanitize-url" "^4.0.0" + "@babel/runtime-corejs3" "^7.11.2" + "@braintree/sanitize-url" "^5.0.0" "@kyleshockey/object-assign-deep" "^0.4.2" "@kyleshockey/xml" "^1.0.2" base64-js "^1.2.0" classnames "^2.2.6" - core-js "^2.6.11" css.escape "1.5.1" deep-extend "0.6.0" - dompurify "^2.0.7" + dompurify "^2.2.3" ieee754 "^1.1.13" immutable "^3.x.x" js-file-download "^0.4.1" @@ -22346,15 +23503,15 @@ swagger-ui-react@^3.31.1: react-immutable-pure-component "^1.1.1" react-inspector "^2.3.0" react-motion "^0.5.2" - react-redux "^5.1.2" - react-syntax-highlighter "=12.2.1" - redux "^4.0.5" + react-redux "=4.4.10" + react-syntax-highlighter "^15.4.3" + redux "=3.7.2" redux-immutable "3.1.0" remarkable "^2.0.1" reselect "^4.0.0" serialize-error "^2.1.0" sha.js "^2.4.11" - swagger-client "=3.10.12" + swagger-client "^3.12.1" url-parse "^1.4.7" xml-but-prettier "^1.0.1" zenscroll "^4.0.2" @@ -22366,7 +23523,7 @@ swr@^0.3.0: dependencies: fast-deep-equal "2.0.1" -symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: +symbol-observable@^1.0.3, symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== @@ -22448,6 +23605,17 @@ tar-stream@^2.0.0: inherits "^2.0.3" readable-stream "^3.1.1" +tar-stream@^2.1.4: + version "2.1.4" + resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz#c4fb1a11eb0da29b893a5b25476397ba2d053bfa" + integrity sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar@^2.0.0: version "2.2.2" resolved "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" @@ -22487,12 +23655,16 @@ tarn@^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" - resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" - integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= +teeny-request@^7.0.0: + version "7.0.1" + resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz#bdd41fdffea5f8fbc0d29392cb47bec4f66b2b4c" + integrity sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw== dependencies: - bintrees "1.0.1" + http-proxy-agent "^4.0.0" + https-proxy-agent "^5.0.0" + node-fetch "^2.6.1" + stream-events "^1.0.5" + uuid "^8.0.0" telejson@^5.0.2: version "5.0.2" @@ -22882,6 +24054,11 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +"traverse@>=0.3.0 <0.4": + version "0.3.9" + resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" + integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= + traverse@~0.6.6: version "0.6.6" resolved "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" @@ -22907,16 +24084,6 @@ trim-off-newlines@^1.0.0: resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" integrity sha1-n5up2e+odkw4dpi8v+sshI8RrbM= -trim-trailing-lines@^1.0.0: - version "1.1.3" - resolved "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz#7f0739881ff76657b7776e10874128004b625a94" - integrity sha512-4ku0mmjXifQcTVfYDfR5lpgV7zVqPg6zV9rdZmwOPqq0+Zq19xDqEgagqVbc4pOOShbncuAOIs59R3+3gcF3ZA== - -trim@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" - integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= - triple-beam@^1.2.0, triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" @@ -23168,10 +24335,21 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz#153bbd468ef07725c1df9c77e8b453f8d36abba5" - integrity sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg== +typescript-json-schema@^0.45.0: + version "0.45.0" + resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.45.0.tgz#2f244e99518e589a442ee5f9c1d2c85a1ec7dc84" + integrity sha512-4MeX0HIODRd+K1/sIbkPryGXG43PVQ9cvIC8gDYml6yBgcsWLjvPMpTNr9VV+bQwpDLncB8Ie4qSenuKUwNZpg== + dependencies: + "@types/json-schema" "^7.0.6" + glob "^7.1.6" + json-stable-stringify "^1.0.1" + typescript "^4.1.2" + yargs "^16.1.1" + +typescript@^4.0.3, typescript@^4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" + integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== ua-parser-js@^0.7.18: version "0.7.21" @@ -23196,6 +24374,13 @@ uid-number@0.0.6: resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= +uid-safe@~2.1.5: + version "2.1.5" + resolved "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + uid2@0.0.3, uid2@0.0.x: version "0.0.3" resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" @@ -23211,7 +24396,7 @@ unc-path-regex@^0.1.2: resolved "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= -undefsafe@^2.0.2: +undefsafe@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz#6b166e7094ad46313b2202da7ecc2cd7cc6e7aae" integrity sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A== @@ -23233,14 +24418,6 @@ unfetch@^4.1.0: resolved "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db" integrity sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg== -unherit@^1.0.4: - version "1.1.3" - resolved "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" - integrity sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ== - dependencies: - inherits "^2.0.0" - xtend "^4.0.0" - unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -23264,18 +24441,6 @@ unicode-property-aliases-ecmascript@^1.0.4: resolved "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== -unified@^6.1.5: - version "6.2.0" - resolved "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" - integrity sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA== - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-plain-obj "^1.1.0" - trough "^1.0.0" - vfile "^2.0.0" - x-is-string "^0.1.0" - unified@^9.0.0: version "9.2.0" resolved "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" @@ -23329,28 +24494,11 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" -unist-util-is@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" - integrity sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A== - unist-util-is@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.0.3.tgz#e8b44db55fc20c43752b3346c116344d45d7c91d" integrity sha512-bTofCFVx0iQM8Jqb1TBDVRIQW03YkD3p66JOd/aCWuqzlLyUtx1ZAGw/u+Zw+SttKvSVcvTiKYbfrtLoLefykw== -unist-util-remove-position@^1.0.0: - version "1.1.4" - resolved "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-1.1.4.tgz#ec037348b6102c897703eee6d0294ca4755a2020" - integrity sha512-tLqd653ArxJIPnKII6LMZwH+mb5q+n/GtXQZo6S6csPRs5zB0u79Yw8ouR3wTw8wxvdJFhpP6Y7jorWdCgLO0A== - dependencies: - unist-util-visit "^1.1.0" - -unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: - version "1.1.2" - resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" - integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ== - unist-util-stringify-position@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da" @@ -23363,13 +24511,6 @@ unist-util-visit-parents@1.1.2: resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz#f6e3afee8bdbf961c0e6f028ea3c0480028c3d06" integrity sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q== -unist-util-visit-parents@^2.0.0: - version "2.1.2" - resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz#25e43e55312166f3348cae6743588781d112c1e9" - integrity sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g== - dependencies: - unist-util-is "^3.0.0" - unist-util-visit-parents@^3.0.0: version "3.1.1" resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6" @@ -23378,13 +24519,6 @@ unist-util-visit-parents@^3.0.0: "@types/unist" "^2.0.0" unist-util-is "^4.0.0" -unist-util-visit@^1.1.0, unist-util-visit@^1.3.0: - version "1.4.1" - resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" - integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== - dependencies: - unist-util-visit-parents "^2.0.0" - unist-util-visit@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" @@ -23453,15 +24587,31 @@ untildify@^4.0.0: resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== +unzipper@^0.10.11: + version "0.10.11" + resolved "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" + integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== + dependencies: + big-integer "^1.6.17" + binary "~0.3.0" + bluebird "~3.4.1" + buffer-indexof-polyfill "~1.0.0" + duplexer2 "~0.1.4" + fstream "^1.0.12" + graceful-fs "^4.2.2" + listenercount "~1.0.1" + readable-stream "~2.3.6" + setimmediate "~1.0.4" + upath@^1.1.1, upath@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== -update-notifier@^4.0.0: - version "4.1.0" - resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3" - integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew== +update-notifier@^4.1.0: + version "4.1.3" + resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" + integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== dependencies: boxen "^4.2.0" chalk "^3.0.0" @@ -23520,11 +24670,6 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" -url-value-parser@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed" - integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw== - url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -23643,10 +24788,10 @@ v8-compile-cache@^2.0.3: resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== -v8-to-istanbul@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-6.0.1.tgz#7ef0e32faa10f841fe4c1b0f8de96ed067c0be1e" - integrity sha512-PzM1WlqquhBvsV+Gco6WSFeg1AGdD53ccMRkFeyHRE/KRZaVacPOmQYP3EeVgDBtKD2BJ8kgynBQ5OtKiHCH+w== +v8-to-istanbul@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz#b4fe00e35649ef7785a9b7fcebcea05f37c332fc" + integrity sha512-fLL2rFuQpMtm9r8hrAV2apXX/WqHJ6+IC4/eQVdMDGBUgH/YMV4Gv3duk3kjmyg6uiQWBAA9nJwue4iJUOkHeA== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" @@ -23740,18 +24885,6 @@ verror@1.10.0, verror@^1.8.1: core-util-is "1.0.2" extsprintf "^1.2.0" -vfile-location@^2.0.0: - version "2.0.6" - resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-2.0.6.tgz#8a274f39411b8719ea5728802e10d9e0dff1519e" - integrity sha512-sSFdyCP3G6Ka0CEmN83A2YCMKIieHx0EDaj5IDP4g1pa5ZJ4FJDvpO0WODLxo4LUX4oe52gmSCK7Jw4SBghqxA== - -vfile-message@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-1.1.1.tgz#5833ae078a1dfa2d96e9647886cd32993ab313e1" - integrity sha512-1WmsopSGhWt5laNir+633LszXvZ+Z/lxveBf6yhGsqnQIhlhzooZae7zV6YVM1Sdkw68dtAW3ow0pOdPANugvA== - dependencies: - unist-util-stringify-position "^1.1.1" - vfile-message@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a" @@ -23760,16 +24893,6 @@ vfile-message@^2.0.0: "@types/unist" "^2.0.0" unist-util-stringify-position "^2.0.0" -vfile@^2.0.0: - version "2.3.0" - resolved "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" - integrity sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w== - dependencies: - is-buffer "^1.1.4" - replace-ext "1.0.0" - unist-util-stringify-position "^1.0.0" - vfile-message "^1.0.0" - vfile@^4.0.0: version "4.2.0" resolved "https://registry.npmjs.org/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01" @@ -24052,11 +25175,6 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@>=0.10.0: - version "3.3.1" - resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.3.1.tgz#6c1acf37dec176b0fd6bc9a74b616bec2f612935" - integrity sha512-faXTmGDcLuEPBpJwb5LQfyxvubKiE+RlbmmweFGKjvIPFj4uHTTfdtTIkdTRhC6OSH9S9eyYbx8kZ0UEaQqYTA== - whatwg-fetch@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" @@ -24346,11 +25464,6 @@ ws@^7.3.1: resolved "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8" integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== -x-is-string@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" - integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI= - xcase@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" @@ -24424,13 +25537,6 @@ xpath@0.0.27: resolved "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== -xregexp@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz#7e92e73d9174a99a59743f67a4ce879a04b5ae50" - integrity sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g== - dependencies: - "@babel/runtime-corejs3" "^7.8.3" - xss@^1.0.6: version "1.0.7" resolved "https://registry.npmjs.org/xss/-/xss-1.0.7.tgz#a554cbd5e909324bd6893fb47fff441ad54e2a95" @@ -24454,10 +25560,10 @@ y18n@^4.0.0: resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== -y18n@^5.0.1: - version "5.0.2" - resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.2.tgz#48218df5da2731b4403115c39a1af709c873f829" - integrity sha512-CkwaeZw6dQgqgPGeTWKMXCRmMcBgETFlTml1+ZOO+q7kGst8NREJ+eWwFNPVUQ4QGdAaklbqCZHH6Zuep1RjiA== +y18n@^5.0.5: + version "5.0.5" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18" + integrity sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg== yaeti@^0.0.6: version "0.0.6" @@ -24525,10 +25631,10 @@ yargs-parser@^18.1.2: 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" - integrity sha512-yYsjuSkjbLMBp16eaOt7/siKTjNVjMm3SoJnIg3sEh/JsvqVVDyjRKmaJV4cl+lNIgq6QEco2i3gDebJl7/vLA== +yargs-parser@^20.2.2: + version "20.2.4" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== yargs-parser@^3.2.0: version "3.2.0" @@ -24588,18 +25694,18 @@ yargs@^15.1.0, yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.3: - version "16.0.3" - resolved "https://registry.npmjs.org/yargs/-/yargs-16.0.3.tgz#7a919b9e43c90f80d4a142a89795e85399a7e54c" - integrity sha512-6+nLw8xa9uK1BOEOykaiYAJVh6/CjxWXK/q9b5FpRgNslt8s22F2xMBqVIKgCRjNgGvGPBy8Vog7WN7yh4amtA== +yargs@^16.0.3, yargs@^16.1.1: + version "16.1.1" + resolved "https://registry.npmjs.org/yargs/-/yargs-16.1.1.tgz#5a4a095bd1ca806b0a50d0c03611d38034d219a1" + integrity sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w== dependencies: - cliui "^7.0.0" - escalade "^3.0.2" + cliui "^7.0.2" + escalade "^3.1.1" get-caller-file "^2.0.5" require-directory "^2.1.1" string-width "^4.2.0" - y18n "^5.0.1" - yargs-parser "^20.0.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" yargs@^5.0.0: version "5.0.0" @@ -24647,6 +25753,11 @@ yn@^4.0.0: resolved "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz#611480051ea43b510da1dfdbe177ed159f00a979" integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + yup@^0.29.3: version "0.29.3" resolved "https://registry.npmjs.org/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" @@ -24678,6 +25789,15 @@ zenscroll@^4.0.2: resolved "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz#e8d5774d1c0738a47bcfa8729f3712e2deddeb25" integrity sha1-6NV3TRwHOKR7z6hynzcS4t7d6yU= +zip-stream@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.0.2.tgz#3a20f1bd7729c2b59fd4efa04df5eb7a5a217d2e" + integrity sha512-TGxB2g+1ur6MHkvM644DuZr8Uzyz0k0OYWtS3YlpfWBEmK4woaC2t3+pozEL3dBfIPmpgmClR5B2QRcMgGt22g== + dependencies: + archiver-utils "^2.1.0" + compress-commons "^4.0.0" + readable-stream "^3.6.0" + zombie@^6.1.4: version "6.1.4" resolved "https://registry.npmjs.org/zombie/-/zombie-6.1.4.tgz#9f0f53f3d9a032beb7f3fe5b382146a3475a4d47"