diff --git a/.changeset/afraid-carpets-know.md b/.changeset/afraid-carpets-know.md new file mode 100644 index 0000000000..94a5452606 --- /dev/null +++ b/.changeset/afraid-carpets-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Add Optional Props to Override Icon for SidebarSearch and SidebarSearchModal Component diff --git a/.changeset/blue-queens-sniff.md b/.changeset/blue-queens-sniff.md new file mode 100644 index 0000000000..9a26507f92 --- /dev/null +++ b/.changeset/blue-queens-sniff.md @@ -0,0 +1,7 @@ +--- +'@backstage/app-defaults': patch +'@backstage/core-app-api': patch +'@backstage/core-plugin-api': patch +--- + +Add `FetchApi` and related `fetchApiRef` which implement fetch, with an added Backstage token header when available. diff --git a/.changeset/brave-impalas-switch.md b/.changeset/brave-impalas-switch.md new file mode 100644 index 0000000000..729a4ec96a --- /dev/null +++ b/.changeset/brave-impalas-switch.md @@ -0,0 +1,22 @@ +--- +'@backstage/backend-common': patch +--- + +Fixed bug in backend-common to allow passing of remote option in order to enable passing remote url in --config option. The remote option should be passed along with reloadIntervalSeconds from packages/backend/src/index.ts (Updated the file as well) + +These changes are needed in `packages/backend/src/index.ts` if remote URLs are desired to be passed in --config option and read and watch remote files for config. + +```diff +@@ -86,7 +86,11 @@ async function main() { + const config = await loadBackendConfig({ + argv: process.argv, + logger, ++ remote: { ++ reloadIntervalSeconds: 60 * 10 // Check remote config changes every 10 minutes. Change to your desired interval in seconds ++ } + }); ++ + const createEnv = makeCreateEnv(config); + + const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); +``` diff --git a/.changeset/clever-singers-search.md b/.changeset/clever-singers-search.md deleted file mode 100644 index 0750cea8a8..0000000000 --- a/.changeset/clever-singers-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/integration': patch ---- - -Narrow the types returned by the request option functions, to only the specifics that they actually do return. The reason for this change is that a full `RequestInit` is unfortunate to return because it's different between `cross-fetch` and `node-fetch`. diff --git a/.changeset/cuddly-cooks-enjoy.md b/.changeset/cuddly-cooks-enjoy.md new file mode 100644 index 0000000000..d39844e7a9 --- /dev/null +++ b/.changeset/cuddly-cooks-enjoy.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +In case remote.reloadIntervalSeconds is passed, it must be a valid positive value diff --git a/.changeset/cuddly-suns-sit.md b/.changeset/cuddly-suns-sit.md new file mode 100644 index 0000000000..f1fe16add6 --- /dev/null +++ b/.changeset/cuddly-suns-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Fix undefined identity bug in UserSettingsProfileCard caused by using deprecated methods of the IdentityApi diff --git a/.changeset/dry-pianos-brush.md b/.changeset/dry-pianos-brush.md deleted file mode 100644 index c749962a83..0000000000 --- a/.changeset/dry-pianos-brush.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Incorporate usage of the tokenManager into the backend created using `create-app`. - -In existing backends, update the `PluginEnvironment` to include a `tokenManager`: - -```diff -// packages/backend/src/types.ts - -... -import { - ... -+ TokenManager, -} from '@backstage/backend-common'; - -export type PluginEnvironment = { - ... -+ tokenManager: TokenManager; -}; -``` - -Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. - -```diff -// packages/backend/src/index.ts - -... -import { - ... -+ ServerTokenManager, -} from '@backstage/backend-common'; -... - -function makeCreateEnv(config: Config) { - ... - // CHOOSE ONE - // TokenManager not requiring a secret -+ const tokenManager = ServerTokenManager.noop(); - // OR TokenManager requiring a secret -+ const tokenManager = ServerTokenManager.fromConfig(config); - - ... - return (plugin: string): PluginEnvironment => { - ... -- return { logger, cache, database, config, reader, discovery }; -+ return { logger, cache, database, config, reader, discovery, tokenManager }; - }; -} -``` diff --git a/.changeset/eleven-baboons-sparkle.md b/.changeset/eleven-baboons-sparkle.md new file mode 100644 index 0000000000..e8c28af482 --- /dev/null +++ b/.changeset/eleven-baboons-sparkle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +added useOwnedEntities hook to get the list of entities of the logged-in user diff --git a/.changeset/fluffy-grapes-decide.md b/.changeset/fluffy-grapes-decide.md new file mode 100644 index 0000000000..17a6cc965f --- /dev/null +++ b/.changeset/fluffy-grapes-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added peerPluginDependencies option to experimentalInstallationRecipe for install command to install plugins it depends on. diff --git a/.changeset/fresh-months-approve.md b/.changeset/fresh-months-approve.md new file mode 100644 index 0000000000..c2da09daf6 --- /dev/null +++ b/.changeset/fresh-months-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': minor +--- + +Add new property to enable open links in a new window/tab diff --git a/.changeset/fresh-pumas-collect.md b/.changeset/fresh-pumas-collect.md deleted file mode 100644 index 15d36472ed..0000000000 --- a/.changeset/fresh-pumas-collect.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@backstage/config-loader': patch -'@backstage/plugin-auth-backend': patch -'@backstage/plugin-badges-backend': patch -'@backstage/plugin-bitrise': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-catalog-backend-module-msgraph': patch -'@backstage/plugin-code-coverage-backend': patch -'@backstage/plugin-fossa': patch -'@backstage/plugin-jenkins-backend': patch -'@backstage/plugin-scaffolder-backend': patch -'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch -'@backstage/plugin-sonarqube': patch -'@backstage/plugin-tech-insights-backend': patch -'@backstage/plugin-techdocs-backend': patch -'@backstage/plugin-todo-backend': patch ---- - -Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them diff --git a/.changeset/funny-chefs-guess.md b/.changeset/funny-chefs-guess.md new file mode 100644 index 0000000000..66824892f2 --- /dev/null +++ b/.changeset/funny-chefs-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Fixed bug in OwnershipCard component where text wasn't correctly pluralized diff --git a/.changeset/gentle-humans-hope.md b/.changeset/gentle-humans-hope.md new file mode 100644 index 0000000000..b8c337cec9 --- /dev/null +++ b/.changeset/gentle-humans-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-rails': patch +--- + +Add new options to rails new (force and skipTests) diff --git a/.changeset/gold-seas-wave.md b/.changeset/gold-seas-wave.md new file mode 100644 index 0000000000..ec0c6451b7 --- /dev/null +++ b/.changeset/gold-seas-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-common': minor +--- + +Create catalog-common and add catalog permissions. diff --git a/.changeset/good-fans-dance.md b/.changeset/good-fans-dance.md new file mode 100644 index 0000000000..a24b467c01 --- /dev/null +++ b/.changeset/good-fans-dance.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-bazaar': patch +'@backstage/plugin-bazaar-backend': patch +--- + +made the linkage between a Bazaar project to a catalog Entity optional diff --git a/.changeset/green-toes-search.md b/.changeset/green-toes-search.md deleted file mode 100644 index 2bdba7e31a..0000000000 --- a/.changeset/green-toes-search.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@backstage/plugin-techdocs-backend': minor ---- - -**BREAKING** `DefaultTechDocsCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: - -```diff -// packages/backend/src/plugins/search.ts - -... -export default async function createPlugin({ - ... -+ tokenManager, -}: PluginEnvironment) { - ... - - indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: DefaultTechDocsCollator.fromConfig(config, { - discovery, - logger, -+ tokenManager, - }), - }); - - ... -} -``` diff --git a/.changeset/itchy-grapes-think.md b/.changeset/itchy-grapes-think.md new file mode 100644 index 0000000000..c453b24922 --- /dev/null +++ b/.changeset/itchy-grapes-think.md @@ -0,0 +1,25 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Deprecated the `CatalogClientWrapper` class. + +The default implementation of `catalogApiRef` that this plugin exposes, is now powered by the new `fetchApiRef`. The default implementation of _that_ API, in turn, has the ability to inject the user's Backstage token in requests in a similar manner to what the deprecated `CatalogClientWrapper` used to do. The latter has therefore been taken out of the default catalog API implementation. + +If you use a custom `fetchApiRef` implementation that does NOT issue tokens, or use a custom `catalogApiRef` implementation which does NOT use the default `fetchApiRef`, you can still for some time wrap your catalog API in this class to get back the old behavior: + +```ts +// Add this to your packages/app/src/plugins.ts if you want to get back the old +// catalog client behavior: +createApiFactory({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new CatalogClientWrapper({ + client: new CatalogClient({ discoveryApi }), + identityApi, + }), +}), +``` + +But do consider migrating to making use of the `fetchApiRef` as soon as convenient, since the wrapper class will be removed in a future release. diff --git a/.changeset/lemon-moons-stare.md b/.changeset/lemon-moons-stare.md deleted file mode 100644 index 4b7818d403..0000000000 --- a/.changeset/lemon-moons-stare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Create a `TokenManager` interface and `ServerTokenManager` implementation to generate and validate server tokens for authenticated backend-to-backend API requests. diff --git a/.changeset/lovely-drinks-kiss.md b/.changeset/lovely-drinks-kiss.md new file mode 100644 index 0000000000..f343888920 --- /dev/null +++ b/.changeset/lovely-drinks-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Add the ability to supply a custom `fetchApi`. In the default frontend app setup, this will use the `fetchApiRef` implementation. diff --git a/.changeset/many-trees-happen.md b/.changeset/many-trees-happen.md new file mode 100644 index 0000000000..f29c11fb22 --- /dev/null +++ b/.changeset/many-trees-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Removed deprecated option `env` from `LoadConfigOptions` and associated tests diff --git a/.changeset/neat-stingrays-decide.md b/.changeset/neat-stingrays-decide.md new file mode 100644 index 0000000000..f339d7ded6 --- /dev/null +++ b/.changeset/neat-stingrays-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': minor +--- + +Removed deprecated `Keyboard` class which has been superseded by `@testing-library/user-event#userEvent` diff --git a/.changeset/new-suns-agree.md b/.changeset/new-suns-agree.md new file mode 100644 index 0000000000..c93bb1ca12 --- /dev/null +++ b/.changeset/new-suns-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-rollbar-backend': patch +--- + +Bump `axios` diff --git a/.changeset/new-waves-rule.md b/.changeset/new-waves-rule.md new file mode 100644 index 0000000000..7470a1d08e --- /dev/null +++ b/.changeset/new-waves-rule.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-app-api': minor +--- + +- Removed deprecated definition `createApp` from `@backstage/core-app-api` which has been replaced by `@backstage/app-defaults#createApp` +- Removed deprecated type `BackstagePluginWithAnyOutput` +- Removed deprecated constructors for `GithubAuth`, `OAuth2`, and `SamlAuth` as the `create` method should be used instead diff --git a/.changeset/nice-apricots-grow.md b/.changeset/nice-apricots-grow.md new file mode 100644 index 0000000000..290041091d --- /dev/null +++ b/.changeset/nice-apricots-grow.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-plugin-api': minor +--- + +- Removed deprecated option `description` from `ApiRefConfig` +- Removed descriptions from all plugin API refs +- Removed deprecated parameters `path`, `icon`, and `title` in `createRouteRef` +- Removed deprecated types `Error` and `ErrorContext` from `ErrorApi` diff --git a/.changeset/olive-cameras-rush.md b/.changeset/olive-cameras-rush.md new file mode 100644 index 0000000000..9a83aea699 --- /dev/null +++ b/.changeset/olive-cameras-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': minor +--- + +Removed deprecated `msw` definition which was replaced by calling `setupRequestMockHandlers` directly diff --git a/.changeset/olive-rats-destroy.md b/.changeset/olive-rats-destroy.md deleted file mode 100644 index 5908fc0f10..0000000000 --- a/.changeset/olive-rats-destroy.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/cli': patch -'@techdocs/cli': patch ---- - -Bump react-dev-utils to v12 diff --git a/.changeset/perfect-apricots-raise.md b/.changeset/perfect-apricots-raise.md new file mode 100644 index 0000000000..d7a40e6343 --- /dev/null +++ b/.changeset/perfect-apricots-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Removed deprecated option `configPaths` as it has been superseded by `configTargets` diff --git a/.changeset/perfect-buses-collect.md b/.changeset/perfect-buses-collect.md new file mode 100644 index 0000000000..e0df504087 --- /dev/null +++ b/.changeset/perfect-buses-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added OwnedEntityPicker field which displays Owned Entities in options diff --git a/.changeset/rare-ladybugs-invite.md b/.changeset/rare-ladybugs-invite.md new file mode 100644 index 0000000000..4153363b9c --- /dev/null +++ b/.changeset/rare-ladybugs-invite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Fixed the fallback identity population to correctly generate an entity reference for `userEntityRef` if no token is provided. diff --git a/.changeset/rude-brooms-raise.md b/.changeset/rude-brooms-raise.md deleted file mode 100644 index 6b5bd3ca3a..0000000000 --- a/.changeset/rude-brooms-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-plugin-api': patch ---- - -Tweaked the logged deprecation warning for `createRouteRef` to hopefully make it more clear. diff --git a/.changeset/short-rockets-remain.md b/.changeset/short-rockets-remain.md new file mode 100644 index 0000000000..603caec71a --- /dev/null +++ b/.changeset/short-rockets-remain.md @@ -0,0 +1,9 @@ +--- +'@backstage/app-defaults': patch +'@backstage/core-app-api': minor +'@backstage/core-plugin-api': minor +--- + +- Removed deprecation configuration option `theme` from `AppTheme` of the `AppThemeApi` +- Removed reference to `theme` in the `app-defaults` default `AppTheme` +- Removed logic in `AppThemeProvider` that creates `ThemeProvider` from `appTheme.theme` diff --git a/.changeset/silver-books-clean.md b/.changeset/silver-books-clean.md new file mode 100644 index 0000000000..9685cc2119 --- /dev/null +++ b/.changeset/silver-books-clean.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Add support for passing paging parameters to the getEntities call of the catalog client diff --git a/.changeset/sixty-pandas-switch.md b/.changeset/sixty-pandas-switch.md new file mode 100644 index 0000000000..91e767e154 --- /dev/null +++ b/.changeset/sixty-pandas-switch.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-search-backend': minor +--- + +Search result location filtering + +This change introduces a filter for search results based on their location protocol. The intention is to filter out unsafe or +malicious values before they can be consumed by the frontend. By default locations must be http/https URLs (or paths). diff --git a/.changeset/tasty-deers-play.md b/.changeset/tasty-deers-play.md deleted file mode 100644 index cf5322b0bd..0000000000 --- a/.changeset/tasty-deers-play.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Expose catalog lib in plugin-auth-backend, i.e `CatalogIdentityClient` class is exposed now. diff --git a/.changeset/ten-candles-call.md b/.changeset/ten-candles-call.md new file mode 100644 index 0000000000..8907da06bd --- /dev/null +++ b/.changeset/ten-candles-call.md @@ -0,0 +1,9 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-scaffolder': patch +--- + +In @backstage/plugin-scaffolder - When user will have one option available in hostUrl or owner - autoselect and select component should be readonly. + +in @backstage/core-components - Select component has extended API with few more props: native : boolean, disabled: boolean. native - if set to true - Select component will use native browser select picker (not rendered by Material UI lib ). +disabled - if set to true - action on component will not be possible. diff --git a/.changeset/thick-deers-trade.md b/.changeset/thick-deers-trade.md new file mode 100644 index 0000000000..9b023d709a --- /dev/null +++ b/.changeset/thick-deers-trade.md @@ -0,0 +1,17 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-gitops-profiles': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-org': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-sonarqube': patch +--- + +Prefer using `Link` from `@backstage/core-components` rather than material-UI. diff --git a/.changeset/twenty-tigers-smash.md b/.changeset/twenty-tigers-smash.md new file mode 100644 index 0000000000..3ad694fbba --- /dev/null +++ b/.changeset/twenty-tigers-smash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build diff --git a/.changeset/twenty-tigers-ymash.md b/.changeset/twenty-tigers-ymash.md new file mode 100644 index 0000000000..c3e03ba75d --- /dev/null +++ b/.changeset/twenty-tigers-ymash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build diff --git a/.changeset/wise-melons-hope.md b/.changeset/wise-melons-hope.md new file mode 100644 index 0000000000..24a615a7bc --- /dev/null +++ b/.changeset/wise-melons-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Schema-validate local storage cached session info on load diff --git a/.changeset/witty-cats-tell.md b/.changeset/witty-cats-tell.md deleted file mode 100644 index 82e33bc0a6..0000000000 --- a/.changeset/witty-cats-tell.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Add group filtering to the scaffolder page so that individuals can surface specific templates to end users ahead of others, or group templates together. This can be accomplished by passing in a `groups` prop to the `ScaffolderPage` - -``` - - entity?.metadata?.tags?.includes('recommended') ?? false, - }, - ]} -/> -``` diff --git a/.changeset/young-bikes-argue.md b/.changeset/young-bikes-argue.md deleted file mode 100644 index a9f09cd12b..0000000000 --- a/.changeset/young-bikes-argue.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -**BREAKING** `DefaultCatalogCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: - -```diff -// packages/backend/src/plugins/search.ts - -... -export default async function createPlugin({ - ... -+ tokenManager, -}: PluginEnvironment) { - ... - - indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { - discovery, -+ tokenManager, - }), - }); - - ... -} -``` diff --git a/.changeset/young-dodos-bake.md b/.changeset/young-dodos-bake.md new file mode 100644 index 0000000000..f620c6a1a4 --- /dev/null +++ b/.changeset/young-dodos-bake.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Removed deprecated types `ApiRefType` and `ApiRefsToTypes` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 195abc6b11..9cc5a2378e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /docs/features/techdocs @backstage/techdocs-core /docs/features/search @backstage/techdocs-core /docs/assets/search @backstage/techdocs-core +/plugins/allure @backstage/reviewers @deepak-bhardwaj-ps +/plugins/circleci @backstage/reviewers @adamdmharvey /plugins/code-coverage @backstage/reviewers @alde @nissayeva /plugins/code-coverage-backend @backstage/reviewers @alde @nissayeva /plugins/cost-insights @backstage/silver-lining @@ -21,6 +23,13 @@ /plugins/azure-devops @backstage/reviewers @marleypowell @awanlin /plugins/azure-devops-backend @backstage/reviewers @marleypowell @awanlin /plugins/azure-devops-common @backstage/reviewers @marleypowell @awanlin +/plugins/jenkins @backstage/reviewers @timja +/plugins/jenkins-backend @backstage/reviewers @timja +/plugins/kafka @backstage/reviewers @nirga +/plugins/kafka-backend @backstage/reviewers @nirga +/plugins/scaffolder-backend-module-yeoman @backstage/reviewers @pawelmitka +/plugins/scaffolder-backend-module-rails @backstage/reviewers @angeliski +/plugins/git-release-manager @backstage/reviewers @erikengervall /tech-insights-backend @backstage/reviewers @xantier @iain-b /tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b /tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b @@ -33,3 +42,4 @@ /.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core /cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-core +/plugins/apache-airflow @backstage/reviewers @cmpadden diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..f141107482 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,24 @@ +catalog: + - plugins/catalog/**/* + - plugins/catalog-*/**/* + - packages/catalog-*/**/* +scaffolder: + - plugins/scaffolder/**/* + - plugins/scaffolder-*/**/* +search: + - plugins/search/**/* + - plugins/search-*/**/* + - packages/search-*/**/* +docs-like-code: + - plugins/techdocs/**/* + - plugins/techdocs-*/**/* + - packages/techdocs-*/**/* +documentation: + - docs/**/* +microsite: + - microsite/**/* +auth: + - plugins/auth-backend/**/* + - packages/core-app-api/src/apis/implementations/auth/**/* + - packages/core-app-api/src/lib/Auth*/**/* + - packages/core-plugin-api/src/apis/definitions/auth.ts diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 4ec62923d8..8199855c9e 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -12,6 +12,7 @@ Atlassian automations autoscaling Autoscaling +autoselect Avro backrub Bigtable @@ -53,6 +54,7 @@ configs const cookiecutter cron +cronjobs css Datadog dataflow @@ -90,10 +92,12 @@ Figma firehydrant FireHydrant Firekube +Firestore Fiverr gitbeaker GitHub GitLab +Gource Grafana graphql GraphQL @@ -187,6 +191,7 @@ OAuth octokit oidc Okta +Oldsberg onboarding Onboarding OpenShift @@ -194,6 +199,8 @@ orgs pagerduty pageview parallelization +Patrik +Peloton plantuml Platformize Podman @@ -293,6 +300,7 @@ truthy typeahead ui unbreak +Unconference unmanaged unregister unregistration @@ -304,6 +312,7 @@ utils validator validators varchar +Wayfair Weaveworks Webpack winston @@ -316,4 +325,3 @@ Zalando Zhou zoomable zsh -Firestore diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 70afd15eee..a649904a01 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -10,7 +10,7 @@ jobs: name: Create Changeset PR runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install Dependencies run: yarn --frozen-lockfile - name: Create Release Pull Request diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml index 9d054ff6df..3d0832f958 100644 --- a/.github/workflows/fossa.yml +++ b/.github/workflows/fossa.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v2 - name: Install Fossa run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash" diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 0000000000..dc7127ab0d --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,12 @@ +name: 'Pull Request Labeler' +on: + - pull_request_target + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v3 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + sync-labels: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 96a2a20109..5cbf4e32ef 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -24,7 +24,7 @@ jobs: recent activity from the author. It will be closed if no further activity occurs. If you are the author and the PR has been closed, feel free to re-open the PR and continue the contribution! days-before-pr-stale: 7 - days-before-pr-close: 3 + days-before-pr-close: 5 exempt-pr-labels: reviewer-approved,awaiting-review stale-pr-label: stale operations-per-run: 100 diff --git a/ADOPTERS.md b/ADOPTERS.md index c4c84d11be..43e7fba68b 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -5,7 +5,7 @@ | [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. | +| [SDA SE](https://sda.se) | [@dschwank](https://github.com/dschwank), [@iammnils](https://github.com/iammnils) | 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. | @@ -72,3 +72,6 @@ | [Globo](https://globo.com) | [Carlos Gusmรฃo](https://github.com/caeugusmao), [Guilherme Vierno](https://github.com/vierno), [Denis Aoki](https://github.com/dnsaoki2), [Maycon Dionisio](https://github.com/MayconDionisio), | Reduce the friction of accessing the information engineers need about Globo's digital services through a coherent and centralized experience. | | [QBE](https://www.qbe.com/) | [Daniel Steel](https://github.com/danielsteelqbe), [Pete Jespers](https://github.com/petejespersqbe) | Developer portal allowing our global teams to explore and create applications, documentation and cloud infrastructure easily and quickly ๐Ÿš€ | | [LogMeIn](https://www.logmein.com) | [Lorenzo Orsatti](https://github.com/lorsatti) | Improve onboarding experience of new developers. Discover faster and painlessly developer documentation, API definitions and team information. Provide useful dev metrics in a central place. Provide easy-to-use templates for new services. | +| [Telstra](https://www.telstra.com.au) | [@kiranpatel11](https://github.com/kiranpatel11), [JasonC](https://github.com/JasonC17) | Primary usage: software catalog and templates
Emerging usage : TechDocs, Explore Ecosystem, TechRadar, etc | +| [Mosaico](https://www.mosaico.com.br/) | [Wรฉdney Yuri](https://github.com/wedneyyuri),[@tino.milton](https://github.com/miltonjacomini) | A centralized service catalog of our documentation for our service engineers. | +| [Mox Bank](https://www.mox.com/) | [Nick Laqua](https://github.com/nick-laqua-dragon), [Gauthier Roebroeck](https://github.com/gauthier-roebroeck-mox) | "Single pane of glass" developer portal for providing a best-in-class developer experience to our product teams and making Mox the best tech environment in Hongkong ๐Ÿฅฐ๐Ÿš€ | diff --git a/README.md b/README.md index 1078631926..3e91cfe7c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ # [Backstage](https://backstage.io) +> ๐ŸŽ„ The maintainers will be taking a break over the holidays from week beginning 20th Dec. The Repository and Discord may be quieter than usual. We will be back next year, rested and restored, on Jan. 3. ๐ŸŽ„ + +> ๐ŸŽ… Happy holidays and a Happy New Year to all, and especially those that have made this year special for Backstage! ๐ŸŽ… + [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects) [![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22) diff --git a/app-config.yaml b/app-config.yaml index 913a72dbab..6cd05f44e8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -97,6 +97,11 @@ proxy: headers: Authorization: ${ILERT_AUTH_HEADER} + '/airflow': + target: https://your.airflow.instance.com/api/v1 + headers: + Authorization: ${AIRFLOW_BASIC_AUTH_HEADER} + organization: name: My Company @@ -277,24 +282,6 @@ scaffolder: # email: scaffolder@backstage.io # Use to customize the default commit message when new components are created # defaultCommitMessage: 'Initial commit' - github: - token: ${GITHUB_TOKEN} - visibility: public # or 'internal' or 'private' - gitlab: - api: - baseUrl: https://gitlab.com - token: ${GITLAB_TOKEN} - visibility: public # or 'internal' or 'private' - azure: - baseUrl: https://dev.azure.com/{your-organization} - api: - token: ${AZURE_TOKEN} - bitbucket: - api: - host: https://bitbucket.org - username: ${BITBUCKET_USERNAME} - token: ${BITBUCKET_TOKEN} - visibility: public # or or 'private' auth: ### Add auth.keyStore.provider to more granularly control how to store JWK data when running @@ -455,3 +442,6 @@ azureDevOps: host: dev.azure.com token: my-token organization: my-company + +apacheAirflow: + baseUrl: https://your.airflow.instance.com diff --git a/cypress/yarn.lock b/cypress/yarn.lock index baf0d92715..96bc765d0d 100644 --- a/cypress/yarn.lock +++ b/cypress/yarn.lock @@ -13,9 +13,9 @@ figures "^1.7.0" "@cypress/request@^2.88.5": - version "2.88.5" - resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7" - integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA== + version "2.88.10" + resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" + integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -24,19 +24,17 @@ extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" + http-signature "~1.3.6" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.19" - oauth-sign "~0.9.0" performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" tough-cookie "~2.5.0" tunnel-agent "^0.6.0" - uuid "^3.3.2" + uuid "^8.3.2" "@cypress/xvfb@^1.2.4": version "1.2.4" @@ -68,16 +66,6 @@ resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== -ajv@^6.12.3: - version "6.12.6" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" @@ -539,16 +527,6 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -fast-deep-equal@^3.1.1: - version "3.1.3" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -645,19 +623,6 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz#bc18864a6c9fc7b303f2e2abdb9155ad178fbe29" integrity sha512-kBBSQbz2K0Nyn+31j/w36fUfxkBW9/gfwRWdUY1ULReH3iokVJgddZAFcD1D0xlgTmFxJCbUkUclAlc6/IDJkw== -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -675,14 +640,14 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= +http-signature@~1.3.6: + version "1.3.6" + resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" + integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw== dependencies: assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" + jsprim "^2.0.2" + sshpk "^1.14.1" human-signals@^1.1.1: version "1.1.1" @@ -796,15 +761,10 @@ jsbn@~0.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -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" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stringify-safe@~5.0.1: version "5.0.1" @@ -820,14 +780,14 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= +jsprim@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" + integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== dependencies: assert-plus "1.0.0" extsprintf "1.3.0" - json-schema "0.2.3" + json-schema "0.4.0" verror "1.10.0" lazy-ass@^1.6.0: @@ -985,11 +945,6 @@ number-is-nan@^1.0.0: resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - object-assign@^4.1.0: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -1084,7 +1039,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -1191,7 +1146,7 @@ slice-ansi@0.0.4: resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= -sshpk@^1.7.0: +sshpk@^1.14.1: version "1.16.1" resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== @@ -1353,13 +1308,6 @@ untildify@^4.0.0: resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - url@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -1373,10 +1321,10 @@ util-deprecate@~1.0.1: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== verror@1.10.0: version "1.10.0" diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md index aca7e42d96..04c3da92bc 100644 --- a/docs/api/deprecations.md +++ b/docs/api/deprecations.md @@ -61,3 +61,125 @@ breaking change to make `theme` optional. This means that if you currently construct the themes that you pass on to `createApp` using `AppTheme` as an intermediate type, you will need to work around this in some way, for example by passing the themes to `createApp` more directly. + +### Generic Auth API Refs + +`Released 2021-12-16 in @backstage/core-plugin-api v0.3.1` + +There are four auth Utility API references in `@backstage/core-plugin-api` that +were too generic to be useful. The APIs in question are `auth0AuthApiRef`, +`oauth2ApiRef`, `oidcAuthApiRef`, and `samlAuthApiRef`. The issue with these +APIs was that they had no actual contract of what the backing auth provider was. +This made it more or less impossible to use these providers in open source +plugins in any meaningful way. We also did not want to keep these Utility API +references around just as helpers either, instead opting to remove them and let +integrators define their own APIs that are more specific to their auth provider. +This is also falls in line with a long-term goal to unify all auth providers to +not have separate frontend implementations. + +If you're currently using one of these API references for either Sign-In or +access delegation within an app, there are a couple of steps you need to take to +migrate to your own custom API. + +First, you'll need to define a new Utility API reference. If you're only using +the API for sign-in, you can put the definition in `packages/app/src/apis.ts`. +However, if you need to access your auth API inside plugins you you'll need to +export it from a common package. If you don't already have one we recommended +creating `@internal/apis` and from there export the API reference. + +```ts +// `ProfileInfoApi & BackstageIdentityApi & SessionApi` are required for sign-in +// Include `OAuthApi & OpenIdConnectApi` only if applicable +export const acmeAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'internal.auth.acme', +}); +``` + +Next you'll want to wire up the API inside `packages/app/src/apis.ts`, which +varies depending on which API you're replacing. If you for example are replacing +the `oauth2ApiRef`, the factory might look like this: + +```ts +// oauth2 +createApiFactory({ + api: acmeAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), +}); +``` + +Provider specific factory implementations, copy the code you need into the +factory method depending on which apiRef you previously used. + +```ts +// samlAuthApiRef +SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), +}); + +// oidcAuthApiRef +OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: () => null, + }, + environment: configApi.getOptionalString('auth.environment'), +}); + +// auth0AuthApiRef +OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'auth0', + title: 'Auth0', + icon: () => null, + }, + defaultScopes: ['openid', 'email', 'profile'], + environment: configApi.getOptionalString('auth.environment'), +}); +``` + +Finally, for the provider to show up in your settings menu, you also need to +update the settings route in `packages/app/src/App.tsx` to pass the +`acmeAuthApiRef` to the `UserSettingsPage`. This replaces all existing provider +items, so you might want to add back any of the default ones that you are using +from the +[DefaultProviderSettings](https://github.com/backstage/backstage/blob/a3ec122170e0205fd3f9c307b98b1c5e4f55bf5f/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx#L35). + +```tsx + + } + /> + } +/> +``` diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 133653df44..9ebcfc0945 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -128,8 +128,8 @@ the core APIs. The core APIs are the ones exported by [configApiRef](../reference/core-plugin-api.configapiref.md). The core APIs are loaded for any app created with -[createApp](../reference/core-app-api.createapp.md) from -[@backstage/core-plugin-api](../reference/core-plugin-api.md), which means that +[createApp](../reference/app-defaults.createapp.md) from +[@backstage/core-plugin-api](../reference/app-defaults.md), which means that there is no step that needs to be taken to include these APIs in an app. ### Plugin APIs @@ -168,7 +168,7 @@ Lastly, the app itself is the final point where APIs can be added, and what has the final say in what APIs will be loaded at runtime. The app may override the factories for any of the core or plugin APIs, with the exception of the config, app theme, and identity APIs. These are static APIs that are tied into the -[createApp](../reference/core-app-api.createapp.md) implementation, and +[createApp](../reference/app-defaults.createapp.md) implementation, and therefore not possible to override. Overriding APIs is useful for apps that want to switch out behavior to tailor it @@ -291,7 +291,7 @@ The figure below shows the relationship between fooApiRef.
-Figure showing the relationship between utility APIs, the apps that provide them, and the plugins that consume them +Figure showing the relationship between utility APIs, the apps that provide them, and the plugins that consume them
The current method for connecting Utility API providers and consumers is via the @@ -313,7 +313,7 @@ The common development environment for plugins is included in [createDevApp](../reference/dev-utils.createdevapp.md) function creates an application with implementations for all core APIs already present. Contrary to the method for wiring up Utility API implementations in an app created with -[createApp](../reference/core-app-api.createapp.md), +[createApp](../reference/app-defaults.createapp.md), [createDevApp](../reference/dev-utils.createdevapp.md) uses automatic dependency injection. This is to make it possible to replace any API implementation, and having that be reflected in dependents of that API. diff --git a/docs/assets/techdocs/architecture-recommended.drawio.svg b/docs/assets/techdocs/architecture-recommended.drawio.svg index e3af4b6b5f..1768b5dc86 100644 --- a/docs/assets/techdocs/architecture-recommended.drawio.svg +++ b/docs/assets/techdocs/architecture-recommended.drawio.svg @@ -1,4 +1,4 @@ - + @@ -7,7 +7,7 @@ - + @@ -67,8 +67,8 @@ - - + + @@ -105,7 +105,7 @@ - + @@ -124,7 +124,7 @@ - + @@ -232,34 +232,17 @@
- TechDocs plugin + TechDocs Plugin
- TechDocs plugin + TechDocs Plugin - - - - -
-
-
- TechDocs Backend plugin -
-
-
-
- - TechDocs Backend plu... - -
-
- + @@ -268,21 +251,23 @@
- Request TechDocs site + + Request TechDocs Site +
- Request TechDocs site + Request TechDocs Site
- + -
+
Fetch files to render @@ -290,7 +275,7 @@
- + Fetch files to render @@ -302,15 +287,15 @@ - + - + -
+
Source code hosting @@ -323,28 +308,9 @@ - - - - - -
-
-
- Caching -
- (Optional) -
-
-
-
- - Caching... - -
-
- - + + + @@ -379,10 +345,132 @@ + + + + +
+
+
+ Cache Store +
+ (Optional) +
+
+
+
+ + Cache Store... + +
+
+ + + + +
+
+
+ Read/Write +
+ Objects +
+
+
+
+ + Read/Write... + +
+
+ + + + + + + +
+
+
+ + + Memcache + + +
+
+
+
+ + Memcache + +
+
+ + + + + +
+
+
+ + TechDocs Service + +
+
+
+
+ + TechDocs Service + +
+
+ + + + +
+
+
+ + Cache Middleware +
+ (Optional) +
+
+
+
+
+ + Cache Middleware... + +
+
+ + + + + + +
+
+
+ TechDocs Backend Plugin +
+
+
+
+ + TechDocs Backend Plugin + +
+
- + Viewer does not support full SVG 1.1 diff --git a/docs/auth/index.md b/docs/auth/index.md index 0c03e33900..4a8d225048 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -16,8 +16,10 @@ Backstage identity information in your app or plugins. Backstage comes with many common authentication providers in the core library: +- [Atlassian](atlassian/provider.md) - [Auth0](auth0/provider.md) - [Azure](microsoft/provider.md) +- [Bitbucket](bitbucket/provider.md) - [GitHub](github/provider.md) - [GitLab](gitlab/provider.md) - [Google](google/provider.md) diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 1e24235f1a..ef79127223 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -48,6 +48,6 @@ The Microsoft provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend -To add the provider to the frontend, add the `microsoftAuthApi` reference and +To add the provider to the frontend, add the `microsoftAuthApiRef` reference and `SignInPage` component as shown in [Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 7945d6c980..a4da1e3df0 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -67,7 +67,7 @@ production build. ## Configuration Files -It is possible to have multiple configuration files (bundled and/or remote), +It is possible to have multiple configuration files (bundled and/or remote\*), both to support different environments, but also to define configuration that is local to specific packages. The configuration files to load are selected using a `--config ` flag, and it is possible to load any number of @@ -77,6 +77,9 @@ root when running the backend, you would use `--config ../../my-config.yaml`, and for config file on a config server you would use `--config https://some.domain.io/app-config.yaml` +**Note**: In case URLs are passed, it is also needed to set the remote option in +the loadBackendConfig call. + If no `config` flags are specified, the default behavior is to load `app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root. In the provided project setup, `app-config.local.yaml` is `.gitignore`'d, making diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 7cbd3cea89..6f58abe769 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -7,9 +7,9 @@ description: How to build a Backstage Docker image for deployment This section describes how to build a Backstage App into a deployable Docker image. It is split into three sections, first covering the host build approach, -which is recommended due its speed and more efficient and often simpler caching. -The second section covers a full multi-stage Docker build, and the last section -covers how to deploy the frontend and backend as separate images. +which is recommended due to its speed and more efficient and often simpler +caching. The second section covers a full multi-stage Docker build, and the last +section covers how to deploy the frontend and backend as separate images. Something that goes for all of these docker deployment strategies is that they are stateless, so for a production deployment you will want to set up and diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 6b3991144b..32ab830322 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -26,6 +26,7 @@ kubernetes: name: minikube authProvider: 'serviceAccount' skipTLSVerify: false + skipMetricsLookup: true serviceAccountToken: ${K8S_MINIKUBE_TOKEN} dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard dashboardApp: standard @@ -37,6 +38,7 @@ kubernetes: projectId: 'gke-clusters' region: 'europe-west1' skipTLSVerify: true + skipMetricsLookup: true ``` ### `serviceLocatorMethod` @@ -86,8 +88,13 @@ cluster. Valid values are: ##### `clusters.\*.skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `clusters.\*.skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ##### `clusters.\*.serviceAccountToken` (optional) @@ -188,8 +195,13 @@ regions. ##### `skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ### `customResources` (optional) @@ -219,6 +231,26 @@ The custom resource's apiVersion. The plural representing the custom resource. +### `apiVersionOverrides` (optional) + +Overrides for the API versions used to make requests for the corresponding +objects. If using a legacy Kubernetes version, you may use this config to +override the default API versions to ones that are supported by your cluster. + +Example: + +```yaml +--- +kubernetes: + apiVersionOverrides: + cronjobs: 'v1beta1' +``` + +For more information on which API versions are supported by your cluster, please +view the Kubernetes API docs for your Kubernetes version (e.g. +[API Groups for v1.22](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#-strong-api-groups-strong-) +) + ### Role Based Access Control The current RBAC permissions required are read-only cluster wide, for the @@ -232,6 +264,12 @@ following objects: - horizontalpodautoscalers - ingresses +The following RBAC permissions are required on the batch API group for the +following objects: + +- jobs +- cronjobs + ## Surfacing your Kubernetes components as part of an entity There are two ways to surface your Kubernetes components as part of an entity. diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 7c3717e22e..285e399cb4 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -114,13 +114,15 @@ const routes = ( In `Root.tsx`, add the `SidebarSearchModal` component: ```bash -import { SidebarSearchModal } from '@backstage/plugin-search'; +import { SidebarSearchModal, SearchContextProvider } from '@backstage/plugin-search'; export const Root = ({ children }: PropsWithChildren<{}>) => ( - + + + ... ``` diff --git a/docs/features/search/search-engines.md b/docs/features/search/search-engines.md index a0302ce279..8342c74096 100644 --- a/docs/features/search/search-engines.md +++ b/docs/features/search/search-engines.md @@ -4,9 +4,9 @@ title: Search Engines description: Choosing and configuring your search engine for Backstage --- -Backstage supports 2 search engines by default, an in-memory engine called Lunr -and ElasticSearch. You can configure your own search engines by implementing the -provided interface as mentioned in the +Backstage supports 3 search engines by default, an in-memory engine called Lunr, +ElasticSearch and Postgres. You can configure your own search engines by +implementing the provided interface as mentioned in the [search backend documentation.](./getting-started.md#Backend) Provided search engine implementations have their own way of constructing @@ -90,6 +90,18 @@ within your instance. The configuration options are documented in the The underlying functionality is using official ElasticSearch client version 7.x, meaning that ElasticSearch version 7 is the only one confirmed to be supported. +Should you need to create your own bespoke search experiences that require more +than just a query translator (such as faceted search or Relay pagination), you +can access the configuration of the search engine in order to create new elastic +search clients. The version of the client need not be the same as one used +internally by the elastic search engine plugin. For example: + +```typescript +import { Client } from '@elastic/elastic-search'; + +const client = searchEngine.newClient(options => new Client(options)); +``` + ## Example configurations ### AWS diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 2cdf6f1b35..ed7a103bc4 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -11,13 +11,13 @@ would be good to also have some files in there that can be templated in. A simple `template.yaml` definition might look something like this: ```yaml -apiVersion: backstage.io/v1beta2 +apiVersion: scaffolder.backstage.io/v1beta3 kind: Template # some metadata about the template itself metadata: - name: v1beta2-demo + name: v1beta3-demo title: Test Action template - description: scaffolder v1beta2 template demo + description: scaffolder v1beta3 template demo spec: owner: backstage/techdocs-core type: service @@ -55,7 +55,7 @@ spec: input: url: ./template values: - name: '{{ parameters.name }}' + name: ${{ parameters.name }} - id: fetch-docs name: Fetch Docs @@ -69,14 +69,14 @@ spec: action: publish:github input: allowedHosts: ['github.com'] - description: 'This is {{ parameters.name }}' - repoUrl: '{{ parameters.repoUrl }}' + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} - id: register name: Register action: catalog:register input: - repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} catalogInfoPath: '/catalog-info.yaml' ``` diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md index b57bb96818..d22b82bc16 100644 --- a/docs/features/software-templates/configuration.md +++ b/docs/features/software-templates/configuration.md @@ -18,17 +18,31 @@ The next step is to add [add templates](http://backstage.io/docs/features/software-templates/adding-templates) to your Backstage app. -### GitHub +### Publishing defaults -For GitHub, you can configure who can see the new repositories that are created -by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. The `internal` option is for GitHub Enterprise clients, which means -public within the enterprise. +Software templates can define _publish_ actions, such as `publish:github`, to +create new repositories or submit pull / merge requests to existing +repositories. You can configure the author and commit message through the +`scaffolder` configuration in `app-config.yaml`: ```yaml scaffolder: - github: - visibility: public # or 'internal' or 'private' + defaultAuthor: + name: M.C. Hammer # Defaults to `Scaffolder` + email: hammer@donthurtem.com # Defaults to `scaffolder@backstage.io` + defaultCommitMessage: "U can't touch this" # Defaults to 'Initial commit' +``` + +To configure who can see the new repositories created from software templates, +add the `repoVisibility` key within a software template: + +```yaml +- id: publish + name: Publish + action: publish:github + input: + repoUrl: '{{ parameters.repoUrl }}' + repoVisibility: public # or 'internal' or 'private' ``` ### Disabling Docker in Docker situation (Optional) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 1434d62a29..a854543acb 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -12,7 +12,7 @@ code, template in some variables, and then publish the template to some locations like GitHub or GitLab. ### Getting Started diff --git a/docs/features/software-templates/writing-custom-field-extensions.md b/docs/features/software-templates/writing-custom-field-extensions.md index ebb7af4398..fab2e1e2cb 100644 --- a/docs/features/software-templates/writing-custom-field-extensions.md +++ b/docs/features/software-templates/writing-custom-field-extensions.md @@ -26,7 +26,7 @@ You can create your own Field Extension by using the `API` like below: ```tsx -//packages/app/scaffolder/MyCustomExtension/MyCustomExtension.tsx +//packages/app/src/scaffolder/MyCustomExtension/MyCustomExtension.tsx import React from 'react'; import { FieldProps, FieldValidation } from '@rjsf/core'; import FormControl from '@material-ui/core/FormControl'; @@ -68,7 +68,7 @@ export const myCustomValidation = ( ``` ```tsx -// packages/app/scaffolder/MyCustomExtension/extensions.ts +// packages/app/src/scaffolder/MyCustomExtension/extensions.ts /* This is where the magic happens and creates the custom field extension. @@ -94,7 +94,7 @@ export const MyCustomFieldExtension = plugin.provide( ``` ```tsx -// packages/app/scaffolder/MyCustomExtension/index.ts +// packages/app/src/scaffolder/MyCustomExtension/index.ts export { MyCustomFieldExtension } from './extension'; ``` @@ -102,7 +102,7 @@ export { MyCustomFieldExtension } from './extension'; Once all these files are in place, you then need to provide your custom extension to the `scaffolder` plugin. -You do this in `packages/app/App.tsx`. You need to provide the +You do this in `packages/app/src/App.tsx`. You need to provide the `customFieldExtensions` as children to the `ScaffolderPage`. ```tsx @@ -118,7 +118,7 @@ const routes = ( Should look something like this instead: ```tsx -import { MyCustomFieldExtension } from './scafffolder/MyCustomExtension'; +import { MyCustomFieldExtension } from './scaffolder/MyCustomExtension'; const routes = ( ... diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 81f2622312..a1dffe1580 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -6,21 +6,21 @@ description: Details around creating your own custom Software Templates Templates are stored in the **Software Catalog** under a kind `Template`. You can create your own templates with a small `yaml` definition which describes the -template and it's metadata, along with some input variables that your template +template and its metadata, along with some input variables that your template will need, and then a list of actions which are then executed by the scaffolding service. Let's take a look at a simple example: ```yaml -# Notice the v1beta2 version -apiVersion: backstage.io/v1beta2 +# Notice the v1beta3 version +apiVersion: scaffolder.backstage.io/v1beta3 kind: Template # some metadata about the template itself metadata: - name: v1beta2-demo + name: v1beta3-demo title: Test Action template - description: scaffolder v1beta2 template demo + description: scaffolder v1beta3 template demo spec: owner: backstage/techdocs-core type: service @@ -66,8 +66,8 @@ spec: input: url: ./template values: - name: '{{ parameters.name }}' - owner: '{{ parameters.owner }}' + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} - id: fetch-docs name: Fetch Docs @@ -81,20 +81,20 @@ spec: action: publish:github input: allowedHosts: ['github.com'] - description: 'This is {{ parameters.name }}' - repoUrl: '{{ parameters.repoUrl }}' + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} - id: register name: Register action: catalog:register input: - repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} catalogInfoPath: '/catalog-info.yaml' # some outputs which are saved along with the job for use in the frontend output: - remoteUrl: '{{ steps.publish.output.remoteUrl }}' - entityRef: '{{ steps.register.output.entityRef }}' + remoteUrl: ${{ steps.publish.output.remoteUrl }} + entityRef: ${{ steps.register.output.entityRef }} ``` Let's dive in and pick apart what each of these sections do and what they are. @@ -183,12 +183,12 @@ this: It would look something like the following in a template: ```yaml -apiVersion: backstage.io/v1beta2 +apiVersion: scaffolder.backstage.io/v1beta3 kind: Template metadata: - name: v1beta2-demo + name: v1beta3-demo title: Test Action template - description: scaffolder v1beta2 template demo + description: scaffolder v1beta3 template demo spec: owner: backstage/techdocs-core type: service @@ -315,12 +315,12 @@ template. These follow the same standard format: ```yaml - id: fetch-base # A unique id for the step name: Fetch Base # A title displayed in the frontend - if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy + if: ${{ parameters.name }} # Optional condition, skip the step if not truthy action: fetch:template # An action to call input: # Input that is passed as arguments to the action handler url: ./template values: - name: '{{ parameters.name }}' + name: ${{ parameters.name }} ``` By default we ship some [built in actions](./builtin-actions.md) that you can @@ -338,22 +338,19 @@ The main two that are used are the following: ```yaml output: - remoteUrl: '{{ steps.publish.output.remoteUrl }}' # link to the remote repository - entityRef: '{{ steps.register.output.entityRef }}' # link to the entity that has been ingested to the catalog + remoteUrl: ${{ steps.publish.output.remoteUrl }} # link to the remote repository + entityRef: ${{ steps.register.output.entityRef }} # link to the entity that has been ingested to the catalog ``` ### The templating syntax -You might have noticed variables wrapped in `{{ }}` in the examples. These are -`handlebars` template strings for linking and gluing the different parts of the -template together. All the form inputs from the `parameters` section will be -available by using this template syntax (for example, -`{{ parameters.firstName }}` inserts the value of `firstName` from the -parameters). This is great for passing the values from the form into different -steps and reusing these input variables. To pass arrays or objects use the -`json` custom [helper](https://handlebarsjs.com/guide/expressions.html#helpers). -For example, `{{ json parameters.nicknames }}` will insert the result of calling -`JSON.stringify` on the value of the `nicknames` parameter. +You might have noticed variables wrapped in `${{ }}` in the examples. These are +template strings for linking and gluing the different parts of the template +together. All the form inputs from the `parameters` section will be available by +using this template syntax (for example, `${{ parameters.firstName }}` inserts +the value of `firstName` from the parameters). This is great for passing the +values from the form into different steps and reusing these input variables. +These template strings preserve the type of the parameter. As you can see above in the `Outputs` section, `actions` and `steps` can also output things. You can grab that output using `steps.$stepId.output.$property`. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 8d25b4047f..23af83d09b 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -40,7 +40,7 @@ storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in ## Recommended deployment -This is how we recommend deploying TechDocs in production environment. +This is how we recommend deploying TechDocs in a production environment. TechDocs Architecture diagram @@ -58,12 +58,12 @@ Similar to how it is done in the Basic setup, the TechDocs Reader requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -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._ +Depending on your chosen cloud storage provider and its real-world proximity to +your backend server, there may be a comparably high amount of latency when +loading TechDocs sites using this deployment approach. If you encounter this, +you can optionally configure the `techdocs-backend` to cache responses in a +cache store +[supported by Backstage](../../overview/architecture-overview.md#cache). ### Security consideration diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md index 5548adf7f0..30cb8314a0 100644 --- a/docs/features/techdocs/cli.md +++ b/docs/features/techdocs/cli.md @@ -1,4 +1,9 @@ -# TechDocs CLI +--- +id: cli +title: TechDocs CLI +# prettier-ignore +description: TechDocs CLI - a utility command line interface for managing TechDocs sites in Backstage. +--- Utility command line interface for managing TechDocs sites in [Backstage](https://github.com/backstage/backstage). diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 4148749a62..6317ae365b 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -135,6 +135,22 @@ techdocs: # the old, case-sensitive entity triplet behavior. legacyUseCaseSensitiveTripletPaths: false + # techdocs.cache is optional, and is only recommended when you've configured + # an external techdocs.publisher.type above. Also requires backend.cache to + # be configured with a valid cache store. + cache: + # Represents the number of milliseconds a statically built asset should + # stay cached. Cache invalidation is handled automatically by the frontend, + # which compares the build times in cached metadata vs. canonical storage, + # allowing long TTLs (e.g. 1 month/year) + ttl: 3600000 + + # (Optional) The time (in milliseconds) that the TechDocs backend will wait + # for a cache service to respond before continuing on as though the cached + # object was not found (e.g. when the cache sercice is unavailable). The + # default value is 1000 + readTimeout: 500 + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 241ae7ef03..a4ca0771df 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -54,7 +54,7 @@ For example, adding the theme that we created in the previous section can be done like this: ```ts -import { createApp } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; const app = createApp({ apis: ..., diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index e5d15d5845..1d65f25d1b 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -36,7 +36,7 @@ This will create a new Backstage App inside the current folder. The name of the app-folder is the name that was provided when prompted.

- create app + create app

Inside that directory, it will generate all the files and folder structure diff --git a/docs/getting-started/project-structure.md b/docs/getting-started/project-structure.md index 687743f99d..a0390571b8 100644 --- a/docs/getting-started/project-structure.md +++ b/docs/getting-started/project-structure.md @@ -166,13 +166,9 @@ are separated out into their own folder, see further down. plugin and [techdocs-cli](https://github.com/backstage/techdocs-cli). - [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) - - This package contains more general purpose testing facilities for testing a + This package contains general purpose testing facilities for testing a Backstage App or its plugins. -- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) - - This package contains specific testing facilities used when testing Backstage - core internals. - - [`theme/`](https://github.com/backstage/backstage/tree/master/packages/theme) - Holds the Backstage Theme. diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 18fdef892a..318b37f34e 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -165,6 +165,7 @@ common example being the `migrations` directory. Usage: backstage-cli backend:build [options] Options: + --minify Minify the generated code -h, --help display help for command ``` @@ -371,6 +372,7 @@ the monorepo. Usage: backstage-cli plugin:build [options] Options: + --minify Minify the generated code -h, --help display help for command ``` diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index be02ba8f07..a79989412d 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -175,15 +175,6 @@ 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-app-api and @backstage/core-plugin-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. diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index 33568ac561..05af512f92 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -368,7 +368,7 @@ The following is an example of creation and usage of a parameterized route: ```tsx // Creation of a parameterized route const myRouteRef = createRouteRef({ - title: 'My Named Route', + id: 'myroute', params: ['name'] }) diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 249507ddf0..126bb5ae0d 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -48,7 +48,6 @@ that implements the `OAuthApi` type, it's now working in the frontend too. ```ts const spotifyAuthApiRef = createApiRef({ id: 'core.auth.spotify', - description: 'Provides authentication towards Spotify APIs', }); ``` diff --git a/docs/tutorials/using-backstage-proxy-within-plugin.md b/docs/tutorials/using-backstage-proxy-within-plugin.md index b0c6bef8ef..771aa7a652 100644 --- a/docs/tutorials/using-backstage-proxy-within-plugin.md +++ b/docs/tutorials/using-backstage-proxy-within-plugin.md @@ -99,7 +99,6 @@ export interface MyAwesomeApi { export const myAwesomeApiRef = createApiRef({ id: 'plugin.my-awesome-api.service', - description: 'Example API definition', }); ``` 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 afda20e499..0bf8d7e581 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -7,7 +7,7 @@ authorURL: https://twitter.com/stalund **TL;DR** Today we are announcing a new Backstage feature: Software Templates. Simplify setup, standardize tooling, and deploy with the click of a button. Using automated templates, your engineers can spin up a new microservice, website, or other software component with your organizationโ€™s best practices built-in, right from the start. diff --git a/microsite/blog/2021-12-20-backstage-wrapped-2021.md b/microsite/blog/2021-12-20-backstage-wrapped-2021.md new file mode 100644 index 0000000000..2a6234a9f6 --- /dev/null +++ b/microsite/blog/2021-12-20-backstage-wrapped-2021.md @@ -0,0 +1,145 @@ +--- +title: Backstage Wrapped 2021 +author: Lee Mills, Spotify +authorURL: https://github.com/leemills83 +authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4 +--- + +![Backstage in 2021: 75+ public adopters, 500+ contributors, almost 2,000 forks, 9,000+ contributions, 4,000+ Discord members](assets/21-12-20/backstage-wrapped-2021.png) + +What a year 2021 has been for the Backstage project and community. As the year comes to a close, we can look back at some of the happenings, the successes, and welcome all the newcomers to our growing community. + +In a year that has had its fair share of ups and downs, this community has been something that has brought me so much in the support you provide each other, your eagerness, passion, and willingness to share. Thank you to all of you for creating such a great community, and welcome to everyone joining us. I canโ€™t wait to meet you all and see what great things we can create together in the new year. + +In 2021 weโ€™ve seen the project and community grow in every dimension, reaching over 75 public adopters, over 500 contributors from around the world all bringing new ideas and perspectives. Weโ€™ve had so many of you sharing and demoing your hard work in our monthly community sessions, added new plugins, built new features and really started to see Backstage have an impact for engineering teams across so many of our users. + +Now letโ€™s hear from the maintainers and look at the stats! + + + +## The community grewโ€ฆand grew + +This year, Netflix, [Expedia][metrics], Wayfair, Peloton, [Box][10], [DAZN][dazn], TELUS, and Unity all officially joined the community โ€” along with more than 50 other public [adopters][md]. Contributors grew by leaps and bounds, too. + +Patrik Oldsberg ([@Rugvip][rugvip]): + +> Itโ€™s been awesome to see the engagement by the community grow even more in 2021, especially the way people within the community support each other. Thanks to our growing number of stable, experienced, and knowledgeable contributors itโ€™s now possible to get help in places like Discord anytime, day or night. + +### More adopters, more contributors + +![Accelerated growth of Public Backstage Adopters, from July 2020โ€“December 2021.](assets/21-12-20/public-backstage-adopters-2021.png) + +In January, Backstage had 17 public adopters. By September, [we had 50][50]. And the curve only got steeper from there. + +- **75+ public adopters** +- **500+ contributors** +- **4,000+ Discord members** +- **9,000+ PRs & issues** + +### A very busy repo + +Along with the growth in contributors, PRs, and issues, our active, thriving community has crossed (or is just about to cross) other big milestones on GitHub, too. + +- **Nearly 2,000 forks** +- **20,000+ commits** +- **14,000+ GitHub stars** + +### Where in the world is the Backstage community? + +Watch the visualization below to see where in the world all those contributions came from this past year. + + + +### What does 9,000 contributions look like? + + + +Can you spot your name in the video above? + +Picking up from the end of 2020, the video shows every single contribution to the Backstage source code over this past year. The visualization was built using [Gource.io][gource]: + +> Software projects are displayed by Gource as an animated tree with the root directory of the project at its centre. Directories appear as branches with files as leaves. Developers can be seen working on the tree at the times they contributed to the project. + +And if your name isnโ€™t in there, itโ€™s never too late to get started [contributing][contributing] โ€” so that you too can become a bright, sparkling, laser-beam-shooting spider-node thing in 2022! + +## Other 2021 milestones + +As the community grew, so did the product โ€” with the addition of new features as well as important performance and stability improvements to the core. + +Ben Lambert ([@benjdlambert][blam]): + +> Weโ€™ve had a lot of priorities this year. Our first priority was rebuilding the catalog to make it more efficient. Then, toward the later part of the year, weโ€™ve been stabilizing the core with deprecations and API changes ahead of our 1.0 release of the Core Framework next year. +> +> Itโ€™s been a super productive year for us all as Core Maintainers, and the product has come on leaps and bounds with the help of everyone involved in the project! + +### New features + +These new features were built with the community, and made with customization and extensibility in mind. + +- **Kubernetes Service Monitoring ([RFC][k8s])** +- **Search Platform ([Alpha][search])** +- **Composable Homepage ([README][home])** +- **Authorization Framework ([RFC][auth])** + +### Major stability updates + +As we get the ball rolling for [CNCF Incubation][cncf], weโ€™ve been busy updating, refining, documenting, deprecating, and stabilizing the core. + +- **Software Templates ([Beta][scaffolder])** +- **TechDocs ([Beta][techdocs])** +- **API Reference ([docs][api])** + +### 50+ open source plugins + +We reached [50+ plugins][plugins] built by the community this year, including official plugins (**Snyk**, **FireHydrant**), highly requested plugins (**PagerDuty**, **Prometheus**), and plugins based on new ideas and functionality (**XCMetrics**, **[Tech Insights][techrfc]**) โ€” all tailor-made for Backstage. + +### In the spotlight + +This year Backstage was center stage at both community-organized events and at major conferences. + +- **New (and [expanded][09]) Community Sessions** +- **Backstage Users Unconference + [Open Mic][openmic]** +- **KubeCon + CloudNativeCon [Europe][europe] & [North America][na]** +- **GitHub Universe 2021 [Keynote][day2]** + +Find more talks, podcasts, blog posts, and Backstage in the news [here][news]. + +## Goodbye, 2021. Hello, 2022! + +Lastly, thanks to everyone in the global Backstage community who contributed to the open source project in 2021. + +This was the first year Spotify hosted [Backstage Community Sessions][meetups], and itโ€™s been a great source of inspiration, motivation, and joy getting to know all the people behind the contributions. We look forward to seeing all your faces again in 2022 โ€” and hopefully many new ones as well. + +Until then, [grab this Backstage Zoom background][zoom] to use at the next Community Session. + +![Backstage Zoom Background](assets/21-12-20/Backstage-background-02-2021.png) + +See you then! Happy holidays from everyone on the Backstage team at Spotify! + +[metrics]: https://backstage.spotify.com/blog/measuring-backstage-proof-of-value-at-expedia/ +[10]: https://backstage.spotify.com/blog/community-session/10-box-and-spotify-paid-plugins/ +[dazn]: https://backstage.spotify.com/blog/adopter-spotlight/dazn/ +[md]: https://github.com/backstage/backstage/blob/master/ADOPTERS.md +[rugvip]: https://github.com/Rugvip +[50]: https://backstage.io/blog/2021/09/30/50-public-adopters +[gource]: https://gource.io +[contributing]: https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md +[blam]: https://github.com/benjdlambert +[k8s]: https://github.com/backstage/backstage/issues/2857 +[search]: https://backstage.io/blog/2021/06/24/announcing-backstage-search-platform +[home]: https://github.com/backstage/backstage/blob/master/plugins/home/README.md +[auth]: https://backstage.spotify.com/blog/authorization-framework-rfc/ +[scaffolder]: https://backstage.io/blog/2021/07/26/software-templates-are-now-in-beta +[techdocs]: https://backstage.io/blog/2021/09/16/the-techdocs-beta-has-landed +[api]: https://backstage.io/docs/reference/ +[cncf]: https://github.com/cncf/toc/pull/717 +[plugins]: https://backstage.io/plugins +[techrfc]: https://github.com/backstage/backstage/issues/7576 +[09]: https://backstage.spotify.com/blog/community-session/09-prometheus-homepage-templates/ +[openmic]: https://backstage-openmic.com +[europe]: https://www.youtube.com/watch?v=aIURaocR5D8 +[na]: https://backstage.spotify.com/blog/kubecon-na-2021/ +[day2]: https://www.youtube.com/watch?v=7yQJlYvpoYs&t=269s +[news]: https://github.com/backstage/community#newsletters +[meetups]: https://github.com/backstage/community#meetups +[zoom]: https://drive.google.com/file/d/14wOPXEBZ5Qmi29ms4Sqp1m56dM0AIHbB/view?usp=sharing diff --git a/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png b/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png new file mode 100644 index 0000000000..bf3c72ba70 Binary files /dev/null and b/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png differ diff --git a/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png b/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png new file mode 100644 index 0000000000..6ac5d5f67c Binary files /dev/null and b/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png differ diff --git a/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png b/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png new file mode 100644 index 0000000000..aac981d5f9 Binary files /dev/null and b/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png differ diff --git a/microsite/package.json b/microsite/package.json index a24de8935d..0a90cb2ec5 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -19,7 +19,7 @@ "@spotify/prettier-config": "^12.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.5.0", + "prettier": "^2.5.1", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 9946f4914d..b4d97d549d 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -5190,10 +5190,10 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.5.0: - version "2.5.0" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893" - integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg== +prettier@^2.5.1: + version "2.5.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" + integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== prismjs@^1.22.0: version "1.25.0" diff --git a/package.json b/package.json index 9adbb1c821..57cf603090 100644 --- a/package.json +++ b/package.json @@ -55,15 +55,16 @@ }, "version": "1.0.0", "dependencies": { - "@microsoft/api-documenter": "^7.13.68", - "@microsoft/api-extractor": "^7.18.7", - "@microsoft/api-extractor-model": "^7.13.5", + "@microsoft/api-documenter": "^7.13.77", + "@microsoft/api-extractor": "^7.19.2", + "@microsoft/api-extractor-model": "^7.15.1", "@microsoft/tsdoc": "^0.13.2" }, "devDependencies": { "@changesets/cli": "^2.14.0", "@octokit/rest": "^18.12.0", - "@spotify/prettier-config": "^11.0.0", + "@spotify/prettier-config": "^12.0.0", + "@types/node": "^14.14.32", "@types/webpack": "^5.28.0", "command-exists": "^1.2.9", "cross-env": "^7.0.0", @@ -77,11 +78,12 @@ "prettier": "^2.2.1", "shx": "^0.3.2", "ts-node": "^10.4.0", + "typescript": "~4.3.5", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config", "lint-staged": { - "*.{js,jsx,ts,tsx}": [ + "*.{js,jsx,ts,tsx,mjs,cjs}": [ "eslint --fix", "prettier --write" ], diff --git a/packages/app-defaults/CHANGELOG.md b/packages/app-defaults/CHANGELOG.md index b2b50ae48b..b9a84d500c 100644 --- a/packages/app-defaults/CHANGELOG.md +++ b/packages/app-defaults/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/app-defaults +## 0.1.2 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/core-app-api@0.2.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json index 7388a44ce0..fc1bca2104 100644 --- a/packages/app-defaults/package.json +++ b/packages/app-defaults/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/app-defaults", "description": "Provides the default wiring of a Backstage App", - "version": "0.1.1", + "version": "0.1.2", "private": false, "publishConfig": { "access": "public", @@ -29,23 +29,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.4", - "@backstage/core-app-api": "^0.1.21", - "@backstage/core-plugin-api": "^0.2.0", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.0", + "@backstage/core-app-api": "^0.2.0", + "@backstage/core-plugin-api": "^0.3.0", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.0", - "@backstage/test-utils": "^0.1.22", + "@backstage/cli": "^0.10.1", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "@types/react": "*" + "@types/react": "^16.13.1 || ^17.0.0" }, "files": [ "dist" diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index d3c76d52bd..29aa62bdab 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -34,6 +34,8 @@ import { OneLoginAuth, UnhandledErrorForwarder, AtlassianAuth, + createFetchApi, + FetchMiddlewares, } from '@backstage/core-app-api'; import { @@ -42,6 +44,8 @@ import { analyticsApiRef, errorApiRef, discoveryApiRef, + fetchApiRef, + identityApiRef, oauthRequestApiRef, googleAuthApiRef, githubAuthApiRef, @@ -92,6 +96,27 @@ export const apis = [ deps: { errorApi: errorApiRef }, factory: ({ errorApi }) => WebStorage.create({ errorApi }), }), + createApiFactory({ + api: fetchApiRef, + deps: { + configApi: configApiRef, + identityApi: identityApiRef, + discoveryApi: discoveryApiRef, + }, + factory: ({ configApi, identityApi, discoveryApi }) => { + return createFetchApi({ + middleware: [ + FetchMiddlewares.resolvePluginProtocol({ + discoveryApi, + }), + FetchMiddlewares.injectIdentityAuth({ + identityApi, + config: configApi, + }), + ], + }); + }, + }), createApiFactory({ api: oauthRequestApiRef, deps: {}, diff --git a/packages/app-defaults/src/defaults/components.tsx b/packages/app-defaults/src/defaults/components.tsx index 139bea4862..93a5d9cd50 100644 --- a/packages/app-defaults/src/defaults/components.tsx +++ b/packages/app-defaults/src/defaults/components.tsx @@ -73,7 +73,7 @@ const DefaultErrorBoundaryFallback = ({ }; /** - * Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}. + * Creates a set of default components to pass along to {@link @backstage/core-app-api#createSpecializedApp}. * * @public */ diff --git a/packages/app-defaults/src/defaults/themes.tsx b/packages/app-defaults/src/defaults/themes.tsx index 90a4a0f47f..397c160168 100644 --- a/packages/app-defaults/src/defaults/themes.tsx +++ b/packages/app-defaults/src/defaults/themes.tsx @@ -28,7 +28,6 @@ export const themes: AppTheme[] = [ title: 'Light Theme', variant: 'light', icon: , - theme: lightTheme, Provider: ({ children }) => ( {children} @@ -40,7 +39,6 @@ export const themes: AppTheme[] = [ title: 'Dark Theme', variant: 'dark', icon: , - theme: darkTheme, Provider: ({ children }) => ( {children} diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index f6f6049d68..d1055a54bf 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,88 @@ # example-app +## 0.2.57 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-github-actions@0.4.27 + - @backstage/core-app-api@0.2.1 + - @backstage/plugin-kubernetes@0.5.1 + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/plugin-org@0.3.31 + - @backstage/plugin-azure-devops@0.1.7 + - @backstage/cli@0.10.2 + - @backstage/catalog-model@0.9.8 + - @backstage/plugin-techdocs@0.12.10 + - @backstage/plugin-catalog-react@0.6.7 + - @backstage/plugin-apache-airflow@0.1.0 + +## 0.2.56 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-cloudbuild@0.2.29 + - @backstage/plugin-catalog@0.7.4 + - @backstage/core-app-api@0.2.0 + - @backstage/plugin-scaffolder@0.11.14 + - @backstage/plugin-kubernetes@0.5.0 + - @backstage/app-defaults@0.1.2 + - @backstage/integration-react@0.1.15 + - @backstage/plugin-api-docs@0.6.18 + - @backstage/plugin-azure-devops@0.1.6 + - @backstage/plugin-badges@0.2.16 + - @backstage/plugin-catalog-graph@0.2.3 + - @backstage/plugin-catalog-import@0.7.5 + - @backstage/plugin-catalog-react@0.6.5 + - @backstage/plugin-circleci@0.2.31 + - @backstage/plugin-code-coverage@0.1.19 + - @backstage/plugin-cost-insights@0.11.13 + - @backstage/plugin-explore@0.3.22 + - @backstage/plugin-gcp-projects@0.3.10 + - @backstage/plugin-github-actions@0.4.26 + - @backstage/plugin-graphiql@0.2.24 + - @backstage/plugin-home@0.4.7 + - @backstage/plugin-jenkins@0.5.14 + - @backstage/plugin-kafka@0.2.22 + - @backstage/plugin-lighthouse@0.2.31 + - @backstage/plugin-newrelic@0.3.10 + - @backstage/plugin-org@0.3.30 + - @backstage/plugin-pagerduty@0.3.19 + - @backstage/plugin-rollbar@0.3.20 + - @backstage/plugin-search@0.5.1 + - @backstage/plugin-sentry@0.3.30 + - @backstage/plugin-shortcuts@0.1.15 + - @backstage/plugin-tech-insights@0.1.1 + - @backstage/plugin-tech-radar@0.4.13 + - @backstage/plugin-techdocs@0.12.9 + - @backstage/plugin-todo@0.1.16 + - @backstage/plugin-user-settings@0.3.13 + - @backstage/cli@0.10.1 + +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-azure-devops@0.1.5 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/plugin-user-settings@0.3.12 + - @backstage/plugin-api-docs@0.6.16 + - @backstage/cli@0.10.0 + - @backstage/plugin-circleci@0.2.30 + - @backstage/core-plugin-api@0.2.2 + - @backstage/plugin-search@0.5.0 + - @backstage/plugin-tech-insights@0.1.0 + - @backstage/core-app-api@0.1.24 + - @backstage/plugin-kubernetes@0.4.22 + - @backstage/plugin-scaffolder@0.11.13 + - @backstage/plugin-techdocs@0.12.8 + ## 0.2.53 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 09141f7cf7..a9e8e8ddc8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,62 +1,63 @@ { "name": "example-app", - "version": "0.2.53", + "version": "0.2.57", "private": true, "bundled": true, "dependencies": { - "@backstage/app-defaults": "^0.1.1", - "@backstage/catalog-model": "^0.9.7", - "@backstage/cli": "^0.9.0", - "@backstage/core-app-api": "^0.1.21", - "@backstage/core-components": "^0.7.4", - "@backstage/core-plugin-api": "^0.2.0", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-api-docs": "^0.6.14", - "@backstage/plugin-azure-devops": "^0.1.4", - "@backstage/plugin-badges": "^0.2.14", - "@backstage/plugin-catalog": "^0.7.3", - "@backstage/plugin-catalog-graph": "^0.2.2", - "@backstage/plugin-catalog-import": "^0.7.4", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/plugin-circleci": "^0.2.29", - "@backstage/plugin-cloudbuild": "^0.2.28", - "@backstage/plugin-code-coverage": "^0.1.18", - "@backstage/plugin-cost-insights": "^0.11.11", - "@backstage/plugin-explore": "^0.3.21", - "@backstage/plugin-gcp-projects": "^0.3.9", - "@backstage/plugin-github-actions": "^0.4.24", - "@backstage/plugin-graphiql": "^0.2.21", - "@backstage/plugin-home": "^0.4.6", - "@backstage/plugin-jenkins": "^0.5.12", - "@backstage/plugin-kafka": "^0.2.21", - "@backstage/plugin-kubernetes": "^0.4.20", - "@backstage/plugin-lighthouse": "^0.2.30", - "@backstage/plugin-newrelic": "^0.3.9", - "@backstage/plugin-org": "^0.3.28", - "@backstage/plugin-pagerduty": "0.3.18", - "@backstage/plugin-rollbar": "^0.3.19", - "@backstage/plugin-scaffolder": "^0.11.11", - "@backstage/plugin-search": "^0.4.18", - "@backstage/plugin-sentry": "^0.3.29", - "@backstage/plugin-shortcuts": "^0.1.14", - "@backstage/plugin-tech-radar": "^0.4.12", - "@backstage/plugin-techdocs": "^0.12.6", - "@backstage/plugin-todo": "^0.1.15", - "@backstage/plugin-user-settings": "^0.3.11", + "@backstage/app-defaults": "^0.1.2", + "@backstage/catalog-model": "^0.9.8", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-api-docs": "^0.6.18", + "@backstage/plugin-azure-devops": "^0.1.7", + "@backstage/plugin-apache-airflow": "^0.1.0", + "@backstage/plugin-badges": "^0.2.16", + "@backstage/plugin-catalog": "^0.7.4", + "@backstage/plugin-catalog-graph": "^0.2.3", + "@backstage/plugin-catalog-import": "^0.7.5", + "@backstage/plugin-catalog-react": "^0.6.7", + "@backstage/plugin-circleci": "^0.2.31", + "@backstage/plugin-cloudbuild": "^0.2.29", + "@backstage/plugin-code-coverage": "^0.1.19", + "@backstage/plugin-cost-insights": "^0.11.13", + "@backstage/plugin-explore": "^0.3.22", + "@backstage/plugin-gcp-projects": "^0.3.10", + "@backstage/plugin-github-actions": "^0.4.27", + "@backstage/plugin-graphiql": "^0.2.24", + "@backstage/plugin-home": "^0.4.7", + "@backstage/plugin-jenkins": "^0.5.14", + "@backstage/plugin-kafka": "^0.2.22", + "@backstage/plugin-kubernetes": "^0.5.1", + "@backstage/plugin-lighthouse": "^0.2.31", + "@backstage/plugin-newrelic": "^0.3.10", + "@backstage/plugin-org": "^0.3.31", + "@backstage/plugin-pagerduty": "0.3.19", + "@backstage/plugin-rollbar": "^0.3.20", + "@backstage/plugin-scaffolder": "^0.11.14", + "@backstage/plugin-search": "^0.5.1", + "@backstage/plugin-sentry": "^0.3.30", + "@backstage/plugin-shortcuts": "^0.1.15", + "@backstage/plugin-tech-radar": "^0.4.13", + "@backstage/plugin-techdocs": "^0.12.10", + "@backstage/plugin-todo": "^0.1.16", + "@backstage/plugin-user-settings": "^0.3.13", "@backstage/search-common": "^0.2.0", - "@backstage/theme": "^0.2.11", + "@backstage/plugin-tech-insights": "^0.1.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^18.5.3", - "@roadiehq/backstage-plugin-buildkite": "^1.0.8", "@roadiehq/backstage-plugin-github-insights": "^1.1.23", "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.13", "@roadiehq/backstage-plugin-travis-ci": "^1.0.11", "history": "^5.0.0", "prop-types": "^15.7.2", - "react": "^16.12.0", - "react-dom": "^16.12.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", @@ -64,7 +65,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.22", + "@backstage/test-utils": "^0.1.24", "@rjsf/core": "^3.2.1", "@testing-library/cypress": "^8.0.2", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index fecde616ca..59f107c70d 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -34,6 +34,7 @@ import { SignInPage, } from '@backstage/core-components'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; +import { AzurePullRequestsPage } from '@backstage/plugin-azure-devops'; import { CatalogEntityPage, CatalogIndexPage, @@ -86,6 +87,7 @@ import { providers } from './identityProviders'; import * as plugins from './plugins'; import { techDocsPage } from './components/techdocs/TechDocsPage'; +import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; const app = createApp({ apis, @@ -216,6 +218,8 @@ const routes = ( element={} /> } /> + } /> + } />
); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index f365310595..2d9b9b97f1 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -29,7 +29,10 @@ import LogoIcon from './LogoIcon'; import { NavLink } from 'react-router-dom'; import { GraphiQLIcon } from '@backstage/plugin-graphiql'; import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; -import { SidebarSearchModal } from '@backstage/plugin-search'; +import { + SidebarSearchModal, + SearchContextProvider, +} from '@backstage/plugin-search'; import { Shortcuts } from '@backstage/plugin-shortcuts'; import { Sidebar, @@ -41,6 +44,7 @@ import { SidebarSpace, SidebarScrollWrapper, } from '@backstage/core-components'; +import { AzurePullRequestsIcon } from '@backstage/plugin-azure-devops'; const useSidebarLogoStyles = makeStyles({ root: { @@ -79,7 +83,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - + + + {/* Global nav, not org-specific */} @@ -94,6 +100,11 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( + diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3b9b381380..a7de64e89a 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -106,10 +106,7 @@ import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; import { EntityTodoContent } from '@backstage/plugin-todo'; import { Button, Grid } from '@material-ui/core'; import BadgeIcon from '@material-ui/icons/CallToAction'; -import { - EntityBuildkiteContent, - isBuildkiteAvailable, -} from '@roadiehq/backstage-plugin-buildkite'; + import { EntityGithubInsightsContent, EntityGithubInsightsLanguagesCard, @@ -129,6 +126,8 @@ import { } from '@roadiehq/backstage-plugin-travis-ci'; import React, { ReactNode, useMemo, useState } from 'react'; +const customEntityFilterKind = ['Component', 'API', 'System']; + const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -168,10 +167,6 @@ export const cicdContent = ( - - - - @@ -530,7 +525,10 @@ const userPage = ( - + @@ -546,7 +544,10 @@ const groupPage = ( - + diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 869bf46019..4bd21df721 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/backend-common +## 0.9.14 + +### Patch Changes + +- fe24bc9a32: Each plugin now saves to a separate sqlite database file when `connection.filename` is provided in the sqlite config. + Any existing sqlite database files will be ignored. + +## 0.9.13 + +### Patch Changes + +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- 5a008576c4: Add possibility to use custom error handler +- 98a9c35f0c: Add options argument to support additional database migrations configuration +- 6298de32dd: Add knexConfig config section + +## 0.9.12 + +### Patch Changes + +- 905dd952ac: Create a `TokenManager` interface and `ServerTokenManager` implementation to generate and validate server tokens for authenticated backend-to-backend API requests. +- 6b500622d5: Move to using node-fetch internally instead of cross-fetch +- 54989b671d: Fixed a potential crash in the log redaction code +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/config-loader@0.8.1 + ## 0.9.11 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 9c66c0573e..cbdfba3c38 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -21,6 +21,7 @@ import { GitLabIntegration } from '@backstage/integration'; import { isChildPath } from '@backstage/cli-common'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; +import { LoadConfigOptionsRemote } from '@backstage/config-loader'; import { Logger as Logger_2 } from 'winston'; import { MergeResult } from 'isomorphic-git'; import { PushResult } from 'isomorphic-git'; @@ -175,23 +176,22 @@ export function createStatusCheckRouter(options: { // @public (undocumented) export class DatabaseManager { forPlugin(pluginId: string): PluginDatabaseManager; - static fromConfig(config: Config): DatabaseManager; + static fromConfig( + config: Config, + options?: DatabaseManagerOptions, + ): DatabaseManager; } +// @public +export type DatabaseManagerOptions = { + migrations?: PluginDatabaseManager['migrations']; +}; + // @public (undocumented) export class DockerContainerRunner implements ContainerRunner { - constructor({ dockerClient }: { dockerClient: Docker }); + constructor(options: { dockerClient: Docker }); // (undocumented) - runContainer({ - imageName, - command, - args, - logStream, - mountDirs, - workingDir, - envVars, - pullImage, - }: RunContainerOptions): Promise; + runContainer(options: RunContainerOptions): Promise; } // @public @@ -227,34 +227,17 @@ export function getVoidLogger(): winston.Logger; // @public (undocumented) export class Git { // (undocumented) - add({ dir, filepath }: { dir: string; filepath: string }): Promise; + add(options: { dir: string; filepath: string }): Promise; // (undocumented) - addRemote({ - dir, - url, - remote, - }: { + addRemote(options: { dir: string; remote: string; url: string; }): Promise; // (undocumented) - clone({ - url, - dir, - ref, - }: { - url: string; - dir: string; - ref?: string; - }): Promise; + clone(options: { url: string; dir: string; ref?: string }): Promise; // (undocumented) - commit({ - dir, - message, - author, - committer, - }: { + commit(options: { dir: string; message: string; author: { @@ -267,41 +250,22 @@ export class Git { }; }): Promise; // (undocumented) - currentBranch({ - dir, - fullName, - }: { + currentBranch(options: { dir: string; fullName?: boolean; }): Promise; // (undocumented) - fetch({ dir, remote }: { dir: string; remote?: string }): Promise; + fetch(options: { dir: string; remote?: string }): Promise; // (undocumented) - static fromAuth: ({ - username, - password, - logger, - }: { - username?: string | undefined; - password?: string | undefined; - logger?: Logger_2 | undefined; + static fromAuth: (options: { + username?: string; + password?: string; + logger?: Logger_2; }) => Git; // (undocumented) - init({ - dir, - defaultBranch, - }: { - dir: string; - defaultBranch?: string; - }): Promise; + init(options: { dir: string; defaultBranch?: string }): Promise; // (undocumented) - merge({ - dir, - theirs, - ours, - author, - committer, - }: { + merge(options: { dir: string; theirs: string; ours?: string; @@ -315,17 +279,11 @@ export class Git { }; }): Promise; // (undocumented) - push({ dir, remote }: { dir: string; remote: string }): Promise; + push(options: { dir: string; remote: string }): Promise; // (undocumented) - readCommit({ - dir, - sha, - }: { - dir: string; - sha: string; - }): Promise; + readCommit(options: { dir: string; sha: string }): Promise; // (undocumented) - resolveRef({ dir, ref }: { dir: string; ref: string }): Promise; + resolveRef(options: { dir: string; ref: string }): Promise; } // @public @@ -381,6 +339,7 @@ export function isDatabaseConflictError(e: unknown): boolean; // @public export function loadBackendConfig(options: { logger: Logger_2; + remote?: LoadConfigOptionsRemote; argv: string[]; }): Promise; @@ -395,6 +354,9 @@ export type PluginCacheManager = { // @public export interface PluginDatabaseManager { getClient(): Promise; + migrations?: { + skip?: boolean; + }; } // @public @@ -561,6 +523,8 @@ export type ServiceBuilder = { setRequestLoggingHandler( requestLoggingHandler: RequestLoggingHandlerFactory, ): ServiceBuilder; + setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder; + disableDefaultErrorHandler(): ServiceBuilder; start(): Promise; }; @@ -623,8 +587,8 @@ export type UrlReaderPredicateTuple = { // @public export class UrlReaders { - static create({ logger, config, factories }: UrlReadersOptions): UrlReader; - static default({ logger, config, factories }: UrlReadersOptions): UrlReader; + static create(options: UrlReadersOptions): UrlReader; + static default(options: UrlReadersOptions): UrlReader; } // @public (undocumented) @@ -642,4 +606,8 @@ export function useHotCleanup( // @public export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; + +// Warnings were encountered during analysis: +// +// src/database/types.d.ts:23:12 - (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration ``` diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index bdc7740c13..fe3c7a5bec 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -96,6 +96,12 @@ export interface Config { * @default database */ pluginDivisionMode?: 'database' | 'schema'; + /** + * Arbitrary config object to pass to knex when initializing + * (https://knexjs.org/#Installation-client). Most notable is the debug + * and asyncStackTraces booleans + */ + knexConfig?: object; /** Plugin specific database configuration and client override */ plugin?: { [pluginId: string]: { @@ -111,6 +117,14 @@ export interface Config { * Defaults to base config if unspecified. */ ensureExists?: boolean; + /** + * Arbitrary config object to pass to knex when initializing + * (https://knexjs.org/#Installation-client). Most notable is the + * debug and asyncStackTraces booleans. + * + * This is merged recursively into the base knexConfig + */ + knexConfig?: object; }; }; }; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 7fb2de0fe0..91b8f46255 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.9.11", + "version": "0.9.14", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,9 +31,9 @@ "dependencies": { "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.8.0", + "@backstage/config-loader": "^0.8.1", "@backstage/errors": "^0.1.5", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", "@google-cloud/storage": "^5.8.0", "@lerna/project": "^4.0.0", @@ -46,7 +46,6 @@ "compression": "^1.7.4", "concat-stream": "^2.0.0", "cors": "^2.8.5", - "cross-fetch": "^3.0.6", "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -64,6 +63,7 @@ "minimist": "^1.2.5", "morgan": "^1.10.0", "node-abort-controller": "^3.0.1", + "node-fetch": "^2.6.1", "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", @@ -81,8 +81,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/test-utils": "^0.1.24", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", @@ -94,7 +94,7 @@ "@types/recursive-readdir": "^2.2.0", "@types/stoppable": "^1.1.0", "@types/supertest": "^2.0.8", - "@types/tar": "^4.0.3", + "@types/tar": "^6.1.1", "@types/unzipper": "^0.10.3", "@types/webpack-env": "^1.15.2", "aws-sdk-mock": "^5.2.1", diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 941ad26e65..399898cfc4 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -23,6 +23,7 @@ import { loadConfig, ConfigSchema, ConfigTarget, + LoadConfigOptionsRemote, } from '@backstage/config-loader'; import { AppConfig, Config, ConfigReader } from '@backstage/config'; import { JsonValue } from '@backstage/types'; @@ -178,6 +179,7 @@ let currentCancelFunc: () => void; export async function loadBackendConfig(options: { logger: Logger; // process.argv or any other overrides + remote?: LoadConfigOptionsRemote; argv: string[]; }): Promise { const args = parseArgs(options.argv); @@ -202,8 +204,8 @@ export async function loadBackendConfig(options: { const config = new ObservableConfigProxy(options.logger); const { appConfigs } = await loadConfig({ configRoot: paths.targetRoot, - configPaths: [], configTargets: configTargets, + remote: options.remote, watch: { onChange(newConfigs) { options.logger.info( diff --git a/packages/backend-common/src/database/DatabaseManager.test.ts b/packages/backend-common/src/database/DatabaseManager.test.ts index f2fe855234..a48b1584aa 100644 --- a/packages/backend-common/src/database/DatabaseManager.test.ts +++ b/packages/backend-common/src/database/DatabaseManager.test.ts @@ -15,6 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; import { omit } from 'lodash'; +import path from 'path'; import { createDatabaseClient, ensureDatabaseExists, @@ -36,25 +37,45 @@ describe('DatabaseManager', () => { afterEach(() => jest.resetAllMocks()); describe('DatabaseManager.fromConfig', () => { - it('accesses the backend.database key', () => { - const config = new ConfigReader({ - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - user: 'foo', - password: 'bar', - database: 'foodb', - }, + const backendConfig = { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', }, }, - }); + }, + }; + + it('accesses the backend.database key', () => { + const config = new ConfigReader(backendConfig); const getConfigSpy = jest.spyOn(config, 'getConfig'); DatabaseManager.fromConfig(config); expect(getConfigSpy).toHaveBeenCalledWith('backend.database'); }); + + it('handles default options', () => { + const config = new ConfigReader(backendConfig); + const database = DatabaseManager.fromConfig(config); + const client = database.forPlugin('test'); + + expect(client.migrations?.skip).toBe(false); + }); + + it('handles migrations options', () => { + const config = new ConfigReader(backendConfig); + const database = DatabaseManager.fromConfig(config, { + migrations: { skip: true }, + }); + const client = database.forPlugin('test'); + + expect(client.migrations?.skip).toBe(true); + }); }); describe('DatabaseManager.forPlugin', () => { @@ -150,28 +171,6 @@ describe('DatabaseManager', () => { ); }); - it('uses top level sqlite database filename if plugin config is not present', async () => { - const testManager = DatabaseManager.fromConfig( - new ConfigReader({ - backend: { - database: { - client: 'sqlite3', - connection: 'some-file-path', - }, - }, - }), - ); - - await testManager.forPlugin('pluginwithoutconfig').getClient(); - const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); - const [_, overrides] = mockCalls[0]; - - expect(overrides).toHaveProperty( - 'connection.filename', - expect.stringContaining('some-file-path'), - ); - }); - it('provides an inmemory sqlite database if top level is also inmemory and plugin config is not present', async () => { const testManager = DatabaseManager.fromConfig( new ConfigReader({ @@ -194,6 +193,110 @@ describe('DatabaseManager', () => { ); }); + it('throws if top level sqlite filename is provided', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: 'some-file-path', + }, + }, + }), + ); + + await expect( + testManager.forPlugin('pluginwithoutconfig').getClient(), + ).rejects.toBeInstanceOf(Error); + }); + + it('creates plugin-specific sqlite files when plugin config is not present', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: { + directory: 'sqlite-files', + }, + }, + }, + }), + ); + + await testManager.forPlugin('pluginwithoutconfig').getClient(); + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_, overrides] = mockCalls[0]; + + expect(overrides).toHaveProperty( + 'connection.filename', + path.join('sqlite-files', 'pluginwithoutconfig.sqlite'), + ); + }); + + it('uses sqlite directory from top level config and filename from plugin config', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: { + directory: 'sqlite-files', + }, + plugin: { + test: { + connection: { + filename: 'other.sqlite', + }, + }, + }, + }, + }, + }), + ); + + await testManager.forPlugin('test').getClient(); + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_, overrides] = mockCalls[0]; + + expect(overrides).toHaveProperty( + 'connection.filename', + path.join('sqlite-files', 'other.sqlite'), + ); + }); + + it('uses sqlite directory and filename from plugin config', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: { + directory: 'sqlite-files', + }, + plugin: { + test: { + connection: { + directory: 'custom-sqlite-files', + filename: 'other.sqlite', + }, + }, + }, + }, + }, + }), + ); + + await testManager.forPlugin('test').getClient(); + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_, overrides] = mockCalls[0]; + + expect(overrides).toHaveProperty( + 'connection.filename', + path.join('custom-sqlite-files', 'other.sqlite'), + ); + }); + it('connects to a plugin database using a specific database name', async () => { // testdbname.connection.database is set in config await manager.forPlugin('testdbname').getClient(); @@ -262,7 +365,10 @@ describe('DatabaseManager', () => { expect(baseConfig.get().client).toEqual('sqlite3'); // sqlite3 uses 'filename' instead of 'database' - expect(overrides).toHaveProperty('connection.filename'); + expect(overrides).toHaveProperty( + 'connection.filename', + 'plugin_with_different_client', + ); }); it('provides database client specific base from plugin connection string when client set under plugin', async () => { @@ -505,5 +611,42 @@ describe('DatabaseManager', () => { 'database_name_overriden', ); }); + + it('fetches and merges additional knex config', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + database: 'foodb', + }, + knexConfig: { + something: false, + }, + plugin: { + testdbname: { + knexConfig: { + debug: true, + }, + }, + }, + }, + }, + }), + ); + await testManager.forPlugin('testdbname').getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig] = mockCalls[0]; + + expect(baseConfig.data).toEqual( + expect.objectContaining({ + debug: true, + something: false, + }), + ); + }); }); }); diff --git a/packages/backend-common/src/database/DatabaseManager.ts b/packages/backend-common/src/database/DatabaseManager.ts index cf5e801d66..5959c60c83 100644 --- a/packages/backend-common/src/database/DatabaseManager.ts +++ b/packages/backend-common/src/database/DatabaseManager.ts @@ -14,20 +14,21 @@ * limitations under the License. */ -import { Knex } from 'knex'; -import { omit } from 'lodash'; import { Config, ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; +import { Knex } from 'knex'; +import { merge, omit } from 'lodash'; +import { mergeDatabaseConfig } from './config'; import { - createNameOverride, - ensureDatabaseExists, - normalizeConnection, - createSchemaOverride, - ensureSchemaExists, createDatabaseClient, + createNameOverride, + createSchemaOverride, + ensureDatabaseExists, + ensureSchemaExists, + normalizeConnection, } from './connection'; import { PluginDatabaseManager } from './types'; -import { mergeDatabaseConfig } from './config'; +import path from 'path'; /** * Provides a config lookup path for a plugin's config block. @@ -36,6 +37,15 @@ function pluginPath(pluginId: string): string { return `plugin.${pluginId}`; } +/** + * Configuration options object. + * + * @public + */ +export type DatabaseManagerOptions = { + migrations?: PluginDatabaseManager['migrations']; +}; + /** @public */ export class DatabaseManager { /** @@ -47,19 +57,25 @@ export class DatabaseManager { * names if config is not provided. * * @param config - The loaded application configuration. + * @param options - An optional configuration object. */ - static fromConfig(config: Config): DatabaseManager { + static fromConfig( + config: Config, + options?: DatabaseManagerOptions, + ): DatabaseManager { const databaseConfig = config.getConfig('backend.database'); return new DatabaseManager( databaseConfig, databaseConfig.getOptionalString('prefix'), + options, ); } private constructor( private readonly config: Config, private readonly prefix: string = 'backstage_plugin_', + private readonly options?: DatabaseManagerOptions, ) {} /** @@ -76,6 +92,10 @@ export class DatabaseManager { getClient(): Promise { return _this.getDatabase(pluginId); }, + migrations: { + skip: false, + ..._this.options?.migrations, + }, }; } @@ -95,10 +115,18 @@ export class DatabaseManager { const connection = this.getConnectionConfig(pluginId); if (this.getClientType(pluginId).client === 'sqlite3') { - // sqlite database name should fallback to ':memory:' as a special case - return ( - (connection as Knex.Sqlite3ConnectionConfig)?.filename ?? ':memory:' - ); + const sqliteFilename: string | undefined = ( + connection as Knex.Sqlite3ConnectionConfig + ).filename; + + if (sqliteFilename === ':memory:') { + return sqliteFilename; + } + + const sqliteDirectory = + (connection as { directory?: string }).directory ?? '.'; + + return path.join(sqliteDirectory, sqliteFilename ?? `${pluginId}.sqlite`); } const databaseName = (connection as Knex.ConnectionConfig)?.database; @@ -138,6 +166,24 @@ export class DatabaseManager { }; } + /** + * Provides the knexConfig which should be used for a given plugin. + * + * @param pluginId Plugin to get the knexConfig for + * @returns the merged kexConfig value or undefined if it isn't specified + */ + private getAdditionalKnexConfig(pluginId: string): JsonObject | undefined { + const pluginConfig = this.config + .getOptionalConfig(`${pluginPath(pluginId)}.knexConfig`) + ?.get(); + + const baseConfig = this.config + .getOptionalConfig('knexConfig') + ?.get(); + + return merge(baseConfig, pluginConfig); + } + private getEnsureExistsConfig(pluginId: string): boolean { const baseConfig = this.config.getOptionalBoolean('ensureExists') ?? true; return ( @@ -168,6 +214,17 @@ export class DatabaseManager { this.config.get('connection'), this.config.getString('client'), ); + + if ( + client === 'sqlite3' && + 'filename' in baseConnection && + baseConnection.filename !== ':memory:' + ) { + throw new Error( + '`connection.filename` is not supported for the base sqlite connection. Prefer `connection.directory` or provide a filename for the plugin connection instead.', + ); + } + // Databases cannot be shared unless the `pluginDivisionMode` is set to `schema`. The // `database` property from the base connection is omitted unless `pluginDivisionMode` // is set to `schema`. SQLite3's `filename` property is an exception as this is used as a @@ -200,6 +257,7 @@ export class DatabaseManager { const { client } = this.getClientType(pluginId); return { + ...this.getAdditionalKnexConfig(pluginId), client, connection: this.getConnectionConfig(pluginId), }; diff --git a/packages/backend-common/src/database/connectors/sqlite3.test.ts b/packages/backend-common/src/database/connectors/sqlite3.test.ts index b9da19d247..cfc9f61527 100644 --- a/packages/backend-common/src/database/connectors/sqlite3.test.ts +++ b/packages/backend-common/src/database/connectors/sqlite3.test.ts @@ -73,28 +73,6 @@ describe('sqlite3', () => { }); }); - it('builds a persistent connection per database', () => { - expect( - buildSqliteDatabaseConfig( - createConfig({ - filename: path.join('path', 'to', 'foo'), - }), - { - connection: { - database: 'my-database', - }, - }, - ), - ).toEqual({ - client: 'sqlite3', - connection: { - filename: path.join('path', 'to', 'foo', 'my-database.sqlite'), - database: 'my-database', - }, - useNullAsDefault: true, - }); - }); - it('replaces the connection with an override', () => { expect( buildSqliteDatabaseConfig(createConfig(':memory:'), { diff --git a/packages/backend-common/src/database/connectors/sqlite3.ts b/packages/backend-common/src/database/connectors/sqlite3.ts index 3dfecd7e6b..41f80ed293 100644 --- a/packages/backend-common/src/database/connectors/sqlite3.ts +++ b/packages/backend-common/src/database/connectors/sqlite3.ts @@ -86,19 +86,6 @@ export function buildSqliteDatabaseConfig( overrides, ); - // If we don't create an in-memory database, interpret the connection string - // as a directory that contains multiple sqlite files based on the database - // name. - const database = (config.connection as Knex.ConnectionConfig).database; - const sqliteConnection = config.connection as Knex.Sqlite3ConnectionConfig; - - if (database && sqliteConnection.filename !== ':memory:') { - sqliteConnection.filename = path.join( - sqliteConnection.filename, - `${database}.sqlite`, - ); - } - return config; } diff --git a/packages/backend-common/src/database/types.ts b/packages/backend-common/src/database/types.ts index e96f86980b..344f1088b8 100644 --- a/packages/backend-common/src/database/types.ts +++ b/packages/backend-common/src/database/types.ts @@ -30,6 +30,18 @@ export interface PluginDatabaseManager { * stores so that plugins are discouraged from database integration. */ getClient(): Promise; + + /** + * This property is used to control the behavior of database migrations. + */ + migrations?: { + /** + * skip database migrations. Useful if connecting to a read-only database. + * + * @default false + */ + skip?: boolean; + }; } /** diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 2a4226f88f..12db7d42a1 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -54,10 +54,13 @@ export function setRootLoggerRedactionList(redactionList: string[]) { * and replaces them with the corresponding identifier. */ function redactLogLine(info: winston.Logform.TransformableInfo) { - // TODO(hhogg): The logger is created before the config is loaded, - // because the logger is needed in the config loader. There is a risk of - // a secret being logged out during the config loading stage. - if (redactionRegExp) { + // TODO(hhogg): The logger is created before the config is loaded, because the + // logger is needed in the config loader. There is a risk of a secret being + // logged out during the config loading stage. + // TODO(freben): Added a check that info.message actually was a string, + // because it turned out that this was not necessarily guaranteed. + // https://github.com/backstage/backstage/issues/8306 + if (redactionRegExp && typeof info.message === 'string') { info.message = info.message.replace(redactionRegExp, '[REDACTED]'); } diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 82d1192b17..37d97b8941 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -22,7 +22,7 @@ import { getAzureRequestOptions, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; @@ -72,7 +72,13 @@ export class AzureUrlReader implements UrlReader { try { response = await fetch(builtUrl, { ...getAzureRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -123,7 +129,13 @@ export class AzureUrlReader implements UrlReader { ...getAzureRequestOptions(this.integration.config, { Accept: 'application/zip', }), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index c1a113a50d..b0e8056370 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -23,7 +23,7 @@ import { getBitbucketRequestOptions, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { trimEnd } from 'lodash'; import { Minimatch } from 'minimatch'; @@ -94,7 +94,13 @@ export class BitbucketUrlReader implements UrlReader { try { response = await fetch(bitbucketUrl.toString(), { ...requestOptions, - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index d82b1f7f52..7090d26d35 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -15,7 +15,7 @@ */ import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import { ReaderFactory, ReadTreeResponse, @@ -86,7 +86,13 @@ export class FetchUrlReader implements UrlReader { headers: { ...(options?.etag && { 'If-None-Match': options.etag }), }, - signal: options?.signal, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + signal: options?.signal as any, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 755e0f17e6..d0fbf6d8ae 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -21,7 +21,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { RestEndpointMethodTypes } from '@octokit/rest'; -import fetch from 'cross-fetch'; +import fetch, { RequestInit, Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; @@ -110,7 +110,13 @@ export class GithubUrlReader implements UrlReader { ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, - signal: options?.signal, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + signal: options?.signal as any, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -165,7 +171,13 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers, signal: options?.signal }, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + { headers, signal: options?.signal as any }, options, ); } @@ -189,7 +201,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers, signal: options?.signal }, + { headers, signal: options?.signal as any }, ); return { files, etag: commitSha }; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 128aed3c85..2444e317a7 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -20,7 +20,7 @@ import { GitLabIntegration, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; @@ -76,7 +76,13 @@ export class GitlabUrlReader implements UrlReader { ...getGitLabRequestOptions(this.integration.config).headers, ...(etag && { 'If-None-Match': etag }), }, - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -145,7 +151,13 @@ export class GitlabUrlReader implements UrlReader { ).toString(), { ...getGitLabRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }, ); if (!commitsGitlabResponse.ok) { @@ -169,7 +181,13 @@ export class GitlabUrlReader implements UrlReader { )}/repository/archive?sha=${branch}`, { ...getGitLabRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }, ); if (!archiveGitLabResponse.ok) { diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 7120a9570f..a920ec080b 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -46,7 +46,8 @@ export class UrlReaders { /** * Creates a UrlReader without any known types. */ - static create({ logger, config, factories }: UrlReadersOptions): UrlReader { + static create(options: UrlReadersOptions): UrlReader { + const { logger, config, factories } = options; const mux = new UrlReaderPredicateMux(logger); const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config, @@ -68,7 +69,8 @@ export class UrlReaders { * * Any additional factories passed will be loaded before the default ones. */ - static default({ logger, config, factories = [] }: UrlReadersOptions) { + static default(options: UrlReadersOptions) { + const { logger, config, factories = [] } = options; return UrlReaders.create({ logger, config, diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index 00b103b240..372ecb0260 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import git, { ProgressCallback, MergeResult, @@ -42,44 +43,32 @@ export class Git { }, ) {} - async add({ - dir, - filepath, - }: { - dir: string; - filepath: string; - }): Promise { + async add(options: { dir: string; filepath: string }): Promise { + const { dir, filepath } = options; this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`); return git.add({ fs, dir, filepath }); } - async addRemote({ - dir, - url, - remote, - }: { + async addRemote(options: { dir: string; remote: string; url: string; }): Promise { + const { dir, url, remote } = options; this.config.logger?.info( `Creating new remote {dir=${dir},remote=${remote},url=${url}}`, ); return git.addRemote({ fs, dir, remote, url }); } - async commit({ - dir, - message, - author, - committer, - }: { + async commit(options: { dir: string; message: string; author: { name: string; email: string }; committer: { name: string; email: string }; }): Promise { + const { dir, message, author, committer } = options; this.config.logger?.info( `Committing file to repo {dir=${dir},message=${message}}`, ); @@ -87,15 +76,12 @@ export class Git { return git.commit({ fs, dir, message, author, committer }); } - async clone({ - url, - dir, - ref, - }: { + async clone(options: { url: string; dir: string; ref?: string; }): Promise { + const { url, dir, ref } = options; this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`); return git.clone({ fs, @@ -114,51 +100,35 @@ export class Git { } // https://isomorphic-git.org/docs/en/currentBranch - async currentBranch({ - dir, - fullName, - }: { + async currentBranch(options: { dir: string; fullName?: boolean; }): Promise { - const fullname = fullName ?? false; - return git.currentBranch({ fs, dir, fullname }) as Promise< + const { dir, fullName = false } = options; + return git.currentBranch({ fs, dir, fullname: fullName }) as Promise< string | undefined >; } // https://isomorphic-git.org/docs/en/fetch - async fetch({ - dir, - remote, - }: { - dir: string; - remote?: string; - }): Promise { - const remoteValue = remote ?? 'origin'; + async fetch(options: { dir: string; remote?: string }): Promise { + const { dir, remote = 'origin' } = options; this.config.logger?.info( - `Fetching remote=${remoteValue} for repository {dir=${dir}}`, + `Fetching remote=${remote} for repository {dir=${dir}}`, ); await git.fetch({ fs, http, dir, - remote: remoteValue, + remote, onProgress: this.onProgressHandler(), - headers: { - 'user-agent': 'git/@isomorphic-git', - }, + headers: { 'user-agent': 'git/@isomorphic-git' }, onAuth: this.onAuth, }); } - async init({ - dir, - defaultBranch = 'master', - }: { - dir: string; - defaultBranch?: string; - }): Promise { + async init(options: { dir: string; defaultBranch?: string }): Promise { + const { dir, defaultBranch = 'master' } = options; this.config.logger?.info(`Init git repository {dir=${dir}}`); return git.init({ @@ -169,19 +139,14 @@ export class Git { } // https://isomorphic-git.org/docs/en/merge - async merge({ - dir, - theirs, - ours, - author, - committer, - }: { + async merge(options: { dir: string; theirs: string; ours?: string; author: { name: string; email: string }; committer: { name: string; email: string }; }): Promise { + const { dir, theirs, ours, author, committer } = options; this.config.logger?.info( `Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`, ); @@ -197,7 +162,8 @@ export class Git { }); } - async push({ dir, remote }: { dir: string; remote: string }) { + async push(options: { dir: string; remote: string }) { + const { dir, remote } = options; this.config.logger?.info( `Pushing directory to remote {dir=${dir},remote=${remote}}`, ); @@ -215,24 +181,17 @@ export class Git { } // https://isomorphic-git.org/docs/en/readCommit - async readCommit({ - dir, - sha, - }: { + async readCommit(options: { dir: string; sha: string; }): Promise { + const { dir, sha } = options; return git.readCommit({ fs, dir, oid: sha }); } // https://isomorphic-git.org/docs/en/resolveRef - async resolveRef({ - dir, - ref, - }: { - dir: string; - ref: string; - }): Promise { + async resolveRef(options: { dir: string; ref: string }): Promise { + const { dir, ref } = options; return git.resolveRef({ fs, dir, ref }); } @@ -256,13 +215,12 @@ export class Git { }; }; - static fromAuth = ({ - username, - password, - logger, - }: { + static fromAuth = (options: { username?: string; password?: string; logger?: Logger; - }) => new Git({ username, password, logger }); + }) => { + const { username, password, logger } = options; + return new Git({ username, password, logger }); + }; } diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts index bd97f444de..6229cbb683 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { applyCspDirectives } from './ServiceBuilderImpl'; +import { NextFunction, Request, Response } from 'express'; +import { applyCspDirectives, ServiceBuilderImpl } from './ServiceBuilderImpl'; describe('ServiceBuilderImpl', () => { describe('applyCspDirectives', () => { @@ -33,4 +34,27 @@ describe('ServiceBuilderImpl', () => { expect(result!['upgrade-insecure-requests']).toBeUndefined(); }); }); + + describe('setCustomErrorHandler', () => { + it('check if custom error handler is undefined', () => { + const serviceBuilder = new ServiceBuilderImpl(module); + const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder); + expect(serviceBuilderProto.errorHandler).toBeUndefined(); + }); + + it('adds custom error handler', () => { + const serviceBuilder = new ServiceBuilderImpl(module); + const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder); + const customErrorHandler = ( + error: Error, + _req: Request, + _res: Response, + next: NextFunction, + ) => { + next(error); + }; + serviceBuilderProto.setErrorHandler(customErrorHandler); + expect(serviceBuilderProto.errorHandler).toEqual(customErrorHandler); + }); + }); }); diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index b085cccfd6..54f145539f 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -17,7 +17,7 @@ import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; -import express, { Router } from 'express'; +import express, { Router, ErrorRequestHandler } from 'express'; import helmet from 'helmet'; import * as http from 'http'; import stoppable from 'stoppable'; @@ -25,7 +25,7 @@ import { Logger } from 'winston'; import { useHotCleanup } from '../../hot'; import { getRootLogger } from '../../logging'; import { - errorHandler, + errorHandler as defaultErrorHandler, notFoundHandler, requestLoggingHandler as defaultRequestLoggingHandler, } from '../../middleware'; @@ -66,6 +66,8 @@ export class ServiceBuilderImpl implements ServiceBuilder { private httpsSettings: HttpsSettings | undefined; private routers: [string, Router][]; private requestLoggingHandler: RequestLoggingHandlerFactory | undefined; + private errorHandler: ErrorRequestHandler | undefined; + private useDefaultErrorHandler: boolean; // Reference to the module where builder is created - needed for hot module // reloading private module: NodeModule; @@ -73,6 +75,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { constructor(moduleRef: NodeModule) { this.routers = []; this.module = moduleRef; + this.useDefaultErrorHandler = true; } loadConfig(config: Config): ServiceBuilder { @@ -152,6 +155,16 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } + setErrorHandler(errorHandler: ErrorRequestHandler) { + this.errorHandler = errorHandler; + return this; + } + + disableDefaultErrorHandler() { + this.useDefaultErrorHandler = false; + return this; + } + async start(): Promise { const app = express(); const { port, host, logger, corsOptions, httpsSettings, helmetOptions } = @@ -169,7 +182,14 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(root, route); } app.use(notFoundHandler()); - app.use(errorHandler()); + + if (this.errorHandler) { + app.use(this.errorHandler); + } + + if (this.useDefaultErrorHandler) { + app.use(defaultErrorHandler()); + } const server: http.Server = httpsSettings ? await createHttpsServer(app, httpsSettings, logger) diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 2ad379f31e..3e94196006 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import cors from 'cors'; -import { Router, RequestHandler } from 'express'; +import { Router, RequestHandler, ErrorRequestHandler } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -98,6 +98,21 @@ export type ServiceBuilder = { requestLoggingHandler: RequestLoggingHandlerFactory, ): ServiceBuilder; + /** + * Sets an additional errorHandler to run before the defaultErrorHandler. + * + * For execution of only the custom error handler make sure to also invoke disableDefaultErrorHandler() + * otherwise the defaultErrorHandler is executed at the end of the error middleware chain. + * + * @param errorHandler - an error handler + */ + setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder; + + /** + * Disables the default error handler + */ + disableDefaultErrorHandler(): ServiceBuilder; + /** * Starts the server using the given settings. */ diff --git a/packages/backend-common/src/util/DockerContainerRunner.ts b/packages/backend-common/src/util/DockerContainerRunner.ts index b81bd995ca..424913f316 100644 --- a/packages/backend-common/src/util/DockerContainerRunner.ts +++ b/packages/backend-common/src/util/DockerContainerRunner.ts @@ -28,20 +28,22 @@ export type UserOptions = { export class DockerContainerRunner implements ContainerRunner { private readonly dockerClient: Docker; - constructor({ dockerClient }: { dockerClient: Docker }) { - this.dockerClient = dockerClient; + constructor(options: { dockerClient: Docker }) { + this.dockerClient = options.dockerClient; } - async runContainer({ - imageName, - command, - args, - logStream = new PassThrough(), - mountDirs = {}, - workingDir, - envVars = {}, - pullImage = true, - }: RunContainerOptions) { + async runContainer(options: RunContainerOptions) { + const { + imageName, + command, + args, + logStream = new PassThrough(), + mountDirs = {}, + workingDir, + envVars = {}, + pullImage = true, + } = options; + // Show a better error message when Docker is unavailable. try { await this.dockerClient.ping(); diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json index 2bc6474615..a22895140e 100644 --- a/packages/backend-tasks/package.json +++ b/packages/backend-tasks/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/types": "^0.1.1", @@ -43,8 +43,8 @@ "zod": "^3.9.5" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.9", - "@backstage/cli": "^0.9.0", + "@backstage/backend-test-utils": "^0.1.10", + "@backstage/cli": "^0.10.0", "jest": "^26.0.1", "wait-for-expect": "^3.0.2" }, diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md index 9927aa3cc7..584187b9d3 100644 --- a/packages/backend-test-utils/CHANGELOG.md +++ b/packages/backend-test-utils/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/backend-test-utils +## 0.1.10 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.12 + - @backstage/cli@0.10.0 + ## 0.1.9 ### Patch Changes diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index c3d4f730b7..2c1724e50f 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-test-utils", "description": "Test helpers library for Backstage backends", - "version": "0.1.9", + "version": "0.1.10", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", - "@backstage/cli": "^0.9.0", + "@backstage/backend-common": "^0.9.12", + "@backstage/cli": "^0.10.0", "@backstage/config": "^0.1.9", "knex": "^0.95.1", "mysql2": "^2.2.5", @@ -41,7 +41,7 @@ "uuid": "^8.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "jest": "^26.0.1" }, "files": [ diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 20e0d7885b..9c7b00b61f 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,60 @@ # example-backend +## 0.2.57 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-search-backend-module-elasticsearch@0.0.7 + - @backstage/plugin-catalog-backend@0.19.2 + - @backstage/plugin-scaffolder-backend@0.15.17 + - @backstage/backend-common@0.9.14 + - @backstage/plugin-azure-devops-backend@0.2.5 + - @backstage/plugin-auth-backend@0.5.1 + - @backstage/catalog-model@0.9.8 + - example-app@0.2.57 + +## 0.2.56 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend@0.5.0 + - @backstage/plugin-scaffolder-backend@0.15.16 + - @backstage/plugin-kubernetes-backend@0.4.0 + - @backstage/backend-common@0.9.13 + - @backstage/plugin-catalog-backend@0.19.1 + - @backstage/plugin-search-backend@0.2.8 + - @backstage/plugin-search-backend-module-elasticsearch@0.0.6 + - @backstage/plugin-search-backend-module-pg@0.2.2 + - @backstage/plugin-techdocs-backend@0.12.0 + - @backstage/plugin-todo-backend@0.1.15 + - @backstage/plugin-scaffolder-backend-module-rails@0.2.0 + - @backstage/plugin-azure-devops-backend@0.2.4 + - example-app@0.2.56 + +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-scaffolder-backend@0.15.15 + - @backstage/plugin-auth-backend@0.4.10 + - @backstage/plugin-kubernetes-backend@0.3.20 + - @backstage/plugin-badges-backend@0.1.13 + - @backstage/plugin-catalog-backend@0.19.0 + - @backstage/plugin-code-coverage-backend@0.1.16 + - @backstage/plugin-jenkins-backend@0.1.9 + - @backstage/plugin-tech-insights-backend@0.1.3 + - @backstage/plugin-techdocs-backend@0.11.0 + - @backstage/plugin-todo-backend@0.1.14 + - @backstage/backend-common@0.9.12 + - @backstage/plugin-azure-devops-backend@0.2.3 + - @backstage/plugin-tech-insights-backend-module-jsonfc@0.1.2 + - @backstage/plugin-tech-insights-node@0.1.1 + - example-app@0.2.55 + ## 0.2.54 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index bf18728b9f..80ae3b0998 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.54", + "version": "0.2.57", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,39 +24,39 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/catalog-client": "^0.5.2", - "@backstage/catalog-model": "^0.9.7", + "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.10", - "@backstage/integration": "^0.6.8", + "@backstage/integration": "^0.6.10", "@backstage/plugin-app-backend": "^0.3.19", - "@backstage/plugin-auth-backend": "^0.4.9", - "@backstage/plugin-azure-devops-backend": "^0.2.2", - "@backstage/plugin-badges-backend": "^0.1.12", - "@backstage/plugin-catalog-backend": "^0.18.0", - "@backstage/plugin-code-coverage-backend": "^0.1.15", + "@backstage/plugin-auth-backend": "^0.5.1", + "@backstage/plugin-azure-devops-backend": "^0.2.5", + "@backstage/plugin-badges-backend": "^0.1.13", + "@backstage/plugin-catalog-backend": "^0.19.2", + "@backstage/plugin-code-coverage-backend": "^0.1.16", "@backstage/plugin-graphql-backend": "^0.1.9", - "@backstage/plugin-jenkins-backend": "^0.1.8", - "@backstage/plugin-kubernetes-backend": "^0.3.19", + "@backstage/plugin-jenkins-backend": "^0.1.9", + "@backstage/plugin-kubernetes-backend": "^0.4.0", "@backstage/plugin-kafka-backend": "^0.2.12", "@backstage/plugin-proxy-backend": "^0.2.14", "@backstage/plugin-rollbar-backend": "^0.1.16", - "@backstage/plugin-scaffolder-backend": "^0.15.14", - "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7", - "@backstage/plugin-search-backend": "^0.2.7", + "@backstage/plugin-scaffolder-backend": "^0.15.17", + "@backstage/plugin-scaffolder-backend-module-rails": "^0.2.0", + "@backstage/plugin-search-backend": "^0.2.8", "@backstage/plugin-search-backend-node": "^0.4.2", - "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5", - "@backstage/plugin-search-backend-module-pg": "^0.2.1", - "@backstage/plugin-techdocs-backend": "^0.10.9", - "@backstage/plugin-tech-insights-backend": "^0.1.2", - "@backstage/plugin-tech-insights-node": "^0.1.0", - "@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.1", - "@backstage/plugin-todo-backend": "^0.1.13", + "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.7", + "@backstage/plugin-search-backend-module-pg": "^0.2.2", + "@backstage/plugin-techdocs-backend": "^0.12.0", + "@backstage/plugin-tech-insights-backend": "^0.1.3", + "@backstage/plugin-tech-insights-node": "^0.1.1", + "@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.2", + "@backstage/plugin-todo-backend": "^0.1.15", "@gitbeaker/node": "^34.6.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", "dockerode": "^3.3.1", - "example-app": "^0.2.53", + "example-app": "^0.2.57", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", @@ -68,7 +68,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.2", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4426ed7767..7a79c278ff 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -89,6 +89,7 @@ async function main() { argv: process.argv, logger, }); + const createEnv = makeCreateEnv(config); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index eb1e0502db..c32bbccbb0 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -29,6 +29,7 @@ export default async function createPlugin({ config, discovery, reader, + cache, }: PluginEnvironment): Promise { // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -64,5 +65,6 @@ export default async function createPlugin({ logger, config, discovery, + cache, }); } diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index e16ca740f6..f3921993d3 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -71,7 +71,7 @@ export interface CatalogApi { // @public export class CatalogClient implements CatalogApi { - constructor(options: { discoveryApi: DiscoveryApi }); + constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }); addLocation( { type, target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, @@ -121,6 +121,9 @@ export type CatalogEntitiesRequest = { | Record | undefined; fields?: string[] | undefined; + offset?: number; + limit?: number; + after?: string; }; // @public @@ -155,4 +158,9 @@ export type DiscoveryApi = { // @public export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = 'backstage.io/catalog-processing'; + +// @public +export type FetchApi = { + fetch: typeof fetch; +}; ``` diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 4ab727b6b3..28a3d5f969 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -35,7 +35,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "msw": "^0.35.0" }, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index d2db98bb25..1331f8e874 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -175,6 +175,24 @@ describe('CatalogClient', () => { { apiVersion: '2' }, ]); }); + + it('builds paging parameters properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { + expect(req.url.search).toBe('?offset=1&limit=2&after=%3D'); + return res(ctx.json([])); + }), + ); + + const response = await client.getEntities( + { offset: 1, limit: 2, after: '=' }, + { token }, + ); + + expect(response.items).toEqual([]); + }); }); describe('getLocationById', () => { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 29d01fbe5c..a446d249c0 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -25,7 +25,7 @@ import { stringifyLocationReference, } from '@backstage/catalog-model'; import { ResponseError } from '@backstage/errors'; -import fetch from 'cross-fetch'; +import crossFetch from 'cross-fetch'; import { CATALOG_FILTER_EXISTS, AddLocationRequest, @@ -38,6 +38,7 @@ import { CatalogEntityAncestorsResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; +import { FetchApi } from './types/fetch'; /** * A frontend and backend compatible client for communicating with the Backstage Catalog. @@ -46,9 +47,11 @@ import { DiscoveryApi } from './types/discovery'; * */ export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }) { this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi || { fetch: crossFetch }; } /** @@ -110,7 +113,7 @@ export class CatalogClient implements CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise> { - const { filter = [], fields = [] } = request ?? {}; + const { filter = [], fields = [], offset, limit, after } = request ?? {}; const filterItems = [filter].flat(); const params: string[] = []; @@ -141,6 +144,16 @@ export class CatalogClient implements CatalogApi { params.push(`fields=${fields.map(encodeURIComponent).join(',')}`); } + if (offset !== undefined) { + params.push(`offset=${offset}`); + } + if (limit !== undefined) { + params.push(`limit=${limit}`); + } + if (after !== undefined) { + params.push(`after=${encodeURIComponent(after)}`); + } + const query = params.length ? `?${params.join('&')}` : ''; const entities: Entity[] = await this.requestRequired( 'GET', @@ -206,7 +219,7 @@ export class CatalogClient implements CatalogApi { * @public */ async refreshEntity(entityRef: string, options?: CatalogRequestOptions) { - const response = await fetch( + const response = await this.fetchApi.fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/refresh`, { headers: { @@ -237,7 +250,7 @@ export class CatalogClient implements CatalogApi { { type = 'url', target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, ): Promise { - const response = await fetch( + const response = await this.fetchApi.fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ dryRun ? '?dryRun=true' : '' }`, @@ -376,7 +389,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { throw await ResponseError.fromResponse(response); @@ -392,7 +405,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { throw await ResponseError.fromResponse(response); @@ -410,7 +423,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { if (response.status === 404) { diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 6344caff7c..3a09e14d71 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -29,11 +29,81 @@ export const CATALOG_FILTER_EXISTS = Symbol('CATALOG_FILTER_EXISTS'); * @public */ export type CatalogEntitiesRequest = { + /** + * If given, return only entities that match the given patterns. + * + * @remarks + * + * If multiple filter sets are given as an array, then there is effectively an + * OR between each filter set. + * + * Within one filter set, there is effectively an AND between the various + * keys. + * + * Within one key, if there are more than one value, then there is effectively + * an OR between them. + * + * Example: For an input of + * + * ``` + * [ + * { kind: ['API', 'Component'] }, + * { 'metadata.name': 'a', 'metadata.namespace': 'b' } + * ] + * ``` + * + * This effectively means + * + * ``` + * (kind = EITHER 'API' OR 'Component') + * OR + * (metadata.name = 'a' AND metadata.namespace = 'b' ) + * ``` + * + * Each key is a dot separated path in each object. + * + * As a value you can also pass in the symbol `CATALOG_FILTER_EXISTS` + * (exported from this package), which means that you assert on the existence + * of that key, no matter what its value is. + */ filter?: | Record[] | Record | undefined; + /** + * If given, return only the parts of each entity that match those dot + * separated paths in each object. + * + * @remarks + * + * Example: For an input of `['kind', 'metadata.annotations']`, then response + * objects will be shaped like + * + * ``` + * { + * "kind": "Component", + * "metadata": { + * "annotations": { + * "foo": "bar" + * } + * } + * } + * ``` + */ fields?: string[] | undefined; + /** + * If given, skips over the first N items in the result set. + */ + offset?: number; + /** + * If given, returns at most N items from the result set. + */ + limit?: number; + /** + * If given, skips over all items before that cursor as returned by a previous + * request. + */ + after?: string; }; /** diff --git a/packages/catalog-client/src/types/discovery.ts b/packages/catalog-client/src/types/discovery.ts index 19ee5ed19c..5fa14372e3 100644 --- a/packages/catalog-client/src/types/discovery.ts +++ b/packages/catalog-client/src/types/discovery.ts @@ -15,7 +15,7 @@ */ /** - * This is a copy of the core DiscoveryApi, to avoid importing core. + * This is a copy of the DiscoveryApi, to avoid importing core-plugin-api. * * @public */ diff --git a/packages/catalog-client/src/types/fetch.ts b/packages/catalog-client/src/types/fetch.ts new file mode 100644 index 0000000000..59de6a68f1 --- /dev/null +++ b/packages/catalog-client/src/types/fetch.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This is a copy of FetchApi, to avoid importing core-plugin-api. + * + * @public + */ +export type FetchApi = { + fetch: typeof fetch; +}; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 3552a72e3c..f3c6fab4e9 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -25,5 +25,6 @@ export type { CatalogEntityAncestorsResponse, } from './api'; export type { DiscoveryApi } from './discovery'; +export type { FetchApi } from './fetch'; export { CATALOG_FILTER_EXISTS } from './api'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index efd6513cdd..8c0a65c20a 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/catalog-model +## 0.9.8 + +### Patch Changes + +- ad7338bb48: Added an optional `presence` field to Location spec, which describes whether the target of a location is required to exist or not. It defaults to `'required'`, which is the current behaviour of the catalog. + ## 0.9.7 ### Patch Changes diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 52f36493b5..9c0c33fa0e 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -325,6 +325,7 @@ interface LocationEntityV1alpha1 extends Entity { type?: string; target?: string; targets?: string[]; + presence?: 'required' | 'optional'; }; } export { LocationEntityV1alpha1 as LocationEntity }; diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 0ba07aec3c..6fdc89bdfb 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-model", "description": "Types and validators that help describe the model of a Backstage Catalog", - "version": "0.9.7", + "version": "0.9.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -42,7 +42,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.2", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts index 7efec38395..6e78c1b96e 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -102,4 +102,23 @@ describe('LocationV1alpha1Validator', () => { (entity as any).spec.targets = 7; await expect(validator.check(entity)).rejects.toThrow(/targets/); }); + + it('accepts good presence', async () => { + (entity as any).spec.presence = 'required'; + await expect(validator.check(entity)).resolves.toBe(true); + (entity as any).spec.presence = 'optional'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty presence', async () => { + (entity as any).spec.presence = ''; + await expect(validator.check(entity)).rejects.toThrow(/presence/); + }); + + it('rejects wrong presence', async () => { + (entity as any).spec.presence = 7; + await expect(validator.check(entity)).rejects.toThrow(/presence/); + (entity as any).spec.presence = 'nope'; + await expect(validator.check(entity)).rejects.toThrow(/presence/); + }); }); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 1872e43526..93a218aa2d 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -30,6 +30,7 @@ export interface LocationEntityV1alpha1 extends Entity { type?: string; target?: string; targets?: string[]; + presence?: 'required' | 'optional'; }; } diff --git a/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json index d633d30229..b70df9e70e 100644 --- a/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json @@ -59,6 +59,13 @@ ], "minLength": 1 } + }, + "presence": { + "type": "string", + "description": "Whether the presence of the location target is required and it should be considered an error if it can not be found", + "default": "required", + "examples": ["required"], + "enum": ["required", "optional"] } } } diff --git a/packages/cli/.snyk b/packages/cli/.snyk index 612b4b0525..3ad43bd3de 100644 --- a/packages/cli/.snyk +++ b/packages/cli/.snyk @@ -67,4 +67,16 @@ ignore: reason: Prototype pollution is not an effective attack against a CLI as it already executes arbitrary code expires: 2022-03-06T17:18:55.019Z created: 2021-09-06T17:18:55.027Z + + 'snyk:lic:npm:rollup-plugin-dts:LGPL-3.0': + - '*': + reason: Backstage itself does not redistribute this dependency in minified form + expires: 2031-09-06T17:18:55.027Z + created: 2021-09-06T17:18:55.027Z + + 'snyk:lic:npm:axe-core:MPL-2.0': + - '*': + reason: Backstage itself does not redistribute this dependency in minified form + expires: 2031-09-06T17:18:55.027Z + created: 2021-09-06T17:18:55.027Z patch: {} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ed8525a2cc..5d51ea890b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/cli +## 0.10.2 + +### Patch Changes + +- 25dfc2d483: Add support for `.cjs` and `.mjs` extensions in local and dependency modules. + +## 0.10.1 + +### Patch Changes + +- 0ebb05eee2: Add cli option to minify the generated code of a plugin or backend package + + ``` + backstage-cli plugin:build --minify + backstage-cli backend:build --minify + ``` + +- cd450844f6: Updated the frontend plugin template to put React dependencies in `peerDependencies` by default, as well as allowing both React v16 and v17. This change can be applied to existing plugins by running `yarn backstage-cli plugin:diff` within the plugin package directory. + +## 0.10.0 + +### Minor Changes + +- ea99ef5198: Remove the `backend:build-image` command from the CLI and added more deprecation warnings to other deprecated fields like `--lax` and `remove-plugin` + +### Patch Changes + +- e7230ef814: Bump react-dev-utils to v12 +- 416b68675d: build(dependencies): bump `style-loader` from 1.2.1 to 3.3.1 +- Updated dependencies + - @backstage/config-loader@0.8.1 + ## 0.9.1 ### Patch Changes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 1d33372c60..978c598584 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -96,20 +96,22 @@ async function getProjectConfig(targetPath, displayName) { rootDir: path.resolve(targetPath, 'src'), coverageDirectory: path.resolve(targetPath, 'coverage'), coverageProvider: 'v8', - collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], + collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'], moduleNameMapper: { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), }, transform: { - '\\.(js|jsx|ts|tsx)$': require.resolve('./jestSucraseTransform.js'), + '\\.(js|jsx|ts|tsx|mjs|cjs)$': require.resolve( + './jestSucraseTransform.js', + ), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg|eot|woff|woff2|ttf)$': require.resolve('./jestFileTransform.js'), '\\.(yaml)$': require.resolve('jest-transform-yaml'), }, // A bit more opinionated - testMatch: ['**/?(*.)test.{js,jsx,mjs,ts,tsx}'], + testMatch: ['**/?(*.)test.{js,jsx,ts,tsx,mjs,cjs}'], transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`], }; diff --git a/packages/cli/package.json b/packages/cli/package.json index dd0a49cb7f..4b59d46827 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.9.1", + "version": "0.10.2", "private": false, "publishConfig": { "access": "public" @@ -30,7 +30,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.8.0", + "@backstage/config-loader": "^0.8.1", "@backstage/errors": "^0.1.5", "@backstage/types": "^0.1.1", "@hot-loader/react-dom": "^16.13.0", @@ -38,9 +38,9 @@ "@lerna/project": "^4.0.0", "@octokit/request": "^5.4.12", "@rollup/plugin-commonjs": "^21.0.1", - "@rollup/plugin-json": "^4.0.2", + "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.0.0", - "@rollup/plugin-yaml": "^3.0.0", + "@rollup/plugin-yaml": "^3.1.0", "@spotify/eslint-config-base": "^12.0.0", "@spotify/eslint-config-react": "^12.0.0", "@spotify/eslint-config-typescript": "^12.0.0", @@ -62,7 +62,7 @@ "css-loader": "^5.2.6", "dashify": "^2.0.0", "diff": "^5.0.0", - "esbuild": "^0.8.56", + "esbuild": "^0.14.1", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-formatter-friendly": "^7.0.0", @@ -89,20 +89,19 @@ "ora": "^5.3.0", "postcss": "^8.1.0", "process": "^0.11.10", - "react": "^16.0.0", "react-dev-utils": "^12.0.0-next.47", "react-hot-loader": "^4.12.21", "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", - "rollup": "2.44.x", - "rollup-plugin-dts": "^3.0.1", - "rollup-plugin-esbuild": "2.6.x", + "rollup": "^2.60.2", + "rollup-plugin-dts": "^4.0.1", + "rollup-plugin-esbuild": "^4.7.2", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^4.0.0", "rollup-pluginutils": "^2.8.2", "run-script-webpack-plugin": "^0.0.11", "semver": "^7.3.2", - "style-loader": "^1.2.1", + "style-loader": "^3.3.1", "sucrase": "^3.20.2", "tar": "^6.1.2", "terser-webpack-plugin": "^5.1.3", @@ -117,25 +116,25 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", + "@backstage/theme": "^0.2.14", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", "@types/http-proxy": "^1.17.4", - "@types/inquirer": "^7.3.1", + "@types/inquirer": "^8.1.3", "@types/mock-fs": "^4.13.0", "@types/node": "^14.14.32", "@types/recursive-readdir": "^2.2.0", "@types/rollup-plugin-peer-deps-external": "^2.2.0", "@types/rollup-plugin-postcss": "^2.0.0", - "@types/tar": "^4.0.3", + "@types/tar": "^6.1.1", "@types/terser-webpack-plugin": "^5.0.4", "@types/webpack": "^5.28.0", "@types/webpack-dev-server": "^3.11.5", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 22e7fb9de9..4fa3d1ac6e 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -20,8 +20,17 @@ import { buildBundle } from '../../lib/bundler'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; import { loadCliConfig } from '../../lib/config'; import { paths } from '../../lib/paths'; +import chalk from 'chalk'; export default async (cmd: Command) => { + if (cmd.lax) { + console.warn( + chalk.yellow( + `[DEPRECATED] - The --lax option is deprecated and will be removed in the future. Please open an issue towards https://github.com/backstage/backstage that describes your use-case if you need the flag to stay around.`, + ), + ); + } + const { name } = await fs.readJson(paths.resolveTarget('package.json')); await buildBundle({ entry: 'src/index', diff --git a/packages/cli/src/commands/backend/build.ts b/packages/cli/src/commands/backend/build.ts index 6cb1347b1e..8c25f56746 100644 --- a/packages/cli/src/commands/backend/build.ts +++ b/packages/cli/src/commands/backend/build.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { Command } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; -export default async () => { +export default async (cmd: Command) => { await buildPackage({ outputs: new Set([Output.cjs, Output.types]), + minify: cmd.minify, }); }; diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts deleted file mode 100644 index b352e38203..0000000000 --- a/packages/cli/src/commands/backend/buildImage.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { Command } from 'commander'; -import { yellow } from 'chalk'; -import fs from 'fs-extra'; -import { join as joinPath, relative as relativePath } from 'path'; -import { createDistWorkspace } from '../../lib/packager'; -import { paths } from '../../lib/paths'; -import { run } from '../../lib/run'; -import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; - -const PKG_PATH = 'package.json'; - -export default async (cmd: Command) => { - // Skip the preparation steps if we're being asked for help - if (cmd.args.includes('--help')) { - await run('docker', ['image', 'build', '--help']); - return; - } - - console.warn( - yellow(` -The backend:build-image command is deprecated and will be removed in the future. -Please use the backend:bundle command instead along with your own Docker setup. - - https://backstage.io/docs/deployment/docker -`), - ); - - const pkgPath = paths.resolveTarget(PKG_PATH); - const pkg = await fs.readJson(pkgPath); - const appConfigs = await findAppConfigs(); - const npmrc = (await fs.pathExists(paths.resolveTargetRoot('.npmrc'))) - ? ['.npmrc'] - : []; - const tempDistWorkspace = await createDistWorkspace([pkg.name], { - buildDependencies: Boolean(cmd.build), - files: [ - 'package.json', - 'yarn.lock', - ...npmrc, - ...appConfigs, - { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, - ], - parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), - skeleton: 'skeleton.tar', - }); - console.log(`Dist workspace ready at ${tempDistWorkspace}`); - - // all args are forwarded to docker build - await run('docker', ['image', 'build', '.', ...cmd.args], { - cwd: tempDistWorkspace, - }); - - await fs.remove(tempDistWorkspace); -}; - -/** - * Find all config files to copy into the image - */ -async function findAppConfigs(): Promise { - const files = []; - - for (const name of await fs.readdir(paths.targetRoot)) { - if (name.startsWith('app-config.') && name.endsWith('.yaml')) { - files.push(name); - } - } - - if (paths.targetRoot !== paths.targetDir) { - const dirPath = relativePath(paths.targetRoot, paths.targetDir); - - for (const name of await fs.readdir(paths.targetDir)) { - if (name.startsWith('app-config.') && name.endsWith('.yaml')) { - files.push(joinPath(dirPath, name)); - } - } - } - - return files; -} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a37d5e9ffe..94a8104ff1 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -30,7 +30,10 @@ export function registerCommands(program: CommanderStatic) { .command('app:build') .description('Build an app for a production release') .option('--stats', 'Write bundle stats to output directory') - .option('--lax', 'Do not require environment variables to be set') + .option( + '--lax', + '[DEPRECATED] - Do not require environment variables to be set', + ) .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); @@ -44,6 +47,7 @@ export function registerCommands(program: CommanderStatic) { program .command('backend:build') .description('Build a backend plugin') + .option('--minify', 'Minify the generated code') .action(lazy(() => import('./backend/build').then(m => m.default))); program @@ -55,16 +59,6 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); - program - .command('backend:build-image') - .allowUnknownOption(true) - .helpOption(', --backstage-cli-help') // Let docker handle --help - .option('--build', 'Build packages before packing them into the image') - .description( - 'Bundles the package into a docker image. This command is deprecated and will be removed.', - ) - .action(lazy(() => import('./backend/buildImage').then(m => m.default))); - program .command('backend:dev') .description('Start local development server with HMR for the backend') @@ -115,7 +109,7 @@ export function registerCommands(program: CommanderStatic) { program .command('remove-plugin') - .description('Removes plugin in the current repository') + .description('[DEPRECATED] - Removes plugin in the current repository') .action( lazy(() => import('./remove-plugin/removePlugin').then(m => m.default)), ); @@ -123,6 +117,7 @@ export function registerCommands(program: CommanderStatic) { program .command('plugin:build') .description('Build a plugin') + .option('--minify', 'Minify the generated code') .action(lazy(() => import('./plugin/build').then(m => m.default))); program diff --git a/packages/cli/src/commands/install/install.ts b/packages/cli/src/commands/install/install.ts index cc6e46cfa2..bd3a05ebc1 100644 --- a/packages/cli/src/commands/install/install.ts +++ b/packages/cli/src/commands/install/install.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Step, PackageWithInstallRecipe } from './types'; +import { + Step, + PackageWithInstallRecipe, + PeerPluginDependencies, +} from './types'; import { fetchPackageInfo } from '../../lib/versioning'; import { NotFoundError } from '../../lib/errors'; import * as stepDefinitionMap from './steps'; @@ -52,7 +56,10 @@ type Steps = Array<{ }>; class PluginInstaller { - static async resolveSteps(pkg: PackageWithInstallRecipe) { + static async resolveSteps( + pkg: PackageWithInstallRecipe, + versionToInstall?: string, + ) { const steps: Steps = []; // collectDependencies @@ -62,7 +69,7 @@ class PluginInstaller { target: 'packages/app', type: 'dependencies' as const, name: pkg.name, - query: `^${pkg.version}`, + query: versionToInstall || `^${pkg.version}`, }); steps.push({ type: 'dependencies', @@ -97,22 +104,64 @@ class PluginInstaller { } } -export default async (pluginId?: string, cmd?: Command) => { - // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? +async function installPluginAndPeerPlugins(pkg: PackageWithInstallRecipe) { + const pluginDeps: PeerPluginDependencies = new Map(); + pluginDeps.set(pkg.name, { pkg }); + await loadPeerPluginDeps(pkg, pluginDeps); - let pkg: PackageWithInstallRecipe; - if (pluginId) { - pkg = await fetchPluginPackage(pluginId); - } else if (cmd?.from) { - // TODO(himanshu): Also support reading directly from url - pkg = await fs.readJson(cmd.from); - } else { + console.log(`Installing ${pkg.name} AND any peer plugin dependencies.`); + for (const [_pluginDepName, pluginDep] of pluginDeps.entries()) { + const { pkg: pluginDepPkg, versionToInstall } = pluginDep; + console.log( + `Installing plugin: ${pluginDepPkg.name}: ${ + versionToInstall || pluginDepPkg.version + }`, + ); + const steps = await PluginInstaller.resolveSteps( + pluginDepPkg, + versionToInstall, + ); + const installer = new PluginInstaller(steps); + await installer.run(); + } +} + +async function loadPackageJson( + plugin: string, +): Promise { + if (plugin.endsWith('package.json')) { + // Install from local package if pluginId is a package.json file - needs to be absolute path + return await fs.readJson(plugin); + } + return await fetchPluginPackage(plugin); +} + +async function loadPeerPluginDeps( + pkg: PackageWithInstallRecipe, + pluginMap: PeerPluginDependencies, +) { + for (const [pluginId, pluginVersion] of Object.entries( + pkg.experimentalInstallationRecipe?.peerPluginDependencies ?? {}, + )) { + const depPkg = await loadPackageJson(pluginId); + if (!pluginMap.get(depPkg.name)) { + pluginMap.set(depPkg.name, { + pkg: depPkg, + versionToInstall: pluginVersion, + }); + await loadPeerPluginDeps(depPkg, pluginMap); + } + } +} + +export default async (pluginId?: string, cmd?: Command) => { + const from = pluginId || cmd?.from; + // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? + if (!from) { throw new Error( 'Missing both or a package.json file path in the --from flag.', ); } - - const steps = await PluginInstaller.resolveSteps(pkg); - const installer = new PluginInstaller(steps); - await installer.run(); + const pkg = await loadPackageJson(from); + await installPluginAndPeerPlugins(pkg); }; diff --git a/packages/cli/src/commands/install/types.ts b/packages/cli/src/commands/install/types.ts index 3105f78822..1fa52a5e82 100644 --- a/packages/cli/src/commands/install/types.ts +++ b/packages/cli/src/commands/install/types.ts @@ -38,6 +38,7 @@ export type SerializedStep = { export type InstallationRecipe = { type?: 'frontend' | 'backend'; steps: SerializedStep[]; + peerPluginDependencies: { [pluginId: string]: string }; }; /** package.json data */ @@ -46,6 +47,14 @@ export type PackageWithInstallRecipe = YarnInfoInspectData & { experimentalInstallationRecipe?: InstallationRecipe; }; +export type PeerPluginDependencies = Map< + string, + { + pkg: PackageWithInstallRecipe; + versionToInstall?: string; + } +>; + export interface Step { run(): Promise; } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 41b454ffff..5c3e0a88e3 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -20,7 +20,7 @@ import { paths } from '../lib/paths'; export default async (cmd: Command, cmdArgs: string[]) => { const args = [ - '--ext=js,jsx,ts,tsx', + '--ext=js,jsx,ts,tsx,mjs,cjs', '--max-warnings=0', `--format=${cmd.format}`, ...(cmdArgs ?? [paths.targetDir]), diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 8a63a017a2..9c5e173989 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { Command } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; -export default async () => { +export default async (cmd: Command) => { await buildPackage({ outputs: new Set([Output.esm, Output.types]), + minify: cmd.minify, }); }; diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 11fdc8bb11..a82bfda36d 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -184,6 +184,12 @@ export const removeReferencesFromAppPackage = async ( }; export default async () => { + console.warn( + chalk.yellow( + '[DEPRECATED] - The remove-plugin command is deprecated and will be removed in the future.', + ), + ); + const questions: Question[] = [ { type: 'input', diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 3c95a87bdd..ba60e8fea9 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -100,6 +100,7 @@ export const makeConfigs = async ( }), esbuild({ target: 'es2019', + minify: options.minify, }), ], }); diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index f75d1b1dda..e88c388013 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -22,4 +22,5 @@ export enum Output { export type BuildOptions = { outputs: Set; + minify?: boolean; }; diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 863728b8f8..d1d26f383d 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -61,7 +61,7 @@ export const transforms = (options: TransformOptions): Transforms => { }, }, { - test: /\.(jsx?|mjs)$/, + test: /\.(jsx?|mjs|cjs)$/, exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { @@ -71,7 +71,7 @@ export const transforms = (options: TransformOptions): Transforms => { }, }, { - test: /\.m?js/, + test: /\.(js|mjs|cjs)/, resolve: { fullySpecified: false, }, diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index d343a554f8..f85db276b2 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -59,7 +59,6 @@ export async function loadCliConfig(options: Options) { ? async name => process.env[name] || 'x' : undefined, configRoot: paths.targetRoot, - configPaths: [], configTargets: configTargets, }); diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts index f8bbfa09ce..5bd90e3d22 100644 --- a/packages/cli/src/lib/diff/handlers.ts +++ b/packages/cli/src/lib/diff/handlers.ts @@ -75,7 +75,9 @@ class PackageJsonHandler { await this.syncScripts(); await this.syncPublishConfig(); await this.syncDependencies('dependencies'); + await this.syncDependencies('peerDependencies', true); await this.syncDependencies('devDependencies'); + await this.syncReactDeps(); } // Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change. @@ -207,12 +209,12 @@ class PackageJsonHandler { } } - private async syncDependencies(fieldName: string) { + private async syncDependencies(fieldName: string, required: boolean = false) { const pkgDeps = this.pkg[fieldName]; const targetDeps = (this.targetPkg[fieldName] = this.targetPkg[fieldName] || {}); - if (!pkgDeps) { + if (!pkgDeps && !required) { return; } @@ -231,10 +233,26 @@ class PackageJsonHandler { continue; } - await this.syncField(key, pkgDeps, targetDeps, fieldName, true, true); + await this.syncField( + key, + pkgDeps, + targetDeps, + fieldName, + true, + !required, + ); } } + private async syncReactDeps() { + const targetDeps = (this.targetPkg.dependencies = + this.targetPkg.dependencies || {}); + + // Remove these from from deps since they're now in peerDeps + await this.syncField('react', {}, targetDeps, 'dependencies'); + await this.syncField('react-dom', {}, targetDeps, 'dependencies'); + } + private async write() { await this.writeFunc(`${JSON.stringify(this.targetPkg, null, 2)}\n`); } diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index 3b512b22c3..d65e1e06d6 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -66,7 +66,7 @@ export const version = findVersion(); export const isDev = fs.pathExistsSync(paths.resolveOwn('src')); export function createPackageVersionProvider(lockfile?: Lockfile) { - return (name: string, versionHint?: string) => { + return (name: string, versionHint?: string): string => { const packageVersion = packageVersions[name]; const targetVersion = versionHint || packageVersion; if (!targetVersion) { @@ -94,6 +94,6 @@ export function createPackageVersionProvider(lockfile?: Lockfile) { if (semver.parse(versionHint)?.prerelease.length) { return versionHint!; } - return `^${versionHint}`; + return versionHint?.match(/^[\d\.]+$/) ? `^${versionHint}` : versionHint!; }; } diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 624302da93..7461b0a57b 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -32,10 +32,11 @@ "@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}", "@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}", "@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.57'}}", - "react": "{{versionQuery 'react' '16.13.1'}}", - "react-dom": "{{versionQuery 'react-dom' '16.13.1'}}", "react-use": "{{versionQuery 'react-use' '17.2.4'}}" }, + "peerDependencies": { + "react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0'}}" + }, "devDependencies": { "@backstage/cli": "{{versionQuery '@backstage/cli'}}", "@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}", diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index 8c484fe2ac..803579a963 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/codemods +## 0.1.26 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.2.1 + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + +## 0.1.25 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/core-app-api@0.2.0 + +## 0.1.24 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/core-plugin-api@0.2.2 + - @backstage/core-app-api@0.1.24 + ## 0.1.23 ### Patch Changes diff --git a/packages/codemods/package.json b/packages/codemods/package.json index e95367a5ac..8261e30547 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/codemods", "description": "A collection of codemods for Backstage projects", - "version": "0.1.23", + "version": "0.1.26", "private": false, "publishConfig": { "access": "public", diff --git a/packages/codemods/src/tests/core-imports.test.ts b/packages/codemods/src/tests/core-imports.test.ts index d8dea7eb90..0b416e5c8e 100644 --- a/packages/codemods/src/tests/core-imports.test.ts +++ b/packages/codemods/src/tests/core-imports.test.ts @@ -45,9 +45,9 @@ return something() it('should refactor imports', () => { const input = ` /* COPYRIGHT: ME */ -import { Button as MyButton, createApiRef, createApp } from '@backstage/core'; +import { Button as MyButton, createApiRef, createSpecializedApp } from '@backstage/core'; -const app = createApp(); +const app = createSpecializedApp(); const apiRef = createApiRef(); const button = `; @@ -57,9 +57,9 @@ const button = import { Button as MyButton } from '@backstage/core-components'; import { createApiRef } from '@backstage/core-plugin-api'; -import { createApp } from '@backstage/core-app-api'; +import { createSpecializedApp } from '@backstage/core-app-api'; -const app = createApp(); +const app = createSpecializedApp(); const apiRef = createApiRef(); const button = `; diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 00ace6e704..93fb5dd02c 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/config-loader +## 0.8.1 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- 4bea7b81d3: Uses key visibility as fallback in non-object arrays + ## 0.8.0 ### Minor Changes diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index e0fab50e5b..269159aab4 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -45,9 +45,7 @@ export function loadConfig( // @public export type LoadConfigOptions = { configRoot: string; - configPaths: string[]; configTargets: ConfigTarget[]; - env?: string; experimentalEnvFunc?: (name: string) => Promise; remote?: LoadConfigOptionsRemote; watch?: LoadConfigOptionsWatch; @@ -103,8 +101,4 @@ export type TransformFunc = ( visibility: ConfigVisibility; }, ) => T | undefined; - -// Warnings were encountered during analysis: -// -// src/loader.d.ts:33:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/config-loader" does not have an export "configTargets" ``` diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index c275a1f045..78f8da3443 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.8.0", + "version": "0.8.1", "private": false, "publishConfig": { "access": "public", diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index 0395c7da6a..be07b915ed 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -20,6 +20,7 @@ import { filterByVisibility, filterErrorsByVisibility } from './filtering'; const data = { arr: ['f', 'b', 's'], + arrU: ['f', 'b', 't'], objArr: [ { f: 1, b: 2, s: 3 }, { f: 4, b: 5, s: 6 }, @@ -40,6 +41,8 @@ const data = { const visibility = new Map( Object.entries({ + '/arrU': 'frontend', + '/arrU/2': 'backend', '/arr/0': 'frontend', '/arr/1': 'backend', '/arr/2': 'secret', @@ -71,6 +74,9 @@ describe('filterByVisibility', () => { 'arr[0]', 'arr[1]', 'arr[2]', + 'arrU[0]', + 'arrU[1]', + 'arrU[2]', 'objArr[0].f', 'objArr[0].b', 'objArr[0].s', @@ -97,10 +103,12 @@ describe('filterByVisibility', () => { obj: { f: 'a' }, arrF: [], objF: {}, + arrU: ['f', 'b'], }, filteredKeys: [ 'arr[1]', 'arr[2]', + 'arrU[2]', 'objArr[0].b', 'objArr[0].s', 'objArr[1].b', @@ -120,6 +128,7 @@ describe('filterByVisibility', () => { { data: { arr: ['b'], + arrU: ['t'], objArr: [{ b: 2 }, { b: 5 }], obj: { b: {} }, arrF: [{ never: 'here' }], @@ -132,6 +141,8 @@ describe('filterByVisibility', () => { filteredKeys: [ 'arr[0]', 'arr[2]', + 'arrU[0]', + 'arrU[1]', 'objArr[0].f', 'objArr[0].s', 'objArr[1].f', @@ -154,6 +165,9 @@ describe('filterByVisibility', () => { filteredKeys: [ 'arr[0]', 'arr[1]', + 'arrU[0]', + 'arrU[1]', + 'arrU[2]', 'objArr[0].f', 'objArr[0].b', 'objArr[1].f', diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index b4226d7dad..b2f39d3cc4 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -61,11 +61,17 @@ export function filterByVisibility( const arr = new Array(); for (const [index, value] of jsonVal.entries()) { - const out = transform( - value, + let path = visibilityPath; + const hasVisibilityInIndex = visibilityByDataPath.get( `${visibilityPath}/${index}`, - `${filterPath}[${index}]`, ); + + if (hasVisibilityInIndex || typeof value === 'object') { + path = `${visibilityPath}/${index}`; + } + + const out = transform(value, path, `${filterPath}[${index}]`); + if (out !== undefined) { arr.push(out); } diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index b5c986eeb9..9faeda64ea 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -118,9 +118,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], - env: 'production', }), ).resolves.toEqual({ appConfigs: [ @@ -146,9 +144,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ url: configUrl }], - env: 'production', remote: { reloadIntervalSeconds: 30, }, @@ -173,9 +169,10 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: ['/root/app-config2.yaml'], - configTargets: [{ path: '/root/app-config.yaml' }], - env: 'production', + configTargets: [ + { path: '/root/app-config.yaml' }, + { path: '/root/app-config2.yaml' }, + ], }), ).resolves.toEqual({ appConfigs: [ @@ -207,9 +204,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: ['/root/app-config.yaml'], configTargets: [{ path: '/root/app-config.yaml' }], - env: 'production', }), ).resolves.toEqual({ appConfigs: [ @@ -231,12 +226,10 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [ { path: '/root/app-config.yaml' }, { path: '/root/app-config.development.yaml' }, ], - env: 'development', }), ).resolves.toEqual({ appConfigs: [ @@ -274,9 +267,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ path: '/root/app-config.substitute.yaml' }], - env: 'development', }), ).resolves.toEqual({ appConfigs: [ @@ -302,7 +293,6 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], watch: { onChange: onChange.resolve, @@ -353,7 +343,6 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ url: configUrl }], watch: { onChange: onChange.resolve, @@ -401,7 +390,6 @@ describe('loadConfig', () => { await loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], watch: { onChange: () => { diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 6a92ed519d..e16a49df89 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -45,7 +45,7 @@ export type LoadConfigOptionsWatch = { export type LoadConfigOptionsRemote = { /** - * An optional remote config reloading period, in seconds + * A remote config reloading period, in seconds */ reloadIntervalSeconds: number; }; @@ -59,17 +59,9 @@ export type LoadConfigOptions = { // The root directory of the config loading context. Used to find default configs. configRoot: string; - /** Absolute paths to load config files from. Configs from earlier paths have lower priority. - * @deprecated Use {@link configTargets} instead. - */ - configPaths: string[]; - // Paths to load config files from. Configs from earlier paths have lower priority. configTargets: ConfigTarget[]; - /** @deprecated This option has been removed */ - env?: string; - /** * Custom environment variable loading function * @@ -114,20 +106,21 @@ export async function loadConfig( .filter((e): e is { path: string } => e.hasOwnProperty('path')) .map(configTarget => configTarget.path); - // Append deprecated configPaths to the absolute config paths received via configTargets. - options.configPaths.forEach(cp => { - if (!configPaths.includes(cp)) { - configPaths.push(cp); - } - }); - const configUrls: string[] = options.configTargets .slice() .filter((e): e is { url: string } => e.hasOwnProperty('url')) .map(configTarget => configTarget.url); - if (remote === undefined && configUrls.length > 0) { - throw new Error(`Remote config detected but this feature is turned off`); + if (remote === undefined) { + if (configUrls.length > 0) { + throw new Error( + `Please make sure you are passing the remote option when loading remote configurations. See https://backstage.io/docs/conf/writing#configuration-files for detailed info.`, + ); + } + } else if (remote.reloadIntervalSeconds <= 0) { + throw new Error( + `Remote config must be contain a non zero reloadIntervalSeconds: value`, + ); } // If no paths are provided, we default to reading diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index ad71ad6fe2..66e0c7d6ed 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,71 @@ # @backstage/core-app-api +## 0.2.1 + +### Patch Changes + +- c11ce4f552: Deprecated `Auth0Auth`, pointing to using `OAuth2` directly instead. +- 9d6503e86c: Switched out usage of deprecated `OAuthRequestApi` types from `@backstage/core-plugin-api`. +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + +## 0.2.0 + +### Minor Changes + +- a036b65c2f: **BREAKING CHANGE** + + The app `SignInPage` component has been updated to switch out the `onResult` callback for a new `onSignInSuccess` callback. This is an immediate breaking change without any deprecation period, as it was deemed to be the way of making this change that had the lowest impact. + + The new `onSignInSuccess` callback directly accepts an implementation of an `IdentityApi`, rather than a `SignInResult`. The `SignInPage` from `@backstage/core-component` has been updated to fit this new API, and as long as you pass on `props` directly you should not see any breakage. + + However, if you implement your own custom `SignInPage`, then this will be a breaking change and you need to migrate over to using the new callback. While doing so you can take advantage of the `UserIdentity.fromLegacy` helper from `@backstage/core-components` to make the migration simpler by still using the `SignInResult` type. This helper is also deprecated though and is only provided for immediate migration. Long-term it will be necessary to build the `IdentityApi` using for example `UserIdentity.create` instead. + + The following is an example of how you can migrate existing usage immediately using `UserIdentity.fromLegacy`: + + ```ts + onResult(signInResult); + // becomes + onSignInSuccess(UserIdentity.fromLegacy(signInResult)); + ``` + + The following is an example of how implement the new `onSignInSuccess` callback of the `SignInPage` using `UserIdentity.create`: + + ```ts + const identityResponse = await authApi.getBackstageIdentity(); + // Profile is optional and will be removed, but allows the + // synchronous getProfile method of the IdentityApi to be used. + const profile = await authApi.getProfile(); + onSignInSuccess( + UserIdentity.create({ + identity: identityResponse.identity, + authApi, + profile, + }), + ); + ``` + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/app-defaults@0.1.2 + - @backstage/version-bridge@0.1.1 + +## 0.1.24 + +### Patch Changes + +- 0e7f256034: Fixed a bug where `useRouteRef` would fail in situations where relative navigation was needed and the app was is mounted on a sub-path. This would typically show up as a failure to navigate to a tab on an entity page. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.1.23 ### Patch Changes diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index e69ee4f325..8746211a45 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -17,17 +17,15 @@ import { AppTheme } from '@backstage/core-plugin-api'; import { AppThemeApi } from '@backstage/core-plugin-api'; import { atlassianAuthApiRef } from '@backstage/core-plugin-api'; import { auth0AuthApiRef } from '@backstage/core-plugin-api'; -import { AuthProvider } from '@backstage/core-plugin-api'; -import { AuthRequester } from '@backstage/core-plugin-api'; -import { AuthRequesterOptions } from '@backstage/core-plugin-api'; +import { AuthProviderInfo } from '@backstage/core-plugin-api'; import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentity } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; +import { Config } from '@backstage/config'; import { ConfigReader } from '@backstage/config'; -import { createApp as createApp_2 } from '@backstage/app-defaults'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; @@ -36,17 +34,21 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FeatureFlag } from '@backstage/core-plugin-api'; import { FeatureFlagsApi } from '@backstage/core-plugin-api'; import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; import { googleAuthApiRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; import { OAuthApi } from '@backstage/core-plugin-api'; import { OAuthRequestApi } from '@backstage/core-plugin-api'; +import { OAuthRequester } from '@backstage/core-plugin-api'; +import { OAuthRequesterOptions } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; -import { PendingAuthRequest } from '@backstage/core-plugin-api'; +import { PendingOAuthRequest } from '@backstage/core-plugin-api'; import { PluginOutput } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; @@ -250,33 +252,20 @@ export class AppThemeSelector implements AppThemeApi { // @public export class AtlassianAuth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - }: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T; } -// @public +// @public @deprecated export class Auth0Auth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; } // @public export type AuthApiCreateOptions = { discoveryApi: DiscoveryApi; environment?: string; - provider?: AuthProvider & { - id: string; - }; + provider?: AuthProviderInfo; }; // @public @@ -287,29 +276,10 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public @deprecated -export type BackstagePluginWithAnyOutput = Omit< - BackstagePlugin, - 'output' -> & { - output(): ( - | PluginOutput - | { - type: string; - } - )[]; -}; - // @public export class BitbucketAuth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T; } // @public @@ -331,10 +301,11 @@ export type BootErrorPageProps = { export { ConfigReader }; -// @public @deprecated -export function createApp( - options?: Parameters[0], -): BackstageApp & AppContext; +// @public +export function createFetchApi(options: { + baseImplementation?: typeof fetch | undefined; + middleware?: FetchMiddleware | FetchMiddleware[] | undefined; +}): FetchApi; // @public export function createSpecializedApp(options: AppOptions): BackstageApp; @@ -387,6 +358,27 @@ export type FeatureFlaggedProps = { } ); +// @public +export interface FetchMiddleware { + apply(next: typeof fetch): typeof fetch; +} + +// @public +export class FetchMiddlewares { + static injectIdentityAuth(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): FetchMiddleware; + static resolvePluginProtocol(options: { + discoveryApi: DiscoveryApi; + }): FetchMiddleware; +} + // @public export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null; @@ -397,18 +389,8 @@ export type FlatRoutesProps = { // @public export class GithubAuth implements OAuthApi, SessionApi { - // Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts - // - // @deprecated - constructor(sessionManager: SessionManager); // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - }: OAuthApiCreateOptions): GithubAuth; + static create(options: OAuthApiCreateOptions): GithubAuth; // (undocumented) getAccessToken(scope?: string, options?: AuthRequestOptions): Promise; // (undocumented) @@ -441,25 +423,13 @@ export type GithubSession = { // @public export class GitlabAuth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T; } // @public export class GoogleAuth { // (undocumented) - static create({ - discoveryApi, - oauthRequestApi, - environment, - provider, - defaultScopes, - }: OAuthApiCreateOptions): typeof googleAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } // @public @@ -477,13 +447,7 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi { // @public export class MicrosoftAuth { // (undocumented) - static create({ - environment, - provider, - oauthRequestApi, - discoveryApi, - defaultScopes, - }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; } // @public @@ -501,20 +465,8 @@ export class OAuth2 BackstageIdentityApi, SessionApi { - // @deprecated - constructor(options: { - sessionManager: SessionManager; - scopeTransform: (scopes: string[]) => string[]; - }); // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - scopeTransform, - }: OAuth2CreateOptions): OAuth2; + static create(options: OAuth2CreateOptions): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -562,32 +514,23 @@ export type OAuthApiCreateOptions = AuthApiCreateOptions & { // @public export class OAuthRequestManager implements OAuthRequestApi { // (undocumented) - authRequest$(): Observable; + authRequest$(): Observable; // (undocumented) - createAuthRequester(options: AuthRequesterOptions): AuthRequester; + createAuthRequester(options: OAuthRequesterOptions): OAuthRequester; } // @public export class OktaAuth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - defaultScopes, - }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T; + static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T; } // @public export class OneLoginAuth { // (undocumented) - static create({ - discoveryApi, - environment, - provider, - oauthRequestApi, - }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T; + static create( + options: OneLoginAuthCreateOptions, + ): typeof oneloginAuthApiRef.T; } // @public @@ -595,23 +538,15 @@ export type OneLoginAuthCreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; - provider?: AuthProvider & { - id: string; - }; + provider?: AuthProviderInfo; }; // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - // @deprecated - constructor(sessionManager: SessionManager); // (undocumented) - static create({ - discoveryApi, - environment, - provider, - }: AuthApiCreateOptions): SamlAuth; + static create(options: AuthApiCreateOptions): SamlAuth; // (undocumented) getBackstageIdentity( options?: AuthRequestOptions, @@ -635,10 +570,10 @@ export type SamlSession = { // @public export type SignInPageProps = { - onResult(result: SignInResult): void; + onSignInSuccess(identityApi: IdentityApi): void; }; -// @public +// @public @deprecated export type SignInResult = { userId: string; profile: ProfileInfo; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 6777088b68..e1a982b7bd 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-app-api", "description": "Core app API used by Backstage apps", - "version": "0.1.23", + "version": "0.2.1", "private": false, "publishConfig": { "access": "public", @@ -29,26 +29,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/app-defaults": "^0.1.1", - "@backstage/core-components": "^0.7.5", + "@backstage/app-defaults": "^0.1.2", + "@backstage/core-components": "^0.8.1", "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", - "@backstage/version-bridge": "^0.1.0", + "@backstage/version-bridge": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@types/react": "*", "@types/prop-types": "^15.7.3", "prop-types": "^15.7.2", - "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.8.15", + "zod": "^3.11.6" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts new file mode 100644 index 0000000000..1cf09ff20d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { IdentityAuthInjectorFetchMiddleware } from './IdentityAuthInjectorFetchMiddleware'; +import { PluginProtocolResolverFetchMiddleware } from './PluginProtocolResolverFetchMiddleware'; +import { FetchMiddleware } from './types'; + +/** + * A collection of common middlewares for the FetchApi. + * + * @public + */ +export class FetchMiddlewares { + /** + * Handles translation from `plugin://` URLs to concrete http(s) URLs based on + * the discovery API. + * + * @remarks + * + * If the request is for `plugin://catalog/entities?filter=x=y`, the discovery + * API will be queried for `'catalog'`. If it returned + * `https://backstage.example.net/api/catalog`, the resulting query would be + * `https://backstage.example.net/api/catalog/entities?filter=x=y`. + * + * If the incoming URL protocol was not `plugin`, the request is just passed + * through verbatim to the underlying implementation. + */ + static resolvePluginProtocol(options: { + discoveryApi: DiscoveryApi; + }): FetchMiddleware { + return new PluginProtocolResolverFetchMiddleware(options.discoveryApi); + } + + /** + * Injects a Backstage token header when the user is signed in. + * + * @remarks + * + * Per default, an `Authorization: Bearer ` is generated. This can be + * customized using the `header` option. + * + * The header injection only happens on allowlisted URLs. Per default, if the + * `config` option is passed in, the `backend.baseUrl` is allowlisted, unless + * the `urlPrefixAllowlist` option is passed in, in which case it takes + * precedence. If you pass in neither config nor an allowlist, the middleware + * will have no effect. + */ + static injectIdentityAuth(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): FetchMiddleware { + return IdentityAuthInjectorFetchMiddleware.create(options); + } + + private constructor() {} +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts new file mode 100644 index 0000000000..b69a4b9f7a --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { IdentityApi } from '@backstage/core-plugin-api'; +import { IdentityAuthInjectorFetchMiddleware } from './IdentityAuthInjectorFetchMiddleware'; + +describe('IdentityAuthInjectorFetchMiddleware', () => { + it('creates using defaults', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + }); + expect(middleware.urlPrefixAllowlist).toEqual([]); + expect(middleware.headerName).toEqual('authorization'); + expect(middleware.headerValue('t')).toEqual('Bearer t'); + }); + + it('creates using config', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + config: new ConfigReader({ + backend: { baseUrl: 'https://example.com/api' }, + }), + header: { name: 'auth', value: t => `${t}!` }, + }); + expect(middleware.urlPrefixAllowlist).toEqual(['https://example.com/api']); + expect(middleware.headerName).toEqual('auth'); + expect(middleware.headerValue('t')).toEqual('t!'); + }); + + it('creates using explicit allowlist', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + config: new ConfigReader({ + backend: { baseUrl: 'https://example.com/api' }, + }), + urlPrefixAllowlist: ['https://a.com', 'http://b.com:8080/'], + }); + expect(middleware.urlPrefixAllowlist).toEqual([ + 'https://a.com', + 'http://b.com:8080', + ]); + }); + + it('injects the header only when a token is available', async () => { + const tokenFunction = jest.fn(); + const identityApi = { + getCredentials: tokenFunction, + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com'], + 'Authorization', + token => `Bearer ${token}`, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + // No token available + tokenFunction.mockResolvedValueOnce({ token: undefined }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual([]); + + // Supply a token, header gets added + tokenFunction.mockResolvedValueOnce({ token: 'token' }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual([ + ['authorization', 'Bearer token'], + ]); + + // Token no longer available + tokenFunction.mockResolvedValueOnce({ token: undefined }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[2][0].headers.entries()]).toEqual([]); + }); + + it('does not overwrite an existing header with the same name', async () => { + const identityApi = { + getCredentials: () => ({ token: 'token' }), + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com'], + 'Authorization', + token => `Bearer ${token}`, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + // No token available + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual([ + ['authorization', 'Bearer token'], + ]); + + // Supply a token, header gets added + await outer( + new Request('https://example.com', { + headers: { authorization: 'do-not-clobber' }, + }), + ); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual([ + ['authorization', 'do-not-clobber'], + ]); + }); + + it('does not affect requests outside the allowlist', async () => { + const identityApi = { + getCredentials: () => ({ token: 'token' }), + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com:8080/root'], + 'Authorization', + token => `Bearer ${token}`, + ); + + const inner = jest.fn(); + const outer = middleware.apply(inner); + + await outer(new Request('https://example.com:8080/root')); + await outer(new Request('https://example.com:8080/root/sub')); + await outer(new Request('https://example.com:8080/root2')); + await outer(new Request('https://example.com/root')); + await outer(new Request('http://example.com:8080/root')); + await outer(new Request('https://example.com/root')); + + const no: string[][] = []; + const yes: string[][] = [['authorization', 'Bearer token']]; + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual(yes); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual(yes); + expect([...inner.mock.calls[2][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[3][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[4][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[5][0].headers.entries()]).toEqual(no); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts new file mode 100644 index 0000000000..46ada0a505 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; +import { IdentityApi } from '@backstage/core-plugin-api'; +import { FetchMiddleware } from './types'; + +/** + * A fetch middleware, which injects a Backstage token header when the user is + * signed in. + */ +export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { + static create(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): IdentityAuthInjectorFetchMiddleware { + const allowlist: string[] = []; + if (options.urlPrefixAllowlist) { + allowlist.push(...options.urlPrefixAllowlist); + } else if (options.config) { + allowlist.push(options.config.getString('backend.baseUrl')); + } + + const headerName = options.header?.name || 'authorization'; + const headerValue = options.header?.value || (token => `Bearer ${token}`); + + return new IdentityAuthInjectorFetchMiddleware( + options.identityApi, + allowlist.map(prefix => prefix.replace(/\/$/, '')), + headerName, + headerValue, + ); + } + + constructor( + public readonly identityApi: IdentityApi, + public readonly urlPrefixAllowlist: string[], + public readonly headerName: string, + public readonly headerValue: (pluginId: string) => string, + ) {} + + apply(next: typeof fetch): typeof fetch { + return async (input, init) => { + // Skip this middleware if the header already exists, or if the URL + // doesn't match any of the allowlist items, or if there was no token + const request = new Request(input, init); + const { token } = await this.identityApi.getCredentials(); + if ( + request.headers.get(this.headerName) || + !this.urlPrefixAllowlist.some( + prefix => + request.url === prefix || request.url.startsWith(`${prefix}/`), + ) || + typeof token !== 'string' || + !token + ) { + return next(input, init); + } + + request.headers.set(this.headerName, this.headerValue(token)); + return next(request); + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts new file mode 100644 index 0000000000..2f4ec6b536 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import { PluginProtocolResolverFetchMiddleware } from './PluginProtocolResolverFetchMiddleware'; + +describe('PluginProtocolResolverFetchMiddleware', () => { + it.each([['https://passthrough.com/a']])( + 'passes through regular URLs, %p', + async url => { + const resolve = jest.fn(); + const discoveryApi = { getBaseUrl: resolve } as unknown as DiscoveryApi; + const middleware = new PluginProtocolResolverFetchMiddleware( + discoveryApi, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + await outer(url); + expect(inner.mock.calls[0][0]).toBe(url); + expect(resolve).not.toBeCalled(); + }, + ); + + it.each([ + [ + 'plugin://my-plugin/sub/path', + 'my-plugin', + 'https://real.com/base', + 'https://real.com/base/sub/path', + ], + [ + 'plugin://my-plugin/sub/path/', + 'my-plugin', + 'https://real.com/base/', + 'https://real.com/base/sub/path/', + ], + ['plugin://x', 'x', 'http://real.com:8080', 'http://real.com:8080'], + [ + 'plugin://x/a/b?c=d&e=f#g', + 'x', + 'https://real.com/base', + 'https://real.com/base/a/b?c=d&e=f#g', + ], + [ + 'plugin://x?c=d&e=f#g', + 'x', + 'https://real.com:8080/base', + 'https://real.com:8080/base?c=d&e=f#g', + ], + [ + 'plugin://username:password@x?c=d&e=f#g', + 'x', + 'https://real.com:8080/base', + 'https://username:password@real.com:8080/base?c=d&e=f#g', + ], + [ + 'plugin://x?c=d&e=f#g', + 'x', + 'https://username:password@real.com:8080/base', + 'https://username:password@real.com:8080/base?c=d&e=f#g', + ], + ])( + 'resolves backstage URLs, %p', + async (original, host, resolved, result) => { + const resolve = jest.fn(); + const discoveryApi = { getBaseUrl: resolve } as unknown as DiscoveryApi; + const middleware = new PluginProtocolResolverFetchMiddleware( + discoveryApi, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + resolve.mockResolvedValueOnce(resolved); + await outer(original); + expect(inner.mock.calls[0][0]).toBe(result); + expect(resolve).toHaveBeenLastCalledWith(host); + }, + ); +}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts new file mode 100644 index 0000000000..72834731fe --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import { FetchMiddleware } from './types'; + +function join(left: string, right: string): string { + if (!right || right === '/') { + return left; + } + + return `${left.replace(/\/$/, '')}/${right.replace(/^\//, '')}`; +} + +/** + * Handles translation from plugin://some-plugin-id/ to concrete http(s) + * URLs. + */ +export class PluginProtocolResolverFetchMiddleware implements FetchMiddleware { + constructor(private readonly discoveryApi: DiscoveryApi) {} + + apply(next: typeof fetch): typeof fetch { + return async (input, init) => { + const request = new Request(input, init); + const prefix = 'plugin://'; + + if (!request.url.startsWith(prefix)) { + return next(input, init); + } + + // Switch to a known protocol, since browser URL parsing misbehaves wildly + // on foreign protocols + const { hostname, pathname, search, hash, username, password } = new URL( + `http://${request.url.substring(prefix.length)}`, + ); + + let base = await this.discoveryApi.getBaseUrl(hostname); + if (username || password) { + const baseUrl = new URL(base); + const authority = `${username}${password ? `:${password}` : ''}@`; + base = `${baseUrl.protocol}//${authority}${baseUrl.host}${baseUrl.pathname}`; + } + + const target = `${join(base, pathname)}${search}${hash}`; + return next(target, request); + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts b/packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts new file mode 100644 index 0000000000..1b85695daa --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { FetchApi } from '@backstage/core-plugin-api'; +import { FetchMiddleware } from './types'; + +/** + * Builds a fetch API, based on the builtin fetch wrapped by a set of optional + * middleware implementations that add behaviors. + * + * @remarks + * + * The middleware are applied in reverse order, i.e. the last one will be + * "closest" to the base implementation. Passing in `[M1, M2, M3]` effectively + * leads to `M1(M2(M3(baseImplementation)))`. + * + * @public + */ +export function createFetchApi(options: { + baseImplementation?: typeof fetch | undefined; + middleware?: FetchMiddleware | FetchMiddleware[] | undefined; +}): FetchApi { + let result = options.baseImplementation || global.fetch; + + const middleware = [options.middleware ?? []].flat().reverse(); + for (const m of middleware) { + result = m.apply(result); + } + + return { + fetch: result, + }; +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/index.ts b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts new file mode 100644 index 0000000000..0f82a9020b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createFetchApi } from './createFetchApi'; +export { FetchMiddlewares } from './FetchMiddlewares'; +export type { FetchMiddleware } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/types.ts b/packages/core-app-api/src/apis/implementations/FetchApi/types.ts new file mode 100644 index 0000000000..5bf029cbf0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A middleware that modifies the behavior of an ongoing fetch. + * + * @public + */ +export interface FetchMiddleware { + /** + * Applies this middleware to an inner implementation. + * + * @param next - The next, inner, implementation, that this middleware shall + * call out to as part of the request cycle. + */ + apply(next: typeof fetch): typeof fetch; +} diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts new file mode 100644 index 0000000000..8a2f12594c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + IdentityApi, + ProfileInfo, + BackstageUserIdentity, +} from '@backstage/core-plugin-api'; + +function mkError(thing: string) { + return new Error( + `Tried to access IdentityApi ${thing} before app was loaded`, + ); +} + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentityProxy implements IdentityApi { + private target?: IdentityApi; + + // This is called by the app manager once the sign-in page provides us with an implementation + setTarget(identityApi: IdentityApi) { + this.target = identityApi; + } + + getUserId(): string { + if (!this.target) { + throw mkError('getUserId'); + } + return this.target.getUserId(); + } + + getProfile(): ProfileInfo { + if (!this.target) { + throw mkError('getProfile'); + } + return this.target.getProfile(); + } + + async getProfileInfo(): Promise { + if (!this.target) { + throw mkError('getProfileInfo'); + } + return this.target.getProfileInfo(); + } + + async getBackstageIdentity(): Promise { + if (!this.target) { + throw mkError('getBackstageIdentity'); + } + return this.target.getBackstageIdentity(); + } + + async getCredentials(): Promise<{ token?: string | undefined }> { + if (!this.target) { + throw mkError('getCredentials'); + } + return this.target.getCredentials(); + } + + async getIdToken(): Promise { + if (!this.target) { + throw mkError('getIdToken'); + } + return this.target.getIdToken(); + } + + async signOut(): Promise { + if (!this.target) { + throw mkError('signOut'); + } + await this.target.signOut(); + location.reload(); + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts index 4a0a07ef34..193c474361 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts @@ -16,14 +16,14 @@ import { OAuthRequestApi, - AuthRequesterOptions, + OAuthRequesterOptions, } from '@backstage/core-plugin-api'; import { OAuthRequestManager } from './OAuthRequestManager'; export default class MockOAuthApi implements OAuthRequestApi { private readonly real = new OAuthRequestManager(); - createAuthRequester(options: AuthRequesterOptions) { + createAuthRequester(options: OAuthRequesterOptions) { return this.real.createAuthRequester(options); } diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts index 7e7f3c2507..f9d9270b2a 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -16,9 +16,9 @@ import { OAuthRequestApi, - PendingAuthRequest, - AuthRequester, - AuthRequesterOptions, + PendingOAuthRequest, + OAuthRequester, + OAuthRequesterOptions, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; @@ -34,11 +34,17 @@ import { BehaviorSubject } from '../../../lib/subjects'; * @public */ export class OAuthRequestManager implements OAuthRequestApi { - private readonly subject = new BehaviorSubject([]); - private currentRequests: PendingAuthRequest[] = []; + private readonly subject = new BehaviorSubject([]); + private currentRequests: PendingOAuthRequest[] = []; private handlerCount = 0; - createAuthRequester(options: AuthRequesterOptions): AuthRequester { + createAuthRequester(options: OAuthRequesterOptions): OAuthRequester { + if (!options.provider.id) { + // eslint-disable-next-line no-console + console.warn( + 'DEPRECATION WARNING: Not passing a provider id to createAuthRequester is deprecated, it will be required in the future', + ); + } const handler = new OAuthPendingRequests(); const index = this.handlerCount; @@ -67,8 +73,8 @@ export class OAuthRequestManager implements OAuthRequestApi { // Converts the pending request and popup options into a popup request that we can forward to subscribers. private makeAuthRequest( request: PendingRequest, - options: AuthRequesterOptions, - ): PendingAuthRequest | undefined { + options: OAuthRequesterOptions, + ): PendingOAuthRequest | undefined { const { scopes } = request; if (!scopes) { return undefined; @@ -88,7 +94,7 @@ export class OAuthRequestManager implements OAuthRequestApi { }; } - authRequest$(): Observable { + authRequest$(): Observable { return this.subject; } } diff --git a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts index caad40ddbf..07ebefca28 100644 --- a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts @@ -30,12 +30,14 @@ const DEFAULT_PROVIDER = { * @public */ export default class AtlassianAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - }: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts index 0a158ccb9e..d8f942b94b 100644 --- a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -28,15 +28,34 @@ const DEFAULT_PROVIDER = { * Implements the OAuth flow to Auth0 products. * * @public + * @deprecated Use {@link OAuth2} instead + * + * @example + * + * ```ts + * OAuth2.create({ + * discoveryApi, + * oauthRequestApi, + * provider: { + * id: 'auth0', + * title: 'Auth0', + * icon: () => null, + * }, + * defaultScopes: ['openid', 'email', 'profile'], + * environment: configApi.getOptionalString('auth.environment'), + * }) + * ``` */ export default class Auth0Auth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['openid', `email`, `profile`], - }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', `email`, `profile`], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts index e488580c4d..c97cccc6ed 100644 --- a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -45,13 +45,15 @@ const DEFAULT_PROVIDER = { * @public */ export default class BitbucketAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['team'], - }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['team'], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts index 3e9c899346..cb0f792524 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -14,25 +14,25 @@ * limitations under the License. */ -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GithubSession } from './types'; import { + AuthRequestOptions, + BackstageIdentity, OAuthApi, + ProfileInfo, SessionApi, SessionState, - ProfileInfo, - BackstageIdentity, - AuthRequestOptions, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { AuthSessionStore, RefreshingAuthSessionManager, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; -import { OAuthApiCreateOptions } from '../types'; import { OptionalRefreshSessionManagerMux } from '../../../../lib/AuthSessionManager/OptionalRefreshSessionManagerMux'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { OAuthApiCreateOptions } from '../types'; +import { GithubSession, githubSessionSchema } from './types'; export type GithubAuthResponse = { providerInfo: { @@ -56,13 +56,15 @@ const DEFAULT_PROVIDER = { * @public */ export default class GithubAuth implements OAuthApi, SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['read:user'], - }: OAuthApiCreateOptions) { + static create(options: OAuthApiCreateOptions) { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read:user'], + } = options; + const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -103,6 +105,7 @@ export default class GithubAuth implements OAuthApi, SessionApi { sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }), storageKey: `${provider.id}Session`, + schema: githubSessionSchema, sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); @@ -116,10 +119,9 @@ export default class GithubAuth implements OAuthApi, SessionApi { return new GithubAuth(sessionManagerMux); } - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(private readonly sessionManager: SessionManager) {} + private constructor( + private readonly sessionManager: SessionManager, + ) {} async signIn() { await this.getAccessToken(); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/index.ts b/packages/core-app-api/src/apis/implementations/auth/github/index.ts index ee4334f6fc..b5aa1a0a25 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export * from './types'; +export type { GithubSession } from './types'; export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/types.ts b/packages/core-app-api/src/apis/implementations/auth/github/types.ts index e1c88b9173..fc289635ed 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/types.ts @@ -15,6 +15,7 @@ */ import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; +import { z } from 'zod'; /** * Session information for GitHub auth. @@ -30,3 +31,25 @@ export type GithubSession = { profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; + +export const githubSessionSchema: z.ZodSchema = z.object({ + providerInfo: z.object({ + accessToken: z.string(), + scopes: z.set(z.string()), + expiresAt: z.date().optional(), + }), + profile: z.object({ + email: z.string().optional(), + displayName: z.string().optional(), + picture: z.string().optional(), + }), + backstageIdentity: z.object({ + id: z.string(), + token: z.string(), + identity: z.object({ + type: z.literal('user'), + userEntityRef: z.string(), + ownershipEntityRefs: z.array(z.string()), + }), + }), +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index f46f2ef72e..00085de8ac 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -30,13 +30,15 @@ const DEFAULT_PROVIDER = { * @public */ export default class GitlabAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['read_user'], - }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read_user'], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts index a6f37b250f..8a528f4511 100644 --- a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -32,17 +32,19 @@ const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; * @public */ export default class GoogleAuth { - static create({ - discoveryApi, - oauthRequestApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - defaultScopes = [ - 'openid', - `${SCOPE_PREFIX}userinfo.email`, - `${SCOPE_PREFIX}userinfo.profile`, - ], - }: OAuthApiCreateOptions): typeof googleAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T { + const { + discoveryApi, + oauthRequestApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + defaultScopes = [ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 148be66873..be5776609d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -30,19 +30,21 @@ const DEFAULT_PROVIDER = { * @public */ export default class MicrosoftAuth { - static create({ - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - discoveryApi, - defaultScopes = [ - 'openid', - 'offline_access', - 'profile', - 'email', - 'User.Read', - ], - }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + const { + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + discoveryApi, + defaultScopes = [ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts index 4f030d1c64..b2398f64de 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -15,6 +15,8 @@ */ import OAuth2 from './OAuth2'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const theFuture = new Date(Date.now() + 3600000); const thePast = new Date(Date.now() - 10); @@ -23,14 +25,24 @@ const PREFIX = 'https://www.googleapis.com/auth/'; const scopeTransform = (x: string[]) => x; +let getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + describe('OAuth2', () => { it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopeTransform, + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( @@ -43,12 +55,13 @@ describe('OAuth2', () => { }); it('should transform scopes', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, + const oauth2 = OAuth2.create({ scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); @@ -59,12 +72,13 @@ describe('OAuth2', () => { }); it('should get refreshed id token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { idToken: 'id-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopeTransform, + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getIdToken()).toBe('id-token'); @@ -72,12 +86,13 @@ describe('OAuth2', () => { }); it('should get optional id token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { idToken: 'id-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); @@ -87,7 +102,7 @@ describe('OAuth2', () => { it('should share popup closed errors', async () => { const error = new Error('NOPE'); error.name = 'RejectedError'; - const getSession = jest + getSession = jest .fn() .mockResolvedValueOnce({ providerInfo: { @@ -97,9 +112,10 @@ describe('OAuth2', () => { }, }) .mockRejectedValue(error); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check @@ -120,7 +136,7 @@ describe('OAuth2', () => { scopes: new Set(), }, }; - const getSession = jest + getSession = jest .fn() .mockResolvedValueOnce(initialSession) .mockResolvedValue({ @@ -130,9 +146,10 @@ describe('OAuth2', () => { scopes: new Set(), }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); // Grab the expired session first diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 403e8445d8..18644a037f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -70,14 +70,16 @@ export default class OAuth2 BackstageIdentityApi, SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = [], - scopeTransform = x => x, - }: OAuth2CreateOptions) { + static create(options: OAuth2CreateOptions) { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = [], + scopeTransform = x => x, + } = options; + const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -118,10 +120,7 @@ export default class OAuth2 private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(options: { + private constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }) { diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts index 465a124051..42c0ddd0ed 100644 --- a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -42,13 +42,15 @@ const OKTA_SCOPE_PREFIX: string = 'okta.'; * @public */ export default class OktaAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['openid', 'email', 'profile', 'offline_access'], - }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { + static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', 'email', 'profile', 'offline_access'], + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts index 93b9f6634c..ea9e2cad28 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -17,7 +17,7 @@ import { oneloginAuthApiRef, OAuthRequestApi, - AuthProvider, + AuthProviderInfo, DiscoveryApi, } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; @@ -30,7 +30,7 @@ export type OneLoginAuthCreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; - provider?: AuthProvider & { id: string }; + provider?: AuthProviderInfo; }; const DEFAULT_PROVIDER = { @@ -57,12 +57,16 @@ const SCOPE_PREFIX: string = 'onelogin.'; * @public */ export default class OneLoginAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T { + static create( + options: OneLoginAuthCreateOptions, + ): typeof oneloginAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + } = options; + return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index c1b70e963d..98038b2f41 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { DirectAuthConnector } from '../../../../lib/AuthConnector'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { - ProfileInfo, - BackstageIdentity, - SessionState, AuthRequestOptions, - ProfileInfoApi, + BackstageIdentity, BackstageIdentityApi, + ProfileInfo, + ProfileInfoApi, SessionApi, + SessionState, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { SamlSession } from './types'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { AuthSessionStore, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; +import { SamlSession, samlSessionSchema } from './types'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -52,11 +52,13 @@ const DEFAULT_PROVIDER = { export default class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - }: AuthApiCreateOptions) { + static create(options: AuthApiCreateOptions) { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + } = options; + const connector = new DirectAuthConnector({ discoveryApi, environment, @@ -70,6 +72,7 @@ export default class SamlAuth const authSessionStore = new AuthSessionStore({ manager: sessionManager, storageKey: `${provider.id}Session`, + schema: samlSessionSchema, }); return new SamlAuth(authSessionStore); @@ -79,10 +82,9 @@ export default class SamlAuth return this.sessionManager.sessionState$(); } - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(private readonly sessionManager: SessionManager) {} + private constructor( + private readonly sessionManager: SessionManager, + ) {} async signIn() { await this.getBackstageIdentity({}); diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts index 724f0aa5d5..2e7c67553e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +import { BackstageIdentity, ProfileInfo } from '@backstage/core-plugin-api'; +import { z } from 'zod'; /** * Session information for SAML auth. @@ -25,3 +27,21 @@ export type SamlSession = { profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; + +export const samlSessionSchema: z.ZodSchema = z.object({ + userId: z.string(), + profile: z.object({ + email: z.string().optional(), + displayName: z.string().optional(), + picture: z.string().optional(), + }), + backstageIdentity: z.object({ + id: z.string(), + token: z.string(), + identity: z.object({ + type: z.literal('user'), + userEntityRef: z.string(), + ownershipEntityRefs: z.array(z.string()), + }), + }), +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts index 825f433cec..55d6c19098 100644 --- a/packages/core-app-api/src/apis/implementations/auth/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/types.ts @@ -15,7 +15,7 @@ */ import { - AuthProvider, + AuthProviderInfo, DiscoveryApi, OAuthRequestApi, } from '@backstage/core-plugin-api'; @@ -36,5 +36,5 @@ export type OAuthApiCreateOptions = AuthApiCreateOptions & { export type AuthApiCreateOptions = { discoveryApi: DiscoveryApi; environment?: string; - provider?: AuthProvider & { id: string }; + provider?: AuthProviderInfo; }; diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts index c494f966f2..1c79d3d164 100644 --- a/packages/core-app-api/src/apis/implementations/index.ts +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -27,5 +27,6 @@ export * from './ConfigApi'; export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './FetchApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-app-api/src/app/AppIdentity.ts b/packages/core-app-api/src/app/AppIdentity.ts deleted file mode 100644 index 64e698102f..0000000000 --- a/packages/core-app-api/src/app/AppIdentity.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api'; -import { SignInResult } from './types'; - -/** - * Implementation of the connection between the App-wide IdentityApi - * and sign-in page. - */ -export class AppIdentity implements IdentityApi { - private hasIdentity = false; - private userId?: string; - private profile?: ProfileInfo; - private idTokenFunc?: () => Promise; - private signOutFunc?: () => Promise; - - getUserId(): string { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi userId before app was loaded', - ); - } - return this.userId!; - } - - getProfile(): ProfileInfo { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi profile before app was loaded', - ); - } - return this.profile!; - } - - async getIdToken(): Promise { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi idToken before app was loaded', - ); - } - return this.idTokenFunc?.(); - } - - async signOut(): Promise { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi signOutFunc before app was loaded', - ); - } - await this.signOutFunc?.(); - location.reload(); - } - - // This is indirectly called by the sign-in page to continue into the app. - setSignInResult(result: SignInResult) { - if (this.hasIdentity) { - return; - } - if (!result.userId) { - throw new Error('Invalid sign-in result, userId not set'); - } - if (!result.profile) { - throw new Error('Invalid sign-in result, profile not set'); - } - this.hasIdentity = true; - this.userId = result.userId; - this.profile = result.profile; - this.idTokenFunc = result.getIdToken; - this.signOutFunc = result.signOut; - } -} diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 5903a8c874..21940c08d5 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -43,12 +43,14 @@ import { AppThemeApi, ConfigApi, featureFlagsApiRef, + IdentityApi, identityApiRef, BackstagePlugin, RouteRef, SubRouteRef, ExternalRouteRef, } from '@backstage/core-plugin-api'; +import { UserIdentity } from '@backstage/core-components'; import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; import { childDiscoverer, @@ -66,7 +68,7 @@ import { RoutingProvider } from '../routing/RoutingProvider'; import { RouteTracker } from '../routing/RouteTracker'; import { validateRoutes } from '../routing/validation'; import { AppContextProvider } from './AppContext'; -import { AppIdentity } from './AppIdentity'; +import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; import { AppComponents, AppConfigLoader, @@ -75,7 +77,6 @@ import { AppRouteBinder, BackstageApp, SignInPageProps, - SignInResult, } from './types'; import { AppThemeProvider } from './AppThemeProvider'; import { defaultConfigLoader } from './defaultConfigLoader'; @@ -189,7 +190,7 @@ export class AppManager implements BackstageApp { private readonly defaultApis: Iterable; private readonly bindRoutes: AppOptions['bindRoutes']; - private readonly identityApi = new AppIdentity(); + private readonly appIdentityProxy = new AppIdentityProxy(); private readonly apiFactoryRegistry: ApiFactoryRegistry; constructor(options: AppOptions) { @@ -344,14 +345,14 @@ export class AppManager implements BackstageApp { component: ComponentType; children: ReactElement; }) => { - const [result, setResult] = useState(); + const [identityApi, setIdentityApi] = useState(); - if (result) { - this.identityApi.setSignInResult(result); - return children; + if (!identityApi) { + return ; } - return ; + this.appIdentityProxy.setTarget(identityApi); + return children; }; const AppRouter = ({ children }: PropsWithChildren<{}>) => { @@ -360,13 +361,7 @@ export class AppManager implements BackstageApp { // If the app hasn't configured a sign-in page, we just continue as guest. if (!SignInPageComponent) { - this.identityApi.setSignInResult({ - userId: 'guest', - profile: { - email: 'guest@example.com', - displayName: 'Guest', - }, - }); + this.appIdentityProxy.setTarget(UserIdentity.createGuest()); return ( @@ -430,7 +425,7 @@ export class AppManager implements BackstageApp { this.apiFactoryRegistry.register('static', { api: identityApiRef, deps: {}, - factory: () => this.identityApi, + factory: () => this.appIdentityProxy, }); // It's possible to replace the feature flag API, but since we must have at least diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx index 2e145025d5..b2c800f673 100644 --- a/packages/core-app-api/src/app/AppThemeProvider.tsx +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -15,8 +15,6 @@ */ import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; -import { ThemeProvider } from '@material-ui/core/styles'; -import CssBaseline from '@material-ui/core/CssBaseline'; import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api'; import { useObservable } from 'react-use'; @@ -90,20 +88,5 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) { throw new Error('App has no themes'); } - if (appTheme.Provider) { - return ; - } - - // eslint-disable-next-line no-console - console.warn( - "DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " + - 'and should be migrated to use a Provider instead. ' + - 'See https://backstage.io/docs/api/deprecations#app-theme for more info.', - ); - - return ( - - {children} - - ); + return ; } diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx deleted file mode 100644 index b2a21634d3..0000000000 --- a/packages/core-app-api/src/app/createApp.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { createApp as createDefaultApp } from '@backstage/app-defaults'; -import { AppContext, BackstageApp } from './types'; - -/** - * Creates a new Backstage App. - * - * @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead - * @param options - A set of options for creating the app - * @public - */ -export function createApp( - options?: Parameters[0], -): BackstageApp & AppContext { - // eslint-disable-next-line no-console - console.warn( - 'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' + - 'migrate to importing createApp from the @backstage/app-defaults package instead. ' + - 'If you do not wish to use a standard app configuration but instead supply all options yourself ' + - ' you can use createSpecializedApp from @backstage/core-app-api instead.', - ); - return createDefaultApp(options) as BackstageApp & AppContext; -} diff --git a/packages/core-app-api/src/app/createSpecializedApp.tsx b/packages/core-app-api/src/app/createSpecializedApp.tsx index 9bda223722..40906ef5c7 100644 --- a/packages/core-app-api/src/app/createSpecializedApp.tsx +++ b/packages/core-app-api/src/app/createSpecializedApp.tsx @@ -23,6 +23,13 @@ import { AppOptions, BackstageApp } from './types'; * @public * @param options - A set of options for creating the app * @returns + * @remarks + * + * You will most likely want to use {@link @backstage/app-defaults#createApp}, + * however, this low-level API allows you to provide a full set of options, + * including your own `components`, `icons`, `defaultApis`, and `themes`. This + * is particularly useful if you are not using `@backstage/core-components` or + * MUI, as it allows you to avoid those dependencies completely. */ export function createSpecializedApp(options: AppOptions): BackstageApp { return new AppManager(options); diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 5ea5405632..7843b36339 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { createApp } from './createApp'; export { createSpecializedApp } from './createSpecializedApp'; export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index f16c8ac656..4b8d2741cb 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -25,6 +25,7 @@ import { SubRouteRef, ExternalRouteRef, PluginOutput, + IdentityApi, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; @@ -42,6 +43,7 @@ export type BootErrorPageProps = { * The outcome of signing in on the sign-in page. * * @public + * @deprecated replaced by passing the {@link @backstage/core-plugin-api#IdentityApi} to the {@link SignInPageProps.onSignInSuccess} instead. */ export type SignInResult = { /** @@ -69,9 +71,9 @@ export type SignInResult = { */ export type SignInPageProps = { /** - * Set the sign-in result for the app. This should only be called once. + * Set the IdentityApi on successful sign in. This should only be called once. */ - onResult(result: SignInResult): void; + onSignInSuccess(identityApi: IdentityApi): void; }; /** @@ -187,7 +189,7 @@ type TargetRouteMap< /** * A function that can bind from external routes of a given plugin, to concrete - * routes of other plugins. See {@link createApp}. + * routes of other plugins. See {@link createSpecializedApp}. * * @public */ @@ -202,30 +204,7 @@ export type AppRouteBinder = < ) => void; /** - * Internal helper type that represents a plugin with any type of output. - * - * @public - * @remarks - * @deprecated Will be removed - * - * The `type: string` type is there to handle output from newer or older plugin - * API versions that might not be supported by this version of the app API, but - * we don't want to break at the type checking level. We only use this more - * permissive type for the `createApp` options, as we otherwise want to stick - * to using the type for the outputs that we know about in this version of the - * app api. - * - * TODO(freben): This should be marked internal but that's not supported by the api report generation tools yet - */ -export type BackstagePluginWithAnyOutput = Omit< - BackstagePlugin, - 'output' -> & { - output(): (PluginOutput | { type: string })[]; -}; - -/** - * The options accepted by {@link createApp}. + * The options accepted by {@link createSpecializedApp}. * * @public */ @@ -327,7 +306,7 @@ export type AppOptions = { }; /** - * The public API of the output of {@link createApp}. + * The public API of the output of {@link createSpecializedApp}. * * @public */ diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 261a008d4b..a467957063 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -15,9 +15,9 @@ */ import { - AuthRequester, + OAuthRequester, OAuthRequestApi, - AuthProvider, + AuthProviderInfo, DiscoveryApi, } from '@backstage/core-plugin-api'; import { showLoginPopup } from '../loginPopup'; @@ -36,7 +36,7 @@ type Options = { * Information about the auth provider to be shown to the user. * The ID Must match the backend auth plugin configuration, for example 'google'. */ - provider: AuthProvider & { id: string }; + provider: AuthProviderInfo; /** * API used to instantiate an auth requester. */ @@ -65,9 +65,9 @@ export class DefaultAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string; - private readonly provider: AuthProvider & { id: string }; + private readonly provider: AuthProviderInfo; private readonly joinScopesFunc: (scopes: Set) => string; - private readonly authRequester: AuthRequester; + private readonly authRequester: OAuthRequester; private readonly sessionTransform: (response: any) => Promise; constructor(options: Options) { diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts index 61fdd825a2..200ba755ac 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -13,18 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AuthProvider, DiscoveryApi } from '@backstage/core-plugin-api'; +import { AuthProviderInfo, DiscoveryApi } from '@backstage/core-plugin-api'; import { showLoginPopup } from '../loginPopup'; type Options = { discoveryApi: DiscoveryApi; environment?: string; - provider: AuthProvider & { id: string }; + provider: AuthProviderInfo; }; export class DirectAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; - private readonly provider: AuthProvider & { id: string }; + private readonly provider: AuthProviderInfo; constructor(options: Options) { const { discoveryApi, environment, provider } = options; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts index ded26c59e5..83d6035171 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { withLogCollector } from '@backstage/test-utils'; +import { z } from 'zod'; import { AuthSessionStore } from './AuthSessionStore'; import { SessionManager } from './types'; const defaultOptions = { storageKey: 'my-key', + schema: z.any(), sessionScopes: (session: string) => new Set(session.split(' ')), }; @@ -147,4 +151,48 @@ describe('GheAuth AuthSessionStore', () => { store.sessionState$(); expect(manager.sessionState$).toHaveBeenCalled(); }); + + it('should schema-validate stored data', async () => { + const manager = new MockManager(); + + const firstStore = new AuthSessionStore({ + manager, + storageKey: 'a', + schema: z.boolean(), + sessionScopes: () => new Set(), + }); + const secondStore = new AuthSessionStore({ + manager, + storageKey: 'a', + schema: z.number(), + sessionScopes: () => new Set(), + }); + + firstStore.setSession(true); + await expect(firstStore.getSession({})).resolves.toBe(true); + + await expect( + withLogCollector(async () => { + await expect(secondStore.getSession({})).resolves.toBeUndefined(); + }), + ).resolves.toMatchObject({ + log: [ + expect.stringContaining( + 'Failed to load session from local storage because it did not conform to the expected schema', + ), + ], + }); + + await expect( + withLogCollector(async () => { + await secondStore.setSession('no' as any); + }), + ).resolves.toMatchObject({ + warn: [ + expect.stringContaining( + 'Failed to save session to local storage because it did not conform to the expected schema', + ), + ], + }); + }); }); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts index fc15da7af8..e1fb80d41f 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ZodSchema } from 'zod'; import { MutableSessionManager, SessionScopesFunc, @@ -27,6 +28,8 @@ type Options = { manager: MutableSessionManager; /** Storage key to use to store sessions */ storageKey: string; + /** The schema used to validate the stored data */ + schema: ZodSchema; /** Used to get the scope of the session */ sessionScopes?: SessionScopesFunc; /** Used to check if the session needs to be refreshed, defaults to never refresh */ @@ -42,6 +45,7 @@ type Options = { export class AuthSessionStore implements MutableSessionManager { private readonly manager: MutableSessionManager; private readonly storageKey: string; + private readonly schema: ZodSchema; private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; private readonly helper: SessionScopeHelper; @@ -49,12 +53,14 @@ export class AuthSessionStore implements MutableSessionManager { const { manager, storageKey, + schema, sessionScopes, sessionShouldRefresh = () => false, } = options; this.manager = manager; this.storageKey = storageKey; + this.schema = schema; this.sessionShouldRefreshFunc = sessionShouldRefresh; this.helper = new SessionScopeHelper({ sessionScopes, @@ -104,7 +110,16 @@ export class AuthSessionStore implements MutableSessionManager { } return value; }); - return session; + + try { + return this.schema.parse(session); + } catch (e) { + // eslint-disable-next-line no-console + console.log( + `Failed to load session from local storage because it did not conform to the expected schema, ${e}`, + ); + throw e; + } } return undefined; @@ -117,19 +132,30 @@ export class AuthSessionStore implements MutableSessionManager { private saveSession(session: T | undefined) { if (session === undefined) { localStorage.removeItem(this.storageKey); - } else { - localStorage.setItem( - this.storageKey, - JSON.stringify(session, (_key, value) => { - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value), - }; - } - return value; - }), - ); + return; } + + try { + this.schema.parse(session); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Failed to save session to local storage because it did not conform to the expected schema, ${e}`, + ); + return; + } + + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); } } diff --git a/packages/core-app-api/src/routing/RouteResolver.test.ts b/packages/core-app-api/src/routing/RouteResolver.test.ts index 1b7eb10003..b068bd5e6d 100644 --- a/packages/core-app-api/src/routing/RouteResolver.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.test.ts @@ -100,23 +100,55 @@ describe('RouteResolver', () => { expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); }); - it('should resolve an absolute route and an app base path', () => { + it('should resolve an absolute route and sub route with an app base path', () => { const r = new RouteResolver( - new Map([[ref1, '/my-route']]), - new Map(), - [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], + new Map([ + [ref2, '/my-parent/:x'], + [ref1, '/my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], new Map(), '/base', ); - expect(r.resolve(ref1, '/')?.()).toBe('/base/my-route'); - expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); - expect(r.resolve(subRef1, '/')?.()).toBe('/base/my-route/foo'); - expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe( - '/base/my-route/foo/2a', + expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined); + expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe( + '/base/my-parent/5x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe( + '/base/my-parent/6x/bar/4a', ); - expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); - expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts index b5be5f893e..f186b28586 100644 --- a/packages/core-app-api/src/routing/RouteResolver.ts +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -207,6 +207,20 @@ export class RouteResolver { return undefined; } + // The location that we get passed in uses the full path, so start by trimming off + // the app base path prefix in case we're running the app on a sub-path. + let relativeSourceLocation: Parameters[1]; + if (typeof sourceLocation === 'string') { + relativeSourceLocation = this.trimPath(sourceLocation); + } else if (sourceLocation.pathname) { + relativeSourceLocation = { + ...sourceLocation, + pathname: this.trimPath(sourceLocation.pathname), + }; + } else { + relativeSourceLocation = sourceLocation; + } + // Next we figure out the base path, which is the combination of the common parent path // between our current location and our target location, as well as the additional path // that is the difference between the parent path and the base of our target location. @@ -214,7 +228,7 @@ export class RouteResolver { this.appBasePath + resolveBasePath( targetRef, - sourceLocation, + relativeSourceLocation, this.routePaths, this.routeParents, this.routeObjects, @@ -225,4 +239,15 @@ export class RouteResolver { }; return routeFunc; } + + private trimPath(targetPath: string) { + if (!targetPath) { + return targetPath; + } + + if (targetPath.startsWith(this.appBasePath)) { + return targetPath.slice(this.appBasePath.length); + } + return targetPath; + } } diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index b4898fa82b..2171a72ccb 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,48 @@ # @backstage/core-components +## 0.8.1 + +### Patch Changes + +- 2c17e5b073: Items in `` are now only active when their full path is active (including search parameters). +- 9d6503e86c: Switched out usage of deprecated `OAuthRequestApi` types from `@backstage/core-plugin-api`. +- 1680a1c5ac: Add Missing Override Components Type for SidebarSpace, SidebarSpacer, and SidebarDivider Components. +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + +## 0.8.0 + +### Minor Changes + +- a036b65c2f: The `SignInPage` has been updated to use the new `onSignInSuccess` callback that was introduced in the same release. While existing code will usually continue to work, it is technically a breaking change because of the dependency on `SignInProps` from the `@backstage/core-plugin-api`. For more information on this change and instructions on how to migrate existing code, see the [`@backstage/core-app-api` CHANGELOG.md](https://github.com/backstage/backstage/blob/master/packages/core-app-api/CHANGELOG.md). + + Added a new `UserIdentity` class which helps create implementations of the `IdentityApi`. It provides a couple of static factory methods such as the most relevant `create`, and `createGuest` to create an `IdentityApi` for a guest user. + + Also provides a deprecated `fromLegacy` method to create an `IdentityApi` from the now deprecated `SignInResult`. This method will be removed in the future when `SignInResult` is also removed. + +### Patch Changes + +- 9603827bb5: Addressed some peer dependency warnings +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- e839500286: Introduce new `LogViewer` component that can be used to display logs. It supports copying, searching, filtering, and displaying text with ANSI color escape codes. +- 1357ac30f1: Standardize on `classnames` instead of both that and `clsx`. +- e5976071ea: Use ellipsis style for overflowed text in sidebar menu +- Updated dependencies + - @backstage/core-plugin-api@0.3.0 + +## 0.7.6 + +### Patch Changes + +- e34f174fc5: Added `` and `` to enable building better sidebars. You can check out the storybook for more inspiration and how to get started. + + Added two new theme props for styling the sidebar too, `navItem.hoverBackground` and `submenu.background`. + +- Updated dependencies + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.7.5 ### Patch Changes diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 0f1e3510ae..f2b167691d 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePalette } from '@backstage/theme'; import { BackstageTheme } from '@backstage/theme'; +import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button'; import { CardHeaderProps } from '@material-ui/core/CardHeader'; import { Column } from '@material-table/core'; @@ -20,6 +21,7 @@ import { CSSProperties } from 'react'; import { ElementType } from 'react'; import { ErrorInfo } from 'react'; import { IconComponent } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; import { LinearProgressProps } from '@material-ui/core/LinearProgress'; import { LinkProps as LinkProps_2 } from '@material-ui/core/Link'; import { LinkProps as LinkProps_3 } from 'react-router-dom'; @@ -27,6 +29,7 @@ import MaterialBreadcrumbs from '@material-ui/core/Breadcrumbs'; import { MaterialTableProps } from '@material-table/core'; import { NavLinkProps } from 'react-router-dom'; import { Overrides } from '@material-ui/core/styles/overrides'; +import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; @@ -35,6 +38,7 @@ import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { SessionApi } from '@backstage/core-plugin-api'; import { SignInPageProps } from '@backstage/core-plugin-api'; +import { SignInResult } from '@backstage/core-plugin-api'; import { SparklinesLineProps } from 'react-sparklines'; import { SparklinesProps } from 'react-sparklines'; import { StyledComponentProps } from '@material-ui/core/styles'; @@ -605,6 +609,50 @@ export type LinkProps = LinkProps_2 & // @public (undocumented) export type LoginRequestListItemClassKey = 'root'; +// @public +export function LogViewer(props: LogViewerProps): JSX.Element; + +// @public +export type LogViewerClassKey = + | 'root' + | 'header' + | 'log' + | 'line' + | 'lineSelected' + | 'lineCopyButton' + | 'lineNumber' + | 'textHighlight' + | 'textSelectedHighlight' + | 'modifierBold' + | 'modifierItalic' + | 'modifierUnderline' + | 'modifierForegroundBlack' + | 'modifierForegroundRed' + | 'modifierForegroundGreen' + | 'modifierForegroundYellow' + | 'modifierForegroundBlue' + | 'modifierForegroundMagenta' + | 'modifierForegroundCyan' + | 'modifierForegroundWhite' + | 'modifierForegroundGrey' + | 'modifierBackgroundBlack' + | 'modifierBackgroundRed' + | 'modifierBackgroundGreen' + | 'modifierBackgroundYellow' + | 'modifierBackgroundBlue' + | 'modifierBackgroundMagenta' + | 'modifierBackgroundCyan' + | 'modifierBackgroundWhite' + | 'modifierBackgroundGrey'; + +// @public +export interface LogViewerProps { + classes?: { + root?: string; + }; + text: string; +} + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -766,13 +814,10 @@ export type ResponseErrorPanelClassKey = 'text' | 'divider'; export function RoutedTabs(props: { routes: SubRoute_2[] }): JSX.Element; // Warning: (ae-forgotten-export) The symbol "SelectProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "SelectComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function Select(props: SelectProps): JSX.Element; -// Warning: (ae-missing-release-tag) "SelectClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type SelectClassKey = | 'formControl' @@ -782,11 +827,18 @@ export type SelectClassKey = | 'checkbox' | 'root'; -// Warning: (ae-missing-release-tag) "SelectInputBaseClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public (undocumented) +export type SelectedItems = string | string[] | number | number[]; + // @public (undocumented) export type SelectInputBaseClassKey = 'root' | 'input'; +// @public (undocumented) +export type SelectItem = { + label: string; + value: string | number; +}; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -832,6 +884,7 @@ export const SidebarContext: Context; // @public (undocumented) export type SidebarContextType = { isOpen: boolean; + setOpen: (open: boolean) => void; }; // Warning: (ae-missing-release-tag) "SidebarDivider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1103,6 +1156,14 @@ export const SidebarDivider: React_2.ComponentType< } >; +// Warning: (ae-missing-release-tag) "SidebarDividerClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarDividerClassKey = 'root'; + +// @public +export const SidebarExpandButton: () => JSX.Element | null; + // Warning: (ae-missing-release-tag) "SidebarIntro" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1708,6 +1769,11 @@ export const SidebarSpace: React_2.ComponentType< } >; +// Warning: (ae-missing-release-tag) "SidebarSpaceClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarSpaceClassKey = 'root'; + // Warning: (ae-missing-release-tag) "SidebarSpacer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1977,6 +2043,39 @@ export const SidebarSpacer: React_2.ComponentType< } >; +// Warning: (ae-missing-release-tag) "SidebarSpacerClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarSpacerClassKey = 'root'; + +// @public +export const SidebarSubmenu: (props: SidebarSubmenuProps) => JSX.Element; + +// @public +export const SidebarSubmenuItem: ( + props: SidebarSubmenuItemProps, +) => JSX.Element; + +// @public +export type SidebarSubmenuItemDropdownItem = { + title: string; + to: string; +}; + +// @public +export type SidebarSubmenuItemProps = { + title: string; + to: string; + icon: IconComponent; + dropdownItems?: SidebarSubmenuItemDropdownItem[]; +}; + +// @public +export type SidebarSubmenuProps = { + title?: string; + children: ReactNode; +}; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2282,6 +2381,33 @@ export function useQueryParamState( // @public (undocumented) export function UserIcon(props: IconComponentProps): JSX.Element; +// @public +export class UserIdentity implements IdentityApi { + static create(options: { + identity: BackstageUserIdentity; + authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi; + profile?: ProfileInfo; + }): IdentityApi; + static createGuest(): IdentityApi; + static fromLegacy(result: SignInResult): IdentityApi; + // (undocumented) + getBackstageIdentity(): Promise; + // (undocumented) + getCredentials(): Promise<{ + token?: string | undefined; + }>; + // (undocumented) + getIdToken(): Promise; + // (undocumented) + getProfile(): ProfileInfo; + // (undocumented) + getProfileInfo(): Promise; + // (undocumented) + getUserId(): string; + // (undocumented) + signOut(): Promise; +} + // Warning: (ae-missing-release-tag) "useSupportConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 896295038c..b47a221227 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-components", "description": "Core components used by Backstage plugins and apps", - "version": "0.7.5", + "version": "0.8.1", "private": false, "publishConfig": { "access": "public", @@ -30,30 +30,28 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.5", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@material-table/core": "^3.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "@types/react-sparklines": "^1.7.0", "@types/react-text-truncate": "^0.14.0", + "ansi-regex": "^5.0.1", "classnames": "^2.2.6", - "clsx": "^1.1.0", "d3-selection": "^3.0.0", "d3-shape": "^3.0.0", "d3-zoom": "^3.0.0", "dagre": "^0.8.5", + "history": "^5.0.0", "immer": "^9.0.1", "lodash": "^4.17.21", "pluralize": "^8.0.0", "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": "^7.12.2", "react-markdown": "^7.0.1", @@ -63,13 +61,20 @@ "react-syntax-highlighter": "^15.4.3", "react-text-truncate": "^0.16.0", "react-use": "^17.2.4", + "react-virtualized-auto-sizer": "^1.0.6", + "react-window": "^1.8.6", "remark-gfm": "^2.0.0", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/core-app-api": "^0.1.23", - "@backstage/cli": "^0.9.1", - "@backstage/test-utils": "^0.1.23", + "@backstage/core-app-api": "^0.2.1", + "@backstage/cli": "^0.10.2", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", @@ -78,12 +83,15 @@ "@types/d3-selection": "^3.0.1", "@types/d3-shape": "^3.0.1", "@types/d3-zoom": "^3.0.1", + "@types/ansi-regex": "^5.0.0", "@types/dagre": "^0.7.44", "@types/google-protobuf": "^3.7.2", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/react-helmet": "^6.1.0", "@types/react-syntax-highlighter": "^13.5.2", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "@types/zen-observable": "^0.8.0" }, "files": [ diff --git a/packages/core-components/src/components/Button/Button.stories.tsx b/packages/core-components/src/components/Button/Button.stories.tsx index 32c3a98bcc..ca95316ee8 100644 --- a/packages/core-components/src/components/Button/Button.stories.tsx +++ b/packages/core-components/src/components/Button/Button.stories.tsx @@ -18,13 +18,13 @@ import { Button } from './Button'; import { useLocation } from 'react-router-dom'; import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api'; import Divider from '@material-ui/core/Divider'; -import Link from '@material-ui/core/Link'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import Typography from '@material-ui/core/Typography'; import MaterialButton from '@material-ui/core/Button'; import { wrapInTestApp } from '@backstage/test-utils'; +import { Link } from '../Link'; const routeRef = createRouteRef({ id: 'storybook.test-route', @@ -45,7 +45,7 @@ export default { A collection of buttons that should be used in the Backstage interface. These leverage the properties inherited from{' '} - + Material-UI Button , but include an opinionated set that align to the Backstage design. diff --git a/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx index a9532dd79b..ecb784f951 100644 --- a/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx +++ b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx @@ -15,8 +15,7 @@ */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; +import { act, fireEvent } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { CopyTextButton } from './CopyTextButton'; import { errorApiRef } from '@backstage/core-plugin-api'; diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx index 5487d2e7b5..449d4a987b 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { DismissableBanner } from './DismissableBanner'; -import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import { WebStorage } from '@backstage/core-app-api'; import { @@ -25,6 +24,7 @@ import { StorageApi, } from '@backstage/core-plugin-api'; import { TestApiProvider } from '@backstage/test-utils'; +import { Link } from '../Link'; export default { title: 'Feedback/DismissableBanner', @@ -83,7 +83,7 @@ export const WithLink = () => ( message={ This is a dismissable banner with a link:{' '} - + example.com @@ -94,6 +94,7 @@ export const WithLink = () => (
); + export const Fixed = () => (
@@ -106,6 +107,7 @@ export const Fixed = () => (
); + export const Warning = () => (
diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts new file mode 100644 index 0000000000..e957b28b7c --- /dev/null +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts @@ -0,0 +1,233 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AnsiProcessor } from './AnsiProcessor'; + +describe('AnsiProcessor', () => { + it('should process a single line', () => { + const processor = new AnsiProcessor(); + expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([ + { + chunks: [ + { + text: 'foo', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'red' }, + }, + { + text: 'baz', + modifiers: {}, + }, + ], + text: 'foobarbaz', + lineNumber: 1, + }, + ]); + + expect(processor.process(`foo bar: baz`)).toEqual([ + { + chunks: [ + { + text: 'foo ', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'green' }, + }, + { + text: ': baz', + modifiers: {}, + }, + ], + text: 'foo bar: baz', + lineNumber: 1, + }, + ]); + }); + + it('should process multiple lines', () => { + const processor = new AnsiProcessor(); + expect( + processor.process(` +a\x1b[34mb\x1b[39mc +x\x1b[44my\x1b[49mz +`), + ).toEqual([ + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'blue' }, + }, + { + text: 'c', + modifiers: {}, + }, + ], + text: 'abc', + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: {}, + }, + { + text: 'y', + modifiers: { background: 'blue' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + text: 'xyz', + lineNumber: 3, + }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 }, + ]); + }); + + it('should carry state across lines', () => { + const processor = new AnsiProcessor(); + expect( + processor.process(` +a\x1b[45mb\x1b[35mc +x\x1b[39my\x1b[49mz`), + ).toEqual([ + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { background: 'magenta' }, + }, + { + text: 'c', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + ], + text: 'abc', + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + { + text: 'y', + modifiers: { background: 'magenta' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + text: 'xyz', + lineNumber: 3, + }, + ]); + }); + + it('should carry forward state when appending lines', () => { + const processor = new AnsiProcessor(); + const out1 = processor.process(` +a\x1b[36mb\x1b[3mc`); + expect(out1).toEqual([ + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + text: 'abc', + lineNumber: 2, + }, + ]); + + const out2 = processor.process(` +a\x1b[36mb\x1b[3mc +x\x1b[39my\x1b[23mz`); + expect(out2).toEqual([ + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + text: 'abc', + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: { foreground: 'cyan', italic: true }, + }, + { + text: 'y', + modifiers: { italic: true }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + text: 'xyz', + lineNumber: 3, + }, + ]); + + // Verifies that we appended rather than reprocessed + expect(out1[0]).toBe(out2[0]); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts new file mode 100644 index 0000000000..d0f835a70e --- /dev/null +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -0,0 +1,216 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ansiRegexMaker from 'ansi-regex'; + +const ansiRegex = ansiRegexMaker(); +const newlineRegex = /\n\r?/g; + +// A mapping of how each escape code changes the modifiers +const codeModifiers = Object.fromEntries( + Object.entries({ + 1: m => ({ ...m, bold: true }), + 3: m => ({ ...m, italic: true }), + 4: m => ({ ...m, underline: true }), + 22: ({ bold: _, ...m }) => m, + 23: ({ italic: _, ...m }) => m, + 24: ({ underline: _, ...m }) => m, + 30: m => ({ ...m, foreground: 'black' }), + 31: m => ({ ...m, foreground: 'red' }), + 32: m => ({ ...m, foreground: 'green' }), + 33: m => ({ ...m, foreground: 'yellow' }), + 34: m => ({ ...m, foreground: 'blue' }), + 35: m => ({ ...m, foreground: 'magenta' }), + 36: m => ({ ...m, foreground: 'cyan' }), + 37: m => ({ ...m, foreground: 'white' }), + 39: ({ foreground: _, ...m }) => m, + 90: m => ({ ...m, foreground: 'grey' }), + 40: m => ({ ...m, background: 'black' }), + 41: m => ({ ...m, background: 'red' }), + 42: m => ({ ...m, background: 'green' }), + 43: m => ({ ...m, background: 'yellow' }), + 44: m => ({ ...m, background: 'blue' }), + 45: m => ({ ...m, background: 'magenta' }), + 46: m => ({ ...m, background: 'cyan' }), + 47: m => ({ ...m, background: 'white' }), + 49: ({ background: _, ...m }) => m, + } as Record ChunkModifiers>).map( + ([code, modifier]) => [`\x1b[${code}m`, modifier], + ), +); + +export type AnsiColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'grey'; + +export interface ChunkModifiers { + foreground?: AnsiColor; + background?: AnsiColor; + bold?: boolean; + italic?: boolean; + underline?: boolean; +} + +export interface AnsiChunk { + text: string; + modifiers: ChunkModifiers; +} + +export class AnsiLine { + text: string; + + constructor( + readonly lineNumber: number = 1, + readonly chunks: AnsiChunk[] = [], + ) { + this.text = chunks + .map(c => c.text) + .join('') + .toLocaleLowerCase('en-US'); + } + + lastChunk(): AnsiChunk | undefined { + return this.chunks[this.chunks.length - 1]; + } + + replaceLastChunk(newChunks?: AnsiChunk[]) { + if (newChunks) { + this.chunks.splice(this.chunks.length - 1, 1, ...newChunks); + this.text = this.chunks + .map(c => c.text) + .join('') + .toLocaleLowerCase('en-US'); + } + } +} + +export class AnsiProcessor { + private text: string = ''; + private lines: AnsiLine[] = []; + + /** + * Processes a chunk of text while keeping internal state that optimizes + * subsequent processing that appends to the text. + */ + process(text: string): AnsiLine[] { + if (this.text === text) { + return this.lines; + } + + if (text.startsWith(this.text)) { + const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0; + const lastLine = this.lines[lastLineIndex] ?? new AnsiLine(); + const lastChunk = lastLine.lastChunk(); + + const newLines = this.processLines( + (lastChunk?.text ?? '') + text.slice(this.text.length), + lastChunk?.modifiers, + lastLine?.lineNumber, + ); + lastLine.replaceLastChunk(newLines[0]?.chunks); + + this.lines[lastLineIndex] = lastLine; + this.lines.push(...newLines.slice(1)); + } else { + this.lines = this.processLines(text); + } + this.text = text; + + return this.lines; + } + + // Split a chunk of text up into lines and process each line individually + private processLines = ( + text: string, + modifiers: ChunkModifiers = {}, + startingLineNumber: number = 1, + ): AnsiLine[] => { + const lines: AnsiLine[] = []; + + let currentModifiers = modifiers; + let currentLineNumber = startingLineNumber; + + let prevIndex = 0; + newlineRegex.lastIndex = 0; + for (;;) { + const match = newlineRegex.exec(text); + if (!match) { + const chunks = this.processText( + text.slice(prevIndex), + currentModifiers, + ); + lines.push(new AnsiLine(currentLineNumber, chunks)); + return lines; + } + + const line = text.slice(prevIndex, match.index); + prevIndex = match.index + match[0].length; + + const chunks = this.processText(line, currentModifiers); + lines.push(new AnsiLine(currentLineNumber, chunks)); + + // Modifiers that are active in the last chunk are carried over to the next line + currentModifiers = + chunks[chunks.length - 1].modifiers ?? currentModifiers; + currentLineNumber += 1; + } + }; + + // Processing of a one individual text chunk + private processText = ( + fullText: string, + modifiers: ChunkModifiers, + ): AnsiChunk[] => { + const chunks: AnsiChunk[] = []; + + let currentModifiers = modifiers; + + let prevIndex = 0; + ansiRegex.lastIndex = 0; + for (;;) { + const match = ansiRegex.exec(fullText); + if (!match) { + chunks.push({ + text: fullText.slice(prevIndex), + modifiers: currentModifiers, + }); + return chunks; + } + + const text = fullText.slice(prevIndex, match.index); + chunks.push({ text, modifiers: currentModifiers }); + + // For every escape code that we encounter we keep track of where the + // next chunk of text starts, and what modifiers it has + prevIndex = match.index + match[0].length; + currentModifiers = this.processCode(match[0], currentModifiers); + } + }; + + private processCode = ( + code: string, + modifiers: ChunkModifiers, + ): ChunkModifiers => { + return codeModifiers[code]?.(modifiers) ?? modifiers; + }; +} diff --git a/packages/core-components/src/components/LogViewer/LogLine.test.tsx b/packages/core-components/src/components/LogViewer/LogLine.test.tsx new file mode 100644 index 0000000000..c5fd6e39ca --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogLine.test.tsx @@ -0,0 +1,356 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AnsiLine, ChunkModifiers } from './AnsiProcessor'; +import { + calculateHighlightedChunks, + findSearchResults, + getModifierClasses, +} from './LogLine'; + +describe('getModifierClasses', () => { + const classes = { + modifierBold: 'bold', + modifierItalic: 'italic', + modifierUnderline: 'underline', + modifierForegroundBlack: 'black', + modifierForegroundRed: 'red', + modifierForegroundGreen: 'green', + modifierForegroundYellow: 'yellow', + modifierForegroundBlue: 'blue', + modifierForegroundMagenta: 'magenta', + modifierForegroundCyan: 'cyan', + modifierForegroundWhite: 'white', + modifierForegroundGrey: 'grey', + modifierBackgroundBlack: 'bg-black', + modifierBackgroundRed: 'bg-red', + modifierBackgroundGreen: 'bg-green', + modifierBackgroundYellow: 'bg-yellow', + modifierBackgroundBlue: 'bg-blue', + modifierBackgroundMagenta: 'bg-magenta', + modifierBackgroundCyan: 'bg-cyan', + modifierBackgroundWhite: 'bg-white', + modifierBackgroundGrey: 'bg-grey', + }; + const curried = (modifiers: ChunkModifiers) => + getModifierClasses( + classes as Parameters[0], + modifiers, + ); + + it('should transform modifiers to classes', () => { + expect(curried({})).toEqual(undefined); + expect(curried({ bold: true })).toEqual('bold'); + expect(curried({ italic: true })).toEqual('italic'); + expect(curried({ underline: true })).toEqual('underline'); + expect(curried({ foreground: 'black' })).toEqual('black'); + expect(curried({ background: 'black' })).toEqual('bg-black'); + expect( + curried({ + bold: true, + italic: true, + underline: true, + foreground: 'red', + background: 'red', + }), + ).toEqual('bold italic underline red bg-red'); + }); +}); + +describe('findSearchResults', () => { + it('should not return results if there is no match', () => { + expect(findSearchResults('Foo', 'Bar')).toEqual(undefined); + expect(findSearchResults('Foo Bar', 'oof')).toEqual(undefined); + expect(findSearchResults('Foo Bar', '')).toEqual(undefined); + expect(findSearchResults('', '')).toEqual(undefined); + expect(findSearchResults('', 'Foo')).toEqual(undefined); + }); + + it('should find result indices', () => { + expect(findSearchResults('Foo', 'Foo')).toEqual([{ start: 0, end: 3 }]); + expect(findSearchResults('Foo', 'o')).toEqual([ + { start: 1, end: 2 }, + { start: 2, end: 3 }, + ]); + expect(findSearchResults('FooBarBaz', 'Bar')).toEqual([ + { start: 3, end: 6 }, + ]); + expect(findSearchResults('Foo Bar Baz', ' ')).toEqual([ + { start: 3, end: 4 }, + { start: 7, end: 8 }, + ]); + expect(findSearchResults('FooBarBazBarFoo', 'Bar')).toEqual([ + { start: 3, end: 6 }, + { start: 9, end: 12 }, + ]); + expect(findSearchResults('FooBarBazBarFoo', 'Foo')).toEqual([ + { start: 0, end: 3 }, + { start: 12, end: 15 }, + ]); + }); + + it('should not overlap search results', () => { + expect(findSearchResults('aaa', 'aa')).toEqual([{ start: 0, end: 2 }]); + expect(findSearchResults('aaaa', 'aa')).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + expect(findSearchResults('aaaaa', 'aa')).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + }); +}); + +describe('calculateHighlightedChunks', () => { + it('should pass through chunks if there are no results', () => { + const chunks = [{ text: 'Foo', modifiers: {} }]; + const line = new AnsiLine(0, chunks); + expect(calculateHighlightedChunks(line, 'bar')).toBe(chunks); + }); + + it('should highlight one result from plain text', () => { + const line = new AnsiLine(0, [{ text: 'FooBarBaz', modifiers: {} }]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: {}, + highlight: 0, + }, + { + text: 'BarBaz', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'bar')).toEqual([ + { + text: 'Foo', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: 0, + }, + { + text: 'Baz', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'baz')).toEqual([ + { + text: 'FooBar', + modifiers: {}, + }, + { + text: 'Baz', + modifiers: {}, + highlight: 0, + }, + ]); + }); + + it('should highlight multiple results from plain text', () => { + const line = new AnsiLine(0, [ + { text: 'FooBarBazBazBarFoo', modifiers: {} }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: {}, + highlight: 0, + }, + { + text: 'BarBazBazBar', + modifiers: {}, + }, + { + text: 'Foo', + modifiers: {}, + highlight: 1, + }, + ]); + expect(calculateHighlightedChunks(line, 'bar')).toEqual([ + { + text: 'Foo', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: 0, + }, + { + text: 'BazBaz', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: 1, + }, + { + text: 'Foo', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'baz')).toEqual([ + { + text: 'FooBar', + modifiers: {}, + }, + { + text: 'Baz', + modifiers: {}, + highlight: 0, + }, + { + text: 'Baz', + modifiers: {}, + highlight: 1, + }, + { + text: 'BarFoo', + modifiers: {}, + }, + ]); + }); + + it('should forward modifiers to result', () => { + const line = new AnsiLine(0, [ + { text: 'FooBarBazBazBarFoo', modifiers: { bold: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: { bold: true }, + highlight: 0, + }, + { + text: 'BarBazBazBar', + modifiers: { bold: true }, + }, + { + text: 'Foo', + modifiers: { bold: true }, + highlight: 1, + }, + ]); + }); + + it('should highlight full chunks', () => { + const line = new AnsiLine(0, [ + { text: 'Foo', modifiers: { bold: true } }, + { text: 'BarBaz', modifiers: { bold: true } }, + { text: 'BazBar', modifiers: { italic: true } }, + { text: 'Foo', modifiers: { italic: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: { bold: true }, + highlight: 0, + }, + { + text: 'BarBaz', + modifiers: { bold: true }, + }, + { + text: 'BazBar', + modifiers: { italic: true }, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: 1, + }, + ]); + }); + + it('should highlight partial chunks', () => { + const line = new AnsiLine(0, [ + { text: 'Fo', modifiers: { bold: true } }, + { text: 'oFooFo', modifiers: {} }, + { text: 'oBarBaz', modifiers: { italic: true } }, + { text: 'Foo', modifiers: { foreground: 'blue' } }, + { text: 'FooFoo', modifiers: { italic: true } }, + { text: 'F', modifiers: { bold: true } }, + { text: 'o', modifiers: {} }, + { text: 'o', modifiers: { bold: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Fo', + modifiers: { bold: true }, + highlight: 0, + }, + { + text: 'o', + modifiers: {}, + highlight: 0, + }, + { + text: 'Foo', + modifiers: {}, + highlight: 1, + }, + { + text: 'Fo', + modifiers: {}, + highlight: 2, + }, + { + text: 'o', + modifiers: { italic: true }, + highlight: 2, + }, + { + text: 'BarBaz', + modifiers: { italic: true }, + }, + { + text: 'Foo', + modifiers: { foreground: 'blue' }, + highlight: 3, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: 4, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: 5, + }, + { + text: 'F', + modifiers: { bold: true }, + highlight: 6, + }, + { + text: 'o', + modifiers: {}, + highlight: 6, + }, + { + text: 'o', + modifiers: { bold: true }, + highlight: 6, + }, + ]); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx new file mode 100644 index 0000000000..ed3396360e --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -0,0 +1,178 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from 'react'; +import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor'; +import startCase from 'lodash/startCase'; +import classnames from 'classnames'; +import { useStyles } from './styles'; + +export function getModifierClasses( + classes: ReturnType, + modifiers: ChunkModifiers, +) { + const classNames = new Array(); + if (modifiers.bold) { + classNames.push(classes.modifierBold); + } + if (modifiers.italic) { + classNames.push(classes.modifierItalic); + } + if (modifiers.underline) { + classNames.push(classes.modifierUnderline); + } + if (modifiers.foreground) { + const key = `modifierForeground${startCase( + modifiers.foreground, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + if (modifiers.background) { + const key = `modifierBackground${startCase( + modifiers.background, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + return classNames.length > 0 ? classNames.join(' ') : undefined; +} + +export function findSearchResults(text: string, searchText: string) { + if (!searchText || !text.includes(searchText)) { + return undefined; + } + const searchResults = new Array<{ start: number; end: number }>(); + let offset = 0; + for (;;) { + const start = text.indexOf(searchText, offset); + if (start === -1) { + break; + } + const end = start + searchText.length; + searchResults.push({ start, end }); + offset = end; + } + return searchResults; +} + +export interface HighlightAnsiChunk extends AnsiChunk { + highlight?: number; +} + +export function calculateHighlightedChunks( + line: AnsiLine, + searchText: string, +): HighlightAnsiChunk[] { + const results = findSearchResults(line.text, searchText); + if (!results) { + return line.chunks; + } + + const chunks = new Array(); + + let lineOffset = 0; + let resultIndex = 0; + let result = results[resultIndex]; + for (const chunk of line.chunks) { + const { text, modifiers } = chunk; + if (!result || lineOffset + text.length < result.start) { + chunks.push(chunk); + lineOffset += text.length; + continue; + } + + let localOffset = 0; + while (result) { + const localStart = Math.max(result.start - lineOffset, 0); + if (localStart > text.length) { + break; // The next result is not in this chunk + } + + const localEnd = Math.min(result.end - lineOffset, text.length); + + const hasTextBeforeResult = localStart > localOffset; + if (hasTextBeforeResult) { + chunks.push({ text: text.slice(localOffset, localStart), modifiers }); + } + const hasResultText = localEnd > localStart; + if (hasResultText) { + chunks.push({ + modifiers, + highlight: resultIndex, + text: text.slice(localStart, localEnd), + }); + } + + localOffset = localEnd; + + const foundCompleteResult = result.end - lineOffset === localEnd; + if (foundCompleteResult) { + resultIndex += 1; + result = results[resultIndex]; + } else { + break; // The rest of the result is in the following chunks + } + } + + const hasTextAfterResult = localOffset < text.length; + if (hasTextAfterResult) { + chunks.push({ text: text.slice(localOffset), modifiers }); + } + + lineOffset += text.length; + } + + return chunks; +} + +export interface LogLineProps { + line: AnsiLine; + classes: ReturnType; + searchText: string; + highlightResultIndex?: number; +} + +export function LogLine({ + line, + classes, + searchText, + highlightResultIndex, +}: LogLineProps) { + const chunks = useMemo( + () => calculateHighlightedChunks(line, searchText), + [line, searchText], + ); + + const elements = useMemo( + () => + chunks.map(({ text, modifiers, highlight }, index) => ( + + {text} + + )), + [chunks, highlightResultIndex, classes], + ); + + return <>{elements}; +} diff --git a/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx new file mode 100644 index 0000000000..255e1d03b8 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, { ComponentType } from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { LogViewer } from './LogViewer'; + +export default { + title: 'Data Display/LogViewer', + component: LogViewer, + decorators: [(Story: ComponentType<{}>) => wrapInTestApp()], +}; + +const exampleLog = `Starting up task with 3 steps +Beginning step Fetch Skeleton + Template +info: Fetching template content from remote URL {"timestamp":"2021-12-03T15:47:11.625Z"} +info: Listing files and directories in template {"timestamp":"2021-12-03T15:47:12.797Z"} +info: Processing 33 template files/directories with input values {"component_id":"srnthsrthntrhsn","description":"rnthsrtnhssrthnrsthn","destination":{"host":"github.com","owner":"rtshnsrtmhrstmh","repo":"srtmhsrtmhrsthms"},"owner":"rstnhrstnhsrthn","timestamp":"2021-12-03T15:47:12.801Z"} +info: Writing file .editorconfig to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.816Z"} +info: Writing file .eslintignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.818Z"} +info: Writing file .eslintrc.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.820Z"} +info: Writing directory .github/ to template output path. {"timestamp":"2021-12-03T15:47:12.823Z"} +info: Writing file .gitignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.824Z"} +info: Writing file README.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.827Z"} +info: Writing file babel.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.829Z"} +info: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.831Z"} +info: Writing directory docs/ to template output path. {"timestamp":"2021-12-03T15:47:12.834Z"} +info: Writing file jest.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.836Z"} +info: Writing file mkdocs.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.838Z"} +info: Writing file next-env.d.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.841Z"} +info: Writing file next.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.844Z"} +info: Writing file package.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.845Z"} +info: Writing file prettier.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.848Z"} +info: Writing directory public/ to template output path. {"timestamp":"2021-12-03T15:47:12.849Z"} +info: Writing directory src/ to template output path. {"timestamp":"2021-12-03T15:47:12.850Z"} +info: Writing file tsconfig.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.851Z"} +info: Writing directory .github/workflows/ to template output path. {"timestamp":"2021-12-03T15:47:12.853Z"} +info: Writing file docs/index.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.854Z"} +info: Writing directory public/static/ to template output path. {"timestamp":"2021-12-03T15:47:12.857Z"} +info: Writing directory src/__tests__/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"} +info: Writing directory src/components/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"} +info: Writing directory src/pages/ to template output path. {"timestamp":"2021-12-03T15:47:12.859Z"} +info: Copying file/directory .github/workflows/build.yml without processing. {"timestamp":"2021-12-03T15:47:12.859Z"} +info: Writing file .github/workflows/build.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.860Z"} +info: Writing file public/static/fonts.css to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.861Z"} +info: Writing file src/components/Header.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.863Z"} +info: Writing file src/__tests__/index.test.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.865Z"} +info: Writing file src/pages/_app.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.868Z"} +info: Writing file src/pages/_document.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.871Z"} +info: Writing directory src/pages/api/ to template output path. {"timestamp":"2021-12-03T15:47:12.873Z"} +info: Writing file src/pages/index.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.874Z"} +info: Writing file src/pages/api/ping.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.877Z"} +info: Template result written to /var/folders/k6/9s7hd6w17115xlgwnsp0wsbr0000gn/T/5c9f8584-fded-4741-b6ef-46d94ff2cbdb {"timestamp":"2021-12-03T15:47:12.878Z"} +Finished step Fetch Skeleton + Template +Beginning step Publish +HttpError: Not Found + at /Users/patriko/dev/backstage/node_modules/@octokit/request/dist-node/index.js:86:21 + at runMicrotasks () + at processTicksAndRejections (internal/process/task_queues.js:95:5) + at async Object.handler (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts:156:20) + at async HandlebarsWorkflowRunner.execute (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts:254:11) + at async TaskWorker.runOneTask (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:110:13) + at async eval (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:100:9) +Run completed with status: failed`; + +export const ExampleLogViewer = () => ( +
+ +
+); diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx new file mode 100644 index 0000000000..bdb3973015 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, { lazy, Suspense } from 'react'; +import { useApp } from '@backstage/core-plugin-api'; + +const RealLogViewer = lazy(() => + import('./RealLogViewer').then(m => ({ default: m.RealLogViewer })), +); + +/** + * The properties for the LogViewer component. + * + * @public + */ +export interface LogViewerProps { + /** + * The text of the logs to display. + * + * The LogViewer component is optimized for appending content at the end of the text. + */ + text: string; + /** + * Styling overrides for classes within the LogViewer component. + */ + classes?: { + root?: string; + }; +} + +/** + * A component that displays logs in a scrollable text area. + * + * The LogViewer has support for search and filtering, as well as displaying + * text content with ANSI color escape codes. + * + * Since the LogViewer uses windowing to avoid rendering all contents at once, the + * log is sized automatically to fill the available vertical space. This means + * it may often be needed to wrap the LogViewer in a container that provides it + * with a fixed amount of space. + * + * @public + */ +export function LogViewer(props: LogViewerProps) { + const { Progress } = useApp().getComponents(); + return ( + }> + + + ); +} diff --git a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx new file mode 100644 index 0000000000..4d3baae68d --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 IconButton from '@material-ui/core/IconButton'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import { LogViewerSearch } from './useLogViewerSearch'; + +export interface LogViewerControlsProps extends LogViewerSearch {} + +export function LogViewerControls(props: LogViewerControlsProps) { + const { resultCount, resultIndexStep, toggleShouldFilter } = props; + const resultIndex = props.resultIndex ?? 0; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + if (event.metaKey || event.ctrlKey || event.altKey) { + toggleShouldFilter(); + } else { + resultIndexStep(event.shiftKey); + } + } + }; + + return ( + <> + {resultCount !== undefined && ( + <> + resultIndexStep(true)}> + + + + {Math.min(resultIndex + 1, resultCount)}/{resultCount} + + resultIndexStep()}> + + + + )} + props.setSearchInput(e.target.value)} + /> + + {props.shouldFilter ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx new file mode 100644 index 0000000000..8d5efb9724 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import UserEvent from '@testing-library/user-event'; +import { renderInTestApp } from '@backstage/test-utils'; +import { RealLogViewer } from './RealLogViewer'; +// eslint-disable-next-line import/no-extraneous-dependencies +import copyToClipboard from 'copy-to-clipboard'; + +// Used by useCopyToClipboard +jest.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// The inside needs mocking to render in jsdom +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: (props: { + children: (size: { width: number; height: number }) => ReactNode; + }) => <>{props.children({ width: 400, height: 200 })}, +})); + +const testText = `Some Log Line +Derp +Foo + +Foo Foo +Wat`; + +describe('RealLogViewer', () => { + it('should render text with search and filtering and copying', async () => { + const rendered = await renderInTestApp(); + expect(rendered.getByText('Derp')).toBeInTheDocument(); + expect(rendered.getByText('Foo Foo')).toBeInTheDocument(); + + UserEvent.tab(); + UserEvent.keyboard('Foo'); + + expect(rendered.getByText('1/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('2/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('3/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('1/3')).toBeInTheDocument(); + UserEvent.keyboard('{shift}{enter}{/shift}'); + expect(rendered.getByText('3/3')).toBeInTheDocument(); + + expect(rendered.queryByText('Some Log Line')).toBeInTheDocument(); + UserEvent.keyboard('{meta}{enter}{/meta}'); + expect(rendered.queryByText('Some Log Line')).not.toBeInTheDocument(); + UserEvent.keyboard('{meta}{enter}{/meta}'); + expect(rendered.queryByText('Some Log Line')).toBeInTheDocument(); + + // Tab down to line #2 and click + UserEvent.tab(); + UserEvent.tab(); + UserEvent.tab(); + UserEvent.click(document.activeElement!); + UserEvent.click(rendered.getByTestId('copy-button')); + + expect(copyToClipboard).toHaveBeenCalledWith('Derp'); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx new file mode 100644 index 0000000000..cd30e96c92 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, useMemo, useRef } from 'react'; +import IconButton from '@material-ui/core/IconButton'; +import CopyIcon from '@material-ui/icons/FileCopy'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; +import { AnsiProcessor } from './AnsiProcessor'; +import { HEADER_SIZE, useStyles } from './styles'; +import classnames from 'classnames'; +import { LogLine } from './LogLine'; +import { LogViewerControls } from './LogViewerControls'; +import { useLogViewerSearch } from './useLogViewerSearch'; +import { useLogViewerSelection } from './useLogViewerSelection'; + +export interface RealLogViewerProps { + text: string; + classes?: { root?: string }; +} + +export function RealLogViewer(props: RealLogViewerProps) { + const classes = useStyles({ classes: props.classes }); + const listRef = useRef(null); + + // The processor keeps state that optimizes appending to the text + const processor = useMemo(() => new AnsiProcessor(), []); + const lines = processor.process(props.text); + + const search = useLogViewerSearch(lines); + const selection = useLogViewerSelection(lines); + + useEffect(() => { + if (search.resultLine !== undefined && listRef.current) { + listRef.current.scrollToItem(search.resultLine - 1, 'center'); + } + }, [search.resultLine]); + + const handleSelectLine = ( + line: number, + event: { shiftKey: boolean; preventDefault: () => void }, + ) => { + event.preventDefault(); + selection.setSelection(line, event.shiftKey); + }; + + return ( + + {({ height, width }) => ( +
+ )} + + ); +} diff --git a/packages/core-components/src/components/LogViewer/index.ts b/packages/core-components/src/components/LogViewer/index.ts new file mode 100644 index 0000000000..f99695f163 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { LogViewer } from './LogViewer'; +export type { LogViewerProps } from './LogViewer'; +export type { LogViewerClassKey } from './styles'; diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts new file mode 100644 index 0000000000..2028809543 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { alpha, makeStyles } from '@material-ui/core/styles'; +import * as colors from '@material-ui/core/colors'; + +export const HEADER_SIZE = 40; + +/** @public Class keys for overriding LogViewer styles */ +export type LogViewerClassKey = + | 'root' + | 'header' + | 'log' + | 'line' + | 'lineSelected' + | 'lineCopyButton' + | 'lineNumber' + | 'textHighlight' + | 'textSelectedHighlight' + | 'modifierBold' + | 'modifierItalic' + | 'modifierUnderline' + | 'modifierForegroundBlack' + | 'modifierForegroundRed' + | 'modifierForegroundGreen' + | 'modifierForegroundYellow' + | 'modifierForegroundBlue' + | 'modifierForegroundMagenta' + | 'modifierForegroundCyan' + | 'modifierForegroundWhite' + | 'modifierForegroundGrey' + | 'modifierBackgroundBlack' + | 'modifierBackgroundRed' + | 'modifierBackgroundGreen' + | 'modifierBackgroundYellow' + | 'modifierBackgroundBlue' + | 'modifierBackgroundMagenta' + | 'modifierBackgroundCyan' + | 'modifierBackgroundWhite' + | 'modifierBackgroundGrey'; + +export const useStyles = makeStyles( + theme => ({ + root: { + background: theme.palette.background.paper, + }, + header: { + height: HEADER_SIZE, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + log: { + fontFamily: '"Monaco", monospace', + fontSize: theme.typography.pxToRem(12), + }, + line: { + position: 'relative', + whiteSpace: 'pre', + + '&:hover': { + background: theme.palette.action.hover, + }, + }, + lineSelected: { + background: theme.palette.action.selected, + + '&:hover': { + background: theme.palette.action.selected, + }, + }, + lineCopyButton: { + position: 'absolute', + paddingTop: 0, + paddingBottom: 0, + }, + lineNumber: { + display: 'inline-block', + textAlign: 'end', + width: 60, + marginRight: theme.spacing(1), + cursor: 'pointer', + }, + textHighlight: { + background: alpha(theme.palette.info.main, 0.15), + }, + textSelectedHighlight: { + background: alpha(theme.palette.info.main, 0.4), + }, + modifierBold: { + fontWeight: theme.typography.fontWeightBold, + }, + modifierItalic: { + fontStyle: 'italic', + }, + modifierUnderline: { + textDecoration: 'underline', + }, + modifierForegroundBlack: { + color: colors.common.black, + }, + modifierForegroundRed: { + color: colors.red[500], + }, + modifierForegroundGreen: { + color: colors.green[500], + }, + modifierForegroundYellow: { + color: colors.yellow[500], + }, + modifierForegroundBlue: { + color: colors.blue[500], + }, + modifierForegroundMagenta: { + color: colors.purple[500], + }, + modifierForegroundCyan: { + color: colors.cyan[500], + }, + modifierForegroundWhite: { + color: colors.common.white, + }, + modifierForegroundGrey: { + color: colors.grey[500], + }, + modifierBackgroundBlack: { + background: colors.common.black, + }, + modifierBackgroundRed: { + background: colors.red[500], + }, + modifierBackgroundGreen: { + background: colors.green[500], + }, + modifierBackgroundYellow: { + background: colors.yellow[500], + }, + modifierBackgroundBlue: { + background: colors.blue[500], + }, + modifierBackgroundMagenta: { + background: colors.purple[500], + }, + modifierBackgroundCyan: { + background: colors.cyan[500], + }, + modifierBackgroundWhite: { + background: colors.common.white, + }, + modifierBackgroundGrey: { + background: colors.grey[500], + }, + }), + { name: 'BackstageLogViewer' }, +); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx new file mode 100644 index 0000000000..a64f22765b --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { act, renderHook } from '@testing-library/react-hooks'; +import { applySearchFilter, useLogViewerSearch } from './useLogViewerSearch'; +import { AnsiLine } from './AnsiProcessor'; + +const lines = [ + new AnsiLine(1, [{ text: 'FooBar', modifiers: {} }]), + new AnsiLine(2, [{ text: 'Baz', modifiers: {} }]), + new AnsiLine(3, [{ text: 'FooBarFoo', modifiers: {} }]), + new AnsiLine(4, [{ text: 'Baz', modifiers: {} }]), + new AnsiLine(5, [{ text: 'BazFoo', modifiers: {} }]), + new AnsiLine(6, [{ text: 'FooFooFoo', modifiers: {} }]), + new AnsiLine(7, [{ text: '', modifiers: {} }]), + new AnsiLine(8, [{ text: 'Bar', modifiers: {} }]), +]; + +describe('applySearchFilter', () => { + it('should find search results', () => { + expect(applySearchFilter(lines, '')).toEqual({ + lines: lines, + results: undefined, + }); + expect(applySearchFilter(lines, 'foo')).toEqual({ + lines: [lines[0], lines[2], lines[4], lines[5]], + results: [ + { lineNumber: 1, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 1 }, + { lineNumber: 5, lineIndex: 0 }, + { lineNumber: 6, lineIndex: 0 }, + { lineNumber: 6, lineIndex: 1 }, + { lineNumber: 6, lineIndex: 2 }, + ], + }); + expect(applySearchFilter(lines, 'bar')).toEqual({ + lines: [lines[0], lines[2], lines[7]], + results: [ + { lineNumber: 1, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 0 }, + { lineNumber: 8, lineIndex: 0 }, + ], + }); + expect(applySearchFilter(lines, 'baz')).toEqual({ + lines: [lines[1], lines[3], lines[4]], + results: [ + { lineNumber: 2, lineIndex: 0 }, + { lineNumber: 4, lineIndex: 0 }, + { lineNumber: 5, lineIndex: 0 }, + ], + }); + }); +}); + +describe('useLogViewerSearch', () => { + it('should provide search state', () => { + const rendered = renderHook(() => useLogViewerSearch(lines)); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: false, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + rendered.result.current.resultIndexStep(); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: false, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + act(() => rendered.result.current.toggleShouldFilter()); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: true, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + act(() => rendered.result.current.setSearchInput('BAR')); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + searchInput: 'BAR', + searchText: 'bar', + shouldFilter: true, + resultCount: 3, + resultIndex: 0, + resultLine: 1, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 1, + resultLine: 3, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 2, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 0, + resultLine: 1, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep(true)); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 2, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.setSearchInput('FOO')); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[4], lines[5]], + searchInput: 'FOO', + searchText: 'foo', + shouldFilter: true, + resultCount: 7, + resultIndex: 2, + resultLine: 3, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.toggleShouldFilter()); + expect(rendered.result.current).toMatchObject({ + lines, + shouldFilter: false, + resultCount: 7, + resultIndex: 2, + resultLine: 3, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines, + searchInput: 'FOO', + searchText: 'foo', + shouldFilter: false, + resultCount: 7, + resultIndex: 3, + resultLine: 5, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 4, + resultLine: 6, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 5, + resultLine: 6, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 6, + resultLine: 6, + resultLineIndex: 2, + }); + + act(() => rendered.result.current.setSearchInput('BAR')); + expect(rendered.result.current).toMatchObject({ + searchText: 'bar', + resultCount: 3, + resultIndex: 6, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep(true)); + expect(rendered.result.current).toMatchObject({ + searchText: 'bar', + resultCount: 3, + resultIndex: 1, + resultLine: 3, + resultLineIndex: 0, + }); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx new file mode 100644 index 0000000000..4462a3ff50 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { useMemo, useState } from 'react'; +import { useToggle } from 'react-use'; +import { AnsiLine } from './AnsiProcessor'; + +export function applySearchFilter(lines: AnsiLine[], searchText: string) { + if (!searchText) { + return { lines }; + } + + const matchingLines = []; + const searchResults = []; + for (const line of lines) { + if (line.text.includes(searchText)) { + matchingLines.push(line); + + let offset = 0; + let lineResultIndex = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + searchResults.push({ + lineNumber: line.lineNumber, + lineIndex: lineResultIndex++, + }); + offset = start + searchText.length; + } + } + } + + return { + lines: matchingLines, + results: searchResults, + }; +} + +export interface LogViewerSearch { + lines: AnsiLine[]; + + searchText: string; + searchInput: string; + setSearchInput: (searchInput: string) => void; + + shouldFilter: boolean; + toggleShouldFilter: () => void; + + resultCount: number | undefined; + resultIndex: number | undefined; + resultIndexStep: (decrement?: boolean) => void; + + resultLine: number | undefined; + resultLineIndex: number | undefined; +} + +export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch { + const [searchInput, setSearchInput] = useState(''); + const searchText = searchInput.toLocaleLowerCase('en-US'); + + const [resultIndex, setResultIndex] = useState(0); + + const [shouldFilter, toggleShouldFilter] = useToggle(false); + + const filter = useMemo( + () => applySearchFilter(lines, searchText), + [lines, searchText], + ); + + const searchResult = filter.results + ? filter.results[Math.min(resultIndex, filter.results.length - 1)] + : undefined; + const resultCount = filter.results?.length; + + const resultIndexStep = (decrement?: boolean) => { + if (decrement) { + if (resultCount !== undefined) { + const next = Math.min(resultIndex - 1, resultCount - 2); + setResultIndex(next < 0 ? resultCount - 1 : next); + } + } else { + if (resultCount !== undefined) { + const next = resultIndex + 1; + setResultIndex(next >= resultCount ? 0 : next); + } + } + }; + + return { + lines: shouldFilter ? filter.lines : lines, + searchText, + searchInput, + setSearchInput, + shouldFilter, + toggleShouldFilter, + resultCount, + resultIndex, + resultIndexStep, + resultLine: searchResult?.lineNumber, + resultLineIndex: searchResult?.lineIndex, + }; +} diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx new file mode 100644 index 0000000000..7e00d50f33 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestApiProvider, MockErrorApi } from '@backstage/test-utils'; +import { errorApiRef } from '@backstage/core-plugin-api'; +import { AnsiLine } from './AnsiProcessor'; +import { useLogViewerSelection } from './useLogViewerSelection'; +// eslint-disable-next-line import/no-extraneous-dependencies +import copyToClipboard from 'copy-to-clipboard'; + +// Used by useCopyToClipboard +jest.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const lines = [ + new AnsiLine(1, [{ text: '1', modifiers: {} }]), + new AnsiLine(2, [{ text: '2', modifiers: {} }]), + new AnsiLine(3, [{ text: '3', modifiers: {} }]), + new AnsiLine(4, [{ text: '4', modifiers: {} }]), + new AnsiLine(5, [{ text: '5', modifiers: {} }]), +]; + +describe('useLogViewerSelection', () => { + it('should manage a selection', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(false); + + act(() => rendered.result.current.setSelection(2, false)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(true); + expect(rendered.result.current.isSelected(3)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(true); + expect(rendered.result.current.shouldShowButton(3)).toBe(false); + + act(() => rendered.result.current.setSelection(3, false)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(false); + + act(() => rendered.result.current.setSelection(1, true)); + + expect(rendered.result.current.isSelected(1)).toBe(true); + expect(rendered.result.current.isSelected(2)).toBe(true); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(true); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(false); + + act(() => rendered.result.current.setSelection(4, true)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(true); + expect(rendered.result.current.isSelected(5)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(true); + expect(rendered.result.current.shouldShowButton(5)).toBe(false); + + expect(copyToClipboard).not.toHaveBeenCalled(); + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4'); + + act(() => rendered.result.current.setSelection(2, true)); + act(() => rendered.result.current.setSelection(4, true)); + + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4'); + + act(() => rendered.result.current.setSelection(2, false)); + act(() => rendered.result.current.setSelection(4, false)); + act(() => rendered.result.current.setSelection(4, false)); + act(() => rendered.result.current.setSelection(5, true)); + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenCalledWith('5'); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx new file mode 100644 index 0000000000..a41f5e7159 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from 'react-use'; +import { AnsiLine } from './AnsiProcessor'; + +export function useLogViewerSelection(lines: AnsiLine[]) { + const errorApi = useApi(errorApiRef); + const [sel, setSelection] = useState<{ start: number; end: number }>(); + const start = sel ? Math.min(sel.start, sel.end) : undefined; + const end = sel ? Math.max(sel.start, sel.end) : undefined; + + const [{ error }, copyToClipboard] = useCopyToClipboard(); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + return { + shouldShowButton(line: number) { + return start === line || end === line; + }, + isSelected(line: number) { + if (!sel) { + return false; + } + return start! <= line && line <= end!; + }, + setSelection(line: number, add: boolean) { + if (add) { + setSelection(s => + s ? { start: s.start, end: line } : { start: line, end: line }, + ); + } else { + setSelection(s => + s?.start === line && s?.end === line + ? undefined + : { start: line, end: line }, + ); + } + }, + copySelection() { + if (sel) { + const copyText = lines + .slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end)) + .map(l => l.chunks.map(c => c.text).join('')) + .join('\n'); + copyToClipboard(copyText); + setSelection(undefined); + } + }, + }; +} diff --git a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index ef0d2373e4..0c6bd62bcb 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -22,7 +22,7 @@ import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; import React, { useState } from 'react'; import { isError } from '@backstage/errors'; -import { PendingAuthRequest } from '@backstage/core-plugin-api'; +import { PendingOAuthRequest } from '@backstage/core-plugin-api'; export type LoginRequestListItemClassKey = 'root'; @@ -36,7 +36,7 @@ const useItemStyles = makeStyles( ); type RowProps = { - request: PendingAuthRequest; + request: PendingOAuthRequest; busy: boolean; setBusy: (busy: boolean) => void; }; diff --git a/packages/core-components/src/components/Progress/Progress.test.tsx b/packages/core-components/src/components/Progress/Progress.test.tsx index 4f4e74ce08..d4057c1675 100644 --- a/packages/core-components/src/components/Progress/Progress.test.tsx +++ b/packages/core-components/src/components/Progress/Progress.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; import { Progress } from './Progress'; diff --git a/packages/core-components/src/components/Select/Select.tsx b/packages/core-components/src/components/Select/Select.tsx index a0e3aba94b..022df0a7ad 100644 --- a/packages/core-components/src/components/Select/Select.tsx +++ b/packages/core-components/src/components/Select/Select.tsx @@ -21,17 +21,18 @@ import FormControl from '@material-ui/core/FormControl'; import InputBase from '@material-ui/core/InputBase'; import MenuItem from '@material-ui/core/MenuItem'; import Select from '@material-ui/core/Select'; -import Typography from '@material-ui/core/Typography'; import { createStyles, makeStyles, Theme, withStyles, } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; import React, { useEffect, useState } from 'react'; import ClosedDropdown from './static/ClosedDropdown'; import OpenedDropdown from './static/OpenedDropdown'; +/** @public */ export type SelectInputBaseClassKey = 'root' | 'input'; const BootstrapInput = withStyles( @@ -60,6 +61,7 @@ const BootstrapInput = withStyles( { name: 'BackstageSelectInputBase' }, )(InputBase); +/** @public */ export type SelectClassKey = | 'formControl' | 'label' @@ -93,6 +95,7 @@ const useStyles = makeStyles( margin: 2, }, checkbox: {}, + root: { display: 'flex', flexDirection: 'column', @@ -101,23 +104,28 @@ const useStyles = makeStyles( { name: 'BackstageSelect' }, ); -type Item = { +/** @public */ +export type SelectItem = { label: string; value: string | number; }; -type Selection = string | string[] | number | number[]; +/** @public */ +export type SelectedItems = string | string[] | number | number[]; export type SelectProps = { multiple?: boolean; - items: Item[]; + items: SelectItem[]; label: string; placeholder?: string; - selected?: Selection; - onChange: (arg: Selection) => void; + selected?: SelectedItems; + onChange: (arg: SelectedItems) => void; triggerReset?: boolean; + native?: boolean; + disabled?: boolean; }; +/** @public */ export function SelectComponent(props: SelectProps) { const { multiple, @@ -127,9 +135,11 @@ export function SelectComponent(props: SelectProps) { selected, onChange, triggerReset, + native = false, + disabled = false, } = props; const classes = useStyles(); - const [value, setValue] = useState( + const [value, setValue] = useState( selected || (multiple ? [] : ''), ); const [isOpen, setOpen] = useState(false); @@ -145,11 +155,15 @@ export function SelectComponent(props: SelectProps) { }, [selected]); const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { - setValue(event.target.value as Selection); - onChange(event.target.value as Selection); + setValue(event.target.value as SelectedItems); + onChange(event.target.value as SelectedItems); }; const handleClick = (event: React.ChangeEvent) => { + if (disabled) { + event.preventDefault(); + return; + } setOpen(previous => { if (multiple && !(event.target instanceof HTMLElement)) { return true; @@ -175,6 +189,8 @@ export function SelectComponent(props: SelectProps) { diff --git a/packages/core-components/src/components/Select/index.tsx b/packages/core-components/src/components/Select/index.tsx index 0f149f05b5..870c2a0899 100644 --- a/packages/core-components/src/components/Select/index.tsx +++ b/packages/core-components/src/components/Select/index.tsx @@ -15,6 +15,11 @@ */ export { SelectComponent as Select } from './Select'; -export type { SelectClassKey, SelectInputBaseClassKey } from './Select'; +export type { + SelectClassKey, + SelectedItems, + SelectInputBaseClassKey, + SelectItem, +} from './Select'; export type { ClosedDropdownClassKey } from './static/ClosedDropdown'; export type { OpenedDropdownClassKey } from './static/OpenedDropdown'; diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx index bbd48c127c..f76524fe50 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -14,9 +14,8 @@ * limitations under the License. */ import { renderInTestApp } from '@backstage/test-utils'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { Route, Routes } from 'react-router'; import { RoutedTabs } from './RoutedTabs'; diff --git a/packages/core-components/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core-components/src/components/TabbedLayout/TabbedLayout.test.tsx index 2bc60783d7..55422faffc 100644 --- a/packages/core-components/src/components/TabbedLayout/TabbedLayout.test.tsx +++ b/packages/core-components/src/components/TabbedLayout/TabbedLayout.test.tsx @@ -14,9 +14,8 @@ * limitations under the License. */ import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { Route, Routes } from 'react-router'; import { TabbedLayout } from './TabbedLayout'; diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx index 838cb02a76..2a8918c055 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { WarningPanel } from './WarningPanel'; import Button from '@material-ui/core/Button'; -import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; +import { Link } from '../Link'; export default { title: 'Feedback/Warning Panel', @@ -32,7 +32,7 @@ export const Default = () => ( <> This example entity is missing an annotation. If this is unexpected, please make sure you have set up everything correctly by following{' '} - this guide. + this guide. } /> diff --git a/packages/core-components/src/components/index.ts b/packages/core-components/src/components/index.ts index 3c9376a894..473fab8444 100644 --- a/packages/core-components/src/components/index.ts +++ b/packages/core-components/src/components/index.ts @@ -30,6 +30,7 @@ export * from './HeaderIconLinkRow'; export * from './HorizontalScrollGrid'; export * from './Lifecycle'; export * from './Link'; +export * from './LogViewer'; export * from './MarkdownContent'; export * from './OAuthRequestDialog'; export * from './OverflowTooltip'; diff --git a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx index 3d55307c81..991c419dc3 100644 --- a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx @@ -16,13 +16,13 @@ import React from 'react'; import Typography from '@material-ui/core/Typography'; -import Link from '@material-ui/core/Link'; import Grid from '@material-ui/core/Grid'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; import { MicDrop } from './MicDrop'; import { useNavigate } from 'react-router'; import { useSupportConfig } from '../../hooks'; +import { Link } from '../../components/Link'; interface IErrorPageProps { status: string; @@ -80,14 +80,11 @@ export function ErrorPage(props: IErrorPageProps) { Looks like someone dropped the mic! - navigate(-1)}> + navigate(-1)}> Go back - ... or please{' '} - - contact support - {' '} - if you think this is a bug. + ... or please contact support if you + think this is a bug. diff --git a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx index b4576a27a9..73c46a1ddc 100644 --- a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx +++ b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx @@ -15,10 +15,10 @@ */ import { makeStyles } from '@material-ui/core/styles'; -import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import Grid from '@material-ui/core/Grid'; import React from 'react'; +import { Link } from '../../components/Link'; /** @public */ export type HeaderLabelClassKey = 'root' | 'label' | 'value'; @@ -74,7 +74,7 @@ export function HeaderLabel(props: HeaderLabelProps) { {label} - {url ? {content} : content} + {url ? {content} : content} ); diff --git a/packages/core-components/src/layout/Sidebar/Bar.test.tsx b/packages/core-components/src/layout/Sidebar/Bar.test.tsx new file mode 100644 index 0000000000..d288aa94c5 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/Bar.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BuildRoundedIcon from '@material-ui/icons/BuildRounded'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import MenuBookIcon from '@material-ui/icons/MenuBook'; +import AcUnitIcon from '@material-ui/icons/AcUnit'; +import { Sidebar, SidebarExpandButton } from './Bar'; +import { SidebarItem, SidebarSearchField } from './Items'; +import { SidebarSubmenuItem } from './SidebarSubmenuItem'; +import { SidebarSubmenu } from './SidebarSubmenu'; +import { SidebarPinStateContext } from '.'; + +async function renderScalableSidebar() { + await renderInTestApp( + {} }} + > + + {}} to="/search" /> + {}} text="Catalog"> + + + + + + + + + , + ); +} + +describe('Sidebar', () => { + beforeEach(async () => { + await renderScalableSidebar(); + }); + + describe('Click to Expand', () => { + it('Sidebar should show expanded items when expand button is clicked', async () => { + userEvent.click(screen.getByTestId('sidebar-expand-button')); + expect(await screen.findByText('Create...')).toBeInTheDocument(); + }); + it('Sidebar should not show expanded items when hovered on', async () => { + userEvent.hover(screen.getByTestId('sidebar-root')); + expect(screen.queryByText('Create...')).not.toBeInTheDocument(); + }); + }); + describe('Submenu Items', () => { + it('Extended sidebar with submenu content hidden by default', async () => { + expect(screen.queryByText('Tools')).not.toBeInTheDocument(); + expect(screen.queryByText('Misc')).not.toBeInTheDocument(); + }); + + it('Extended sidebar with submenu content visible when hover over submenu items', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + expect(await screen.findByText('Tools')).toBeInTheDocument(); + expect(await screen.findByText('Misc')).toBeInTheDocument(); + }); + + it('Multicategory item in submenu shows drop down on click', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + userEvent.click(screen.getByText('Misc')); + expect(screen.getByText('dropdown item 1')).toBeInTheDocument(); + expect(screen.getByText('dropdown item 2')).toBeInTheDocument(); + }); + + it('Dropdown item in submenu renders a link when `to` value is provided', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + userEvent.click(screen.getByText('Misc')); + expect(screen.getByText('dropdown item 1').closest('a')).toHaveAttribute( + 'href', + '/dropdownitemlink', + ); + }); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index e6fa3820ad..bada68a028 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -16,11 +16,13 @@ import { makeStyles } from '@material-ui/core/styles'; import useMediaQuery from '@material-ui/core/useMediaQuery'; -import clsx from 'clsx'; -import React, { useRef, useState, useContext, PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import React, { useState, useContext, PropsWithChildren, useRef } from 'react'; import { sidebarConfig, SidebarContext } from './config'; import { BackstageTheme } from '@backstage/theme'; import { SidebarPinStateContext } from './Page'; +import DoubleArrowRight from './icons/DoubleArrowRight'; +import DoubleArrowLeft from './icons/DoubleArrowLeft'; export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen'; @@ -46,7 +48,6 @@ const useStyles = makeStyles( msOverflowStyle: 'none', scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, - borderRight: `1px solid #383838`, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.shortest, @@ -58,6 +59,19 @@ const useStyles = makeStyles( display: 'none', }, }, + expandButton: { + background: 'none', + border: 'none', + color: theme.palette.navigation.color, + width: '100%', + cursor: 'pointer', + position: 'relative', + height: 48, + }, + arrows: { + position: 'absolute', + right: 10, + }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, transition: theme.transitions.create('width', { @@ -78,10 +92,12 @@ enum State { type Props = { openDelayMs?: number; closeDelayMs?: number; + disableExpandOnHover?: boolean; }; export function Sidebar(props: PropsWithChildren) { const { + disableExpandOnHover = false, openDelayMs = sidebarConfig.defaultOpenDelayMs, closeDelayMs = sidebarConfig.defaultCloseDelayMs, children, @@ -132,22 +148,31 @@ export function Sidebar(props: PropsWithChildren) { const isOpen = (state === State.Open && !isSmallScreen) || isPinned; + const setOpen = (open: boolean) => { + if (open) { + handleOpen(); + } else { + handleClose(); + } + }; + return (
{} : handleOpen} + onFocus={disableExpandOnHover ? () => {} : handleOpen} + onMouseLeave={disableExpandOnHover ? () => {} : handleClose} + onBlur={disableExpandOnHover ? () => {} : handleClose} >
@@ -157,3 +182,39 @@ export function Sidebar(props: PropsWithChildren) {
); } + +/** + * A button which allows you to expand the sidebar when clicked. + * Use optionally to replace sidebar's expand-on-hover feature with expand-on-click. + * + * @public + */ +export const SidebarExpandButton = () => { + const classes = useStyles(); + const { isOpen, setOpen } = useContext(SidebarContext); + const { isPinned } = useContext(SidebarPinStateContext); + const isSmallScreen = useMediaQuery(theme => + theme.breakpoints.down('md'), + ); + + const handleClick = () => { + setOpen(!isOpen); + }; + + if (isPinned || isSmallScreen) { + return null; + } + + return ( + + ); +}; diff --git a/packages/core-components/src/layout/Sidebar/Items.test.tsx b/packages/core-components/src/layout/Sidebar/Items.test.tsx index 123c30ceae..f4d699d09c 100644 --- a/packages/core-components/src/layout/Sidebar/Items.test.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.test.tsx @@ -20,7 +20,7 @@ import { createEvent, fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import { Sidebar } from './Bar'; +import { Sidebar, SidebarExpandButton } from './Bar'; import { SidebarItem, SidebarSearchField } from './Items'; import { renderHook } from '@testing-library/react-hooks'; import { hexToRgb, makeStyles } from '@material-ui/core/styles'; @@ -44,6 +44,7 @@ async function renderSidebar() { text="Create..." className={result.current.spotlight} /> + , ); userEvent.hover(screen.getByTestId('sidebar-root')); diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 3f15ae4ea6..7d00db1b1a 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -14,18 +14,21 @@ * limitations under the License. */ -import { IconComponent } from '@backstage/core-plugin-api'; +import { IconComponent, useElementFilter } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, styled, Theme } from '@material-ui/core/styles'; import Badge from '@material-ui/core/Badge'; import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; import { CreateCSSProperties } from '@material-ui/core/styles/withStyles'; +import ArrowRightIcon from '@material-ui/icons/ArrowRight'; import SearchIcon from '@material-ui/icons/Search'; -import clsx from 'clsx'; +import classnames from 'classnames'; import React, { + Children, forwardRef, KeyboardEventHandler, + PropsWithChildren, ReactNode, useContext, useState, @@ -33,10 +36,18 @@ import React, { import { Link, NavLinkProps, + resolvePath, useLocation, useResolvedPath, } from 'react-router-dom'; -import { sidebarConfig, SidebarContext } from './config'; +import { + sidebarConfig, + SidebarContext, + SidebarItemWithSubmenuContext, +} from './config'; +import { SidebarSubmenu } from './SidebarSubmenu'; +import { isLocationMatch } from './utils'; +import { Location } from 'history'; export type SidebarItemClassKey = | 'root' @@ -85,6 +96,16 @@ const useStyles = makeStyles( open: { width: drawerWidthOpen, }, + highlightable: { + '&:hover': { + background: + theme.palette.navigation.navItem?.hoverBackground ?? '#404040', + }, + }, + highlighted: { + background: + theme.palette.navigation.navItem?.hoverBackground ?? '#404040', + }, label: { // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs fontWeight: 'bold', @@ -123,13 +144,24 @@ const useStyles = makeStyles( textAlign: 'center', marginRight: theme.spacing(1), }, + closedItemIcon: { + width: '100%', + justifyContent: 'center', + }, + submenuArrow: { + position: 'absolute', + right: 0, + }, selected: { '&$root': { borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, color: theme.palette.navigation.selectedColor, }, '&$closed': { - width: drawerWidthClosed - selectedIndicatorWidth, + width: drawerWidthClosed, + }, + '& $closedItemIcon': { + paddingRight: selectedIndicatorWidth, }, '& $iconContainer': { marginLeft: -selectedIndicatorWidth, @@ -140,16 +172,124 @@ const useStyles = makeStyles( { name: 'BackstageSidebarItem' }, ); +function isSidebarItemWithSubmenuActive( + submenu: ReactNode, + currentLocation: Location, +) { + // Item is active if any of submenu items have active paths + const toPathnames: string[] = []; + let isActive = false; + let submenuItems: ReactNode; + Children.forEach(submenu, element => { + if (!React.isValidElement(element)) return; + submenuItems = element.props.children; + }); + Children.forEach(submenuItems, element => { + if (!React.isValidElement(element)) return; + if (element.props.dropdownItems) { + element.props.dropdownItems.map((item: { to: string }) => + toPathnames.push(item.to), + ); + } else if (element.props.to) { + toPathnames.push(element.props.to); + } + }); + isActive = toPathnames.some(to => { + const toLocation = resolvePath(to); + return isLocationMatch(currentLocation, toLocation); + }); + return isActive; +} + +const SidebarItemWithSubmenu = ({ + text, + hasNotifications = false, + icon: Icon, + children, +}: PropsWithChildren) => { + const classes = useStyles(); + const [isHoveredOn, setIsHoveredOn] = useState(false); + const currentLocation = useLocation(); + const isActive = isSidebarItemWithSubmenuActive(children, currentLocation); + + const handleMouseEnter = () => { + setIsHoveredOn(true); + }; + const handleMouseLeave = () => { + setIsHoveredOn(false); + }; + + const { isOpen } = useContext(SidebarContext); + const itemIcon = ( + + + + ); + const openContent = ( + <> +
+ {itemIcon} +
+ {text && ( + + {text} + + )} +
{}
+ + ); + const closedContent = itemIcon; + + return ( + +
+
+ {isOpen ? openContent : closedContent} + {!isHoveredOn && ( + + )} +
+ {isHoveredOn && children} +
+
+ ); +}; + type SidebarItemBaseProps = { icon: IconComponent; text?: string; hasNotifications?: boolean; - children?: ReactNode; + disableHighlight?: boolean; className?: string; }; type SidebarItemButtonProps = SidebarItemBaseProps & { onClick: (ev: React.MouseEvent) => void; + children?: ReactNode; }; type SidebarItemLinkProps = SidebarItemBaseProps & { @@ -157,7 +297,21 @@ type SidebarItemLinkProps = SidebarItemBaseProps & { onClick?: (ev: React.MouseEvent) => void; } & NavLinkProps; -type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps; +type SidebarItemWithSubmenuProps = SidebarItemBaseProps & { + to?: string; + onClick?: (ev: React.MouseEvent) => void; + children: ReactNode; +}; + +/** + * SidebarItem with 'to' property will be a clickable link. + * SidebarItem with 'onClick' property and without 'to' property will be a clickable button. + * SidebarItem which wraps a SidebarSubmenu will be a clickable button which opens a submenu. + */ +type SidebarItemProps = + | SidebarItemLinkProps + | SidebarItemButtonProps + | SidebarItemWithSubmenuProps; function isButtonItem( props: SidebarItemProps, @@ -208,7 +362,10 @@ export const WorkaroundNavLink = React.forwardRef< ref={ref} aria-current={ariaCurrent} style={{ ...style, ...(isActive ? activeStyle : undefined) }} - className={clsx([className, isActive ? activeClassName : undefined])} + className={classnames([ + className, + isActive ? activeClassName : undefined, + ])} /> ); }); @@ -218,6 +375,7 @@ export const SidebarItem = forwardRef((props, ref) => { icon: Icon, text, hasNotifications = false, + disableHighlight = false, onClick, children, className, @@ -235,6 +393,7 @@ export const SidebarItem = forwardRef((props, ref) => { variant="dot" overlap="circular" invisible={!hasNotifications} + className={classnames({ [classes.closedItemIcon]: !isOpen })} > @@ -260,14 +419,48 @@ export const SidebarItem = forwardRef((props, ref) => { const childProps = { onClick, - className: clsx( + className: classnames( className, classes.root, isOpen ? classes.open : classes.closed, isButtonItem(props) && classes.buttonItem, + { [classes.highlightable]: !disableHighlight }, ), }; + let hasSubmenu = false; + let submenu: ReactNode; + const componentType = ( + + <> + + ).type; + // Filter children for SidebarSubmenu components + const submenus = useElementFilter(children, elements => + elements.getElements().filter(child => child.type === componentType), + ); + // Error thrown if more than one SidebarSubmenu in a SidebarItem + if (submenus.length > 1) { + throw new Error( + 'Cannot render more than one SidebarSubmenu inside a SidebarItem', + ); + } else if (submenus.length === 1) { + hasSubmenu = true; + submenu = submenus[0]; + } + + if (hasSubmenu) { + return ( + + {submenu} + + ); + } + if (isButtonItem(props)) { return ( + {dropdownItems && showDropDown && ( +
+ {dropdownItems.map((object, key) => ( + + + {object.title} + + + ))} +
+ )} +
+ ); + } + + return ( +
+ + + + {title} + + +
+ ); +}; diff --git a/packages/core-components/src/layout/Sidebar/config.ts b/packages/core-components/src/layout/Sidebar/config.ts index d2a690e38f..dbcdb7d788 100644 --- a/packages/core-components/src/layout/Sidebar/config.ts +++ b/packages/core-components/src/layout/Sidebar/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createContext } from 'react'; +import { createContext, Dispatch, SetStateAction } from 'react'; const drawerWidthClosed = 72; const iconPadding = 24; @@ -37,13 +37,43 @@ export const sidebarConfig = { userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2, }; +export const submenuConfig = { + drawerWidthClosed: 0, + drawerWidthOpen: 202, + // As per NN/g's guidance on timing for exposing hidden content + // See https://www.nngroup.com/articles/timing-exposing-content/ + defaultOpenDelayMs: 100, + defaultCloseDelayMs: 0, + defaultFadeDuration: 200, + logoHeight: 32, + iconContainerWidth: drawerWidthClosed, + iconSize: drawerWidthClosed - iconPadding * 2, + iconPadding, + selectedIndicatorWidth: 3, + userBadgePadding, + userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2, +}; + export const SIDEBAR_INTRO_LOCAL_STORAGE = '@backstage/core/sidebar-intro-dismissed'; export type SidebarContextType = { isOpen: boolean; + setOpen: (open: boolean) => void; }; export const SidebarContext = createContext({ isOpen: false, + setOpen: _open => {}, }); + +export type SidebarItemWithSubmenuContextType = { + isHoveredOn: boolean; + setIsHoveredOn: Dispatch>; +}; + +export const SidebarItemWithSubmenuContext = + createContext({ + isHoveredOn: false, + setIsHoveredOn: () => {}, + }); diff --git a/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx new file mode 100644 index 0000000000..eb487940a8 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos'; + +const useStyles = makeStyles({ + iconContainer: { + display: 'flex', + position: 'relative', + width: '100%', + }, + arrow1: { + right: '6px', + position: 'absolute', + }, +}); + +const DoubleArrowLeft = () => { + const classes = useStyles(); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default DoubleArrowLeft; diff --git a/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx new file mode 100644 index 0000000000..e1e9b9476d --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos'; + +const useStyles = makeStyles({ + iconContainer: { + display: 'flex', + position: 'relative', + width: '100%', + }, + arrow1: { + right: '6px', + position: 'absolute', + }, +}); + +const DoubleArrowRight = () => { + const classes = useStyles(); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default DoubleArrowRight; diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index 067a9bf2eb..3d922f1322 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -export { Sidebar } from './Bar'; +export { Sidebar, SidebarExpandButton } from './Bar'; +export { SidebarSubmenuItem } from './SidebarSubmenuItem'; +export { SidebarSubmenu } from './SidebarSubmenu'; +export type { SidebarSubmenuProps } from './SidebarSubmenu'; +export type { + SidebarSubmenuItemProps, + SidebarSubmenuItemDropdownItem, +} from './SidebarSubmenuItem'; export type { SidebarClassKey } from './Bar'; export { SidebarPage, SidebarPinStateContext } from './Page'; export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page'; @@ -26,7 +33,12 @@ export { SidebarSpacer, SidebarScrollWrapper, } from './Items'; -export type { SidebarItemClassKey } from './Items'; +export type { + SidebarItemClassKey, + SidebarSpaceClassKey, + SidebarSpacerClassKey, + SidebarDividerClassKey, +} from './Items'; export { IntroCard, SidebarIntro } from './Intro'; export type { SidebarIntroClassKey } from './Intro'; export { diff --git a/packages/core-components/src/layout/Sidebar/utils.test.ts b/packages/core-components/src/layout/Sidebar/utils.test.ts new file mode 100644 index 0000000000..b08dcda0fc --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/utils.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Location, Path } from 'history'; +import { isLocationMatch } from './utils'; + +describe('isLocationMatching', () => { + let currentLocation: Location; + let toLocation: Path; + + it('return false when pathname in target and current location differ', async () => { + currentLocation = { + pathname: '/catalog', + search: '?kind=component', + state: null, + hash: '', + key: '', + }; + toLocation = { + pathname: '/catalog-a', + search: '?kind=component', + hash: '', + }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(false); + }); + + it('return true when exact match between current and target location parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?kind=component', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?kind=component', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return true when target query parameters are subset of current location query parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?x=foo&y=bar', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return false when no matching query parameters between target and current location', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(false); + }); + + it('return true when query parameters match in different order', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar&x=foo', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo&y=bar', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return true when there is a matching query parameter alongside extra parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar&x=foo', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/utils.ts b/packages/core-components/src/layout/Sidebar/utils.ts new file mode 100644 index 0000000000..1afebe796d --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/utils.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Location, Path } from 'history'; +import { isEqual, isMatch } from 'lodash'; +import qs from 'qs'; + +export function isLocationMatch(currentLocation: Location, toLocation: Path) { + const toDecodedSearch = new URLSearchParams(toLocation.search).toString(); + const toQueryParameters = qs.parse(toDecodedSearch); + + const currentDecodedSearch = new URLSearchParams( + currentLocation.search, + ).toString(); + const currentQueryParameters = qs.parse(currentDecodedSearch); + + const matching = + isEqual(toLocation.pathname, currentLocation.pathname) && + isMatch(currentQueryParameters, toQueryParameters); + + return matching; +} diff --git a/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts b/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts new file mode 100644 index 0000000000..db4731704c --- /dev/null +++ b/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + IdentityApi, + ProfileInfo, + BackstageUserIdentity, +} from '@backstage/core-plugin-api'; + +export class GuestUserIdentity implements IdentityApi { + getUserId(): string { + return 'guest'; + } + + async getIdToken(): Promise { + return undefined; + } + + getProfile(): ProfileInfo { + return { + email: 'guest@example.com', + displayName: 'Guest', + }; + } + + async getProfileInfo(): Promise { + return { + email: 'guest@example.com', + displayName: 'Guest', + }; + } + + async getBackstageIdentity(): Promise { + const userEntityRef = `user:default/guest`; + return { + type: 'user', + userEntityRef, + ownershipEntityRefs: [userEntityRef], + }; + } + + async getCredentials(): Promise<{ token?: string | undefined }> { + return {}; + } + + async signOut(): Promise {} +} diff --git a/packages/core-components/src/layout/SignInPage/IdentityApiSignOutProxy.ts b/packages/core-components/src/layout/SignInPage/IdentityApiSignOutProxy.ts new file mode 100644 index 0000000000..ec45bc7399 --- /dev/null +++ b/packages/core-components/src/layout/SignInPage/IdentityApiSignOutProxy.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + BackstageUserIdentity, + IdentityApi, + ProfileInfo, +} from '@backstage/core-plugin-api'; + +export class IdentityApiSignOutProxy implements IdentityApi { + private constructor( + private readonly config: { + identityApi: IdentityApi; + signOut: IdentityApi['signOut']; + }, + ) {} + + static from(config: { + identityApi: IdentityApi; + signOut: IdentityApi['signOut']; + }): IdentityApi { + return new IdentityApiSignOutProxy(config); + } + + getUserId(): string { + return this.config.identityApi.getUserId(); + } + + getIdToken(): Promise { + return this.config.identityApi.getIdToken(); + } + + getProfile(): ProfileInfo { + return this.config.identityApi.getProfile(); + } + + getProfileInfo(): Promise { + return this.config.identityApi.getProfileInfo(); + } + + getBackstageIdentity(): Promise { + return this.config.identityApi.getBackstageIdentity(); + } + + getCredentials(): Promise<{ token?: string | undefined }> { + return this.config.identityApi.getCredentials(); + } + + signOut(): Promise { + return this.config.signOut(); + } +} diff --git a/packages/core-components/src/layout/SignInPage/LegacyUserIdentity.ts b/packages/core-components/src/layout/SignInPage/LegacyUserIdentity.ts new file mode 100644 index 0000000000..e28f94072e --- /dev/null +++ b/packages/core-components/src/layout/SignInPage/LegacyUserIdentity.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + IdentityApi, + ProfileInfo, + BackstageUserIdentity, + SignInResult, +} from '@backstage/core-plugin-api'; + +function parseJwtPayload(token: string) { + const [_header, payload, _signature] = token.split('.'); + return JSON.parse(atob(payload)); +} + +export class LegacyUserIdentity implements IdentityApi { + private constructor(private readonly result: SignInResult) {} + + getUserId(): string { + return this.result.userId; + } + + static fromResult(result: SignInResult): LegacyUserIdentity { + return new LegacyUserIdentity(result); + } + + async getIdToken(): Promise { + return this.result.getIdToken?.(); + } + + getProfile(): ProfileInfo { + return this.result.profile; + } + + async getProfileInfo(): Promise { + return this.result.profile; + } + + async getBackstageIdentity(): Promise { + const token = await this.getIdToken(); + + if (!token) { + const userEntityRef = `user:default/${this.getUserId()}`; + return { + type: 'user', + userEntityRef, + ownershipEntityRefs: [userEntityRef], + }; + } + + const { sub, ent } = parseJwtPayload(token); + return { + type: 'user', + userEntityRef: sub, + ownershipEntityRefs: ent ?? [], + }; + } + + async getCredentials(): Promise<{ token?: string | undefined }> { + const token = await this.result.getIdToken?.(); + return { token }; + } + + async signOut(): Promise { + return this.result.signOut?.(); + } +} diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index bd4fe37480..304e427f33 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -15,11 +15,12 @@ */ import { - BackstageIdentity, + BackstageIdentityResponse, configApiRef, SignInPageProps, useApi, } from '@backstage/core-plugin-api'; +import { UserIdentity } from './UserIdentity'; import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; @@ -49,7 +50,7 @@ type SingleSignInPageProps = SignInPageProps & { export type Props = MultiSignInPageProps | SingleSignInPageProps; export const MultiSignInPage = ({ - onResult, + onSignInSuccess, providers = [], title, align = 'left', @@ -60,7 +61,7 @@ export const MultiSignInPage = ({ const signInProviders = getSignInProviders(providers); const [loading, providerElements] = useSignInProviders( signInProviders, - onResult, + onSignInSuccess, ); if (loading) { @@ -87,9 +88,9 @@ export const MultiSignInPage = ({ }; export const SingleSignInPage = ({ - onResult, provider, auto, + onSignInSuccess, }: SingleSignInPageProps) => { const classes = useStyles(); const authApi = useApi(provider.apiRef); @@ -105,47 +106,42 @@ export const SingleSignInPage = ({ type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { try { - let identity: BackstageIdentity | undefined; + let identityResponse: BackstageIdentityResponse | undefined; if (checkExisting) { // Do an initial check if any logged-in session exists - identity = await authApi.getBackstageIdentity({ + identityResponse = await authApi.getBackstageIdentity({ optional: true, }); } // If no session exists, show the sign-in page - if (!identity && (showPopup || auto)) { + if (!identityResponse && (showPopup || auto)) { // Unless auto is set to true, this step should not happen. // When user intentionally clicks the Sign In button, autoShowPopup is set to true setShowLoginPage(true); - identity = await authApi.getBackstageIdentity({ + identityResponse = await authApi.getBackstageIdentity({ instantPopup: true, }); - if (!identity) { + if (!identityResponse) { throw new Error( `The ${provider.title} provider is not configured to support sign-in`, ); } } - if (!identity) { + if (!identityResponse) { setShowLoginPage(true); return; } const profile = await authApi.getProfile(); - onResult({ - userId: identity!.id, - profile: profile!, - getIdToken: () => { - return authApi - .getBackstageIdentity() - .then(i => i!.token ?? i!.idToken); - }, - signOut: async () => { - await authApi.signOut(); - }, - }); + onSignInSuccess( + UserIdentity.create({ + identity: identityResponse.identity, + authApi, + profile, + }), + ); } catch (err: any) { // User closed the sign-in modal setError(err); diff --git a/packages/core-components/src/layout/SignInPage/UserIdentity.test.ts b/packages/core-components/src/layout/SignInPage/UserIdentity.test.ts new file mode 100644 index 0000000000..6b136ed65d --- /dev/null +++ b/packages/core-components/src/layout/SignInPage/UserIdentity.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api'; +import { UserIdentity } from './UserIdentity'; + +describe('UserIdentity', () => { + it('should cache a successful response from the AuthApi for getProfile', async () => { + const mockIdentity: BackstageUserIdentity = { + type: 'user', + userEntityRef: 'user:default/blam', + ownershipEntityRefs: [], + }; + + const mockProfileInfo: ProfileInfo = { + displayName: 'Blam', + email: 'blob@boop.com', + }; + + const mockAuthApi: any = { + getProfile: jest.fn().mockResolvedValue(mockProfileInfo), + }; + + const userIdentity = UserIdentity.create({ + authApi: mockAuthApi, + identity: mockIdentity, + }); + + await userIdentity.getProfileInfo(); + await userIdentity.getProfileInfo(); + + const response = await userIdentity.getProfileInfo(); + + expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(1); + + expect(response).toEqual(mockProfileInfo); + }); + + it('should not cache failures for the AuthApi for getProfile', async () => { + const mockIdentity: BackstageUserIdentity = { + type: 'user', + userEntityRef: 'user:default/blam', + ownershipEntityRefs: [], + }; + + const mockProfileInfo: ProfileInfo = { + displayName: 'Blam', + email: 'blob@boop.com', + }; + + const mockAuthApi: any = { + getProfile: jest + .fn() + .mockRejectedValueOnce(new Error('boop')) + .mockResolvedValueOnce(mockProfileInfo), + }; + + const userIdentity = UserIdentity.create({ + authApi: mockAuthApi, + identity: mockIdentity, + }); + + await expect(() => userIdentity.getProfileInfo()).rejects.toThrow('boop'); + const response = await userIdentity.getProfileInfo(); + + expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(2); + + expect(response).toEqual(mockProfileInfo); + }); +}); diff --git a/packages/core-components/src/layout/SignInPage/UserIdentity.ts b/packages/core-components/src/layout/SignInPage/UserIdentity.ts new file mode 100644 index 0000000000..7781c79154 --- /dev/null +++ b/packages/core-components/src/layout/SignInPage/UserIdentity.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + IdentityApi, + ProfileInfo, + ProfileInfoApi, + BackstageUserIdentity, + BackstageIdentityApi, + SessionApi, + SignInResult, +} from '@backstage/core-plugin-api'; + +import { GuestUserIdentity } from './GuestUserIdentity'; +import { LegacyUserIdentity } from './LegacyUserIdentity'; + +/** + * An implementation of the IdentityApi that is constructed using + * various backstage user identity representations. + * + * @public + */ +export class UserIdentity implements IdentityApi { + private profilePromise?: Promise; + /** + * Creates a new IdentityApi that acts as a Guest User. + * + * @public + */ + static createGuest(): IdentityApi { + return new GuestUserIdentity(); + } + + /** + * Creates a new IdentityApi using a legacy SignInResult object. + * + * @public + */ + static fromLegacy(result: SignInResult): IdentityApi { + return LegacyUserIdentity.fromResult(result); + } + + /** + * Creates a new IdentityApi implementation using a user identity + * and an auth API that will be used to request backstage tokens. + * + * @public + */ + static create(options: { + identity: BackstageUserIdentity; + authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi; + /** + * Passing a profile synchronously allows the deprecated `getProfile` method to be + * called by consumers of the {@link @backstage/core-plugin-api#IdentityApi}. If you + * do not have any consumers of that method then this is safe to leave out. + * + * @deprecated Only provide this if you have plugins that call the synchronous `getProfile` method, which is also deprecated. + */ + profile?: ProfileInfo; + }): IdentityApi { + return new UserIdentity(options.identity, options.authApi, options.profile); + } + + private constructor( + private readonly identity: BackstageUserIdentity, + private readonly authApi: ProfileInfoApi & + BackstageIdentityApi & + SessionApi, + private readonly profile?: ProfileInfo, + ) {} + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */ + getUserId(): string { + const ref = this.identity.userEntityRef; + const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref); + if (!match) { + throw new TypeError(`Invalid user entity reference "${ref}"`); + } + + return match[3]; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */ + async getIdToken(): Promise { + const identity = await this.authApi.getBackstageIdentity(); + return identity!.token; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */ + getProfile(): ProfileInfo { + if (!this.profile) { + throw new Error( + 'The identity API does not implement synchronous profile fetching, use getProfileInfo() instead', + ); + } + return this.profile; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */ + async getProfileInfo(): Promise { + if (this.profilePromise) { + return await this.profilePromise; + } + + try { + this.profilePromise = this.authApi.getProfile() as Promise; + return await this.profilePromise; + } catch (ex) { + this.profilePromise = undefined; + throw ex; + } + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */ + async getBackstageIdentity(): Promise { + return this.identity; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */ + async getCredentials(): Promise<{ token?: string | undefined }> { + const identity = await this.authApi.getBackstageIdentity(); + return { token: identity!.token }; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */ + async signOut(): Promise { + return this.authApi.signOut(); + } +} diff --git a/packages/core-components/src/layout/SignInPage/auth0Provider.tsx b/packages/core-components/src/layout/SignInPage/auth0Provider.tsx index e1372a2362..739c3709a9 100644 --- a/packages/core-components/src/layout/SignInPage/auth0Provider.tsx +++ b/packages/core-components/src/layout/SignInPage/auth0Provider.tsx @@ -26,17 +26,18 @@ import { errorApiRef, } from '@backstage/core-plugin-api'; import { ForwardedError } from '@backstage/errors'; +import { UserIdentity } from './UserIdentity'; -const Component: ProviderComponent = ({ onResult }) => { +const Component: ProviderComponent = ({ onSignInSuccess }) => { const auth0AuthApi = useApi(auth0AuthApiRef); const errorApi = useApi(errorApiRef); const handleLogin = async () => { try { - const identity = await auth0AuthApi.getBackstageIdentity({ + const identityResponse = await auth0AuthApi.getBackstageIdentity({ instantPopup: true, }); - if (!identity) { + if (!identityResponse) { throw new Error( 'The Auth0 provider is not configured to support sign-in', ); @@ -44,15 +45,13 @@ const Component: ProviderComponent = ({ onResult }) => { const profile = await auth0AuthApi.getProfile(); - onResult({ - userId: identity!.id, - profile: profile!, - getIdToken: () => - auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken), - signOut: async () => { - await auth0AuthApi.signOut(); - }, - }); + onSignInSuccess( + UserIdentity.create({ + identity: identityResponse.identity, + authApi: auth0AuthApi, + profile, + }), + ); } catch (error) { errorApi.post(new ForwardedError('Auth0 login failed', error)); } @@ -77,25 +76,20 @@ const Component: ProviderComponent = ({ onResult }) => { const loader: ProviderLoader = async apis => { const auth0AuthApi = apis.get(auth0AuthApiRef)!; - const identity = await auth0AuthApi.getBackstageIdentity({ + const identityResponse = await auth0AuthApi.getBackstageIdentity({ optional: true, }); - if (!identity) { + if (!identityResponse) { return undefined; } const profile = await auth0AuthApi.getProfile(); - - return { - userId: identity.id, - profile: profile!, - getIdToken: () => - auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken), - signOut: async () => { - await auth0AuthApi.signOut(); - }, - }; + return UserIdentity.create({ + identity: identityResponse.identity, + authApi: auth0AuthApi, + profile, + }); }; export const auth0Provider: SignInProvider = { Component, loader }; diff --git a/packages/core-components/src/layout/SignInPage/commonProvider.tsx b/packages/core-components/src/layout/SignInPage/commonProvider.tsx index 515ae01e4d..8f426a3907 100644 --- a/packages/core-components/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/commonProvider.tsx @@ -27,36 +27,33 @@ import { import { useApi, errorApiRef } from '@backstage/core-plugin-api'; import { GridItem } from './styles'; import { ForwardedError } from '@backstage/errors'; +import { UserIdentity } from './UserIdentity'; -const Component: ProviderComponent = ({ config, onResult }) => { +const Component: ProviderComponent = ({ config, onSignInSuccess }) => { const { apiRef, title, message } = config as SignInProviderConfig; const authApi = useApi(apiRef); const errorApi = useApi(errorApiRef); const handleLogin = async () => { try { - const identity = await authApi.getBackstageIdentity({ + const identityResponse = await authApi.getBackstageIdentity({ instantPopup: true, }); - if (!identity) { + if (!identityResponse) { throw new Error( `The ${title} provider is not configured to support sign-in`, ); } const profile = await authApi.getProfile(); - onResult({ - userId: identity!.id, - profile: profile!, - getIdToken: () => { - return authApi - .getBackstageIdentity() - .then(i => i!.token ?? i!.idToken); - }, - signOut: async () => { - await authApi.signOut(); - }, - }); + + onSignInSuccess( + UserIdentity.create({ + identity: identityResponse.identity, + profile, + authApi, + }), + ); } catch (error) { errorApi.post(new ForwardedError('Login failed', error)); } @@ -82,25 +79,21 @@ const Component: ProviderComponent = ({ config, onResult }) => { const loader: ProviderLoader = async (apis, apiRef) => { const authApi = apis.get(apiRef)!; - const identity = await authApi.getBackstageIdentity({ + const identityResponse = await authApi.getBackstageIdentity({ optional: true, }); - if (!identity) { + if (!identityResponse) { return undefined; } const profile = await authApi.getProfile(); - return { - userId: identity.id, - profile: profile!, - getIdToken: () => - authApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken), - signOut: async () => { - await authApi.signOut(); - }, - }; + return UserIdentity.create({ + identity: identityResponse.identity, + profile, + authApi, + }); }; export const commonProvider: SignInProvider = { Component, loader }; diff --git a/packages/core-components/src/layout/SignInPage/customProvider.tsx b/packages/core-components/src/layout/SignInPage/customProvider.tsx index da1848ac05..13ba906079 100644 --- a/packages/core-components/src/layout/SignInPage/customProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/customProvider.tsx @@ -26,6 +26,7 @@ import isEmpty from 'lodash/isEmpty'; import { InfoCard } from '../InfoCard/InfoCard'; import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; import { GridItem } from './styles'; +import { UserIdentity } from './UserIdentity'; // accept base64url format according to RFC7515 (https://tools.ietf.org/html/rfc7515#section-3) const ID_TOKEN_REGEX = /^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i; @@ -60,7 +61,7 @@ const asInputRef = (renderResult: UseFormRegisterReturn) => { }; }; -const Component: ProviderComponent = ({ onResult }) => { +const Component: ProviderComponent = ({ onSignInSuccess }) => { const classes = useFormStyles(); const { register, handleSubmit, formState } = useForm({ mode: 'onChange', @@ -68,14 +69,15 @@ const Component: ProviderComponent = ({ onResult }) => { const { errors } = formState; - const handleResult = ({ userId, idToken }: Data) => { - onResult({ - userId, - profile: { - email: `${userId}@example.com`, - }, - getIdToken: idToken ? async () => idToken : undefined, - }); + const handleResult = ({ userId }: Data) => { + onSignInSuccess( + UserIdentity.fromLegacy({ + userId, + profile: { + email: `${userId}@example.com`, + }, + }), + ); }; return ( diff --git a/packages/core-components/src/layout/SignInPage/guestProvider.tsx b/packages/core-components/src/layout/SignInPage/guestProvider.tsx index e91d9cbc75..b2370a98d5 100644 --- a/packages/core-components/src/layout/SignInPage/guestProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/guestProvider.tsx @@ -20,16 +20,9 @@ import Button from '@material-ui/core/Button'; import { InfoCard } from '../InfoCard/InfoCard'; import { GridItem } from './styles'; import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { GuestUserIdentity } from './GuestUserIdentity'; -const result = { - userId: 'guest', - profile: { - email: 'guest@example.com', - displayName: 'Guest', - }, -}; - -const Component: ProviderComponent = ({ onResult }) => ( +const Component: ProviderComponent = ({ onSignInSuccess }) => ( ( @@ -56,7 +49,7 @@ const Component: ProviderComponent = ({ onResult }) => ( ); const loader: ProviderLoader = async () => { - return result; + return new GuestUserIdentity(); }; export const guestProvider: SignInProvider = { Component, loader }; diff --git a/packages/core-components/src/layout/SignInPage/index.ts b/packages/core-components/src/layout/SignInPage/index.ts index caa8506399..b3c7618f50 100644 --- a/packages/core-components/src/layout/SignInPage/index.ts +++ b/packages/core-components/src/layout/SignInPage/index.ts @@ -18,3 +18,4 @@ export type { SignInProviderConfig } from './types'; export { SignInPage } from './SignInPage'; export type { SignInPageClassKey } from './styles'; export type { CustomProviderClassKey } from './customProvider'; +export { UserIdentity } from './UserIdentity'; diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 19915e6aa9..23d6c95b3c 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -17,10 +17,10 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { SignInPageProps, - SignInResult, useApi, useApiHolder, errorApiRef, + IdentityApi, } from '@backstage/core-plugin-api'; import { IdentityProviders, @@ -30,6 +30,7 @@ import { import { commonProvider } from './commonProvider'; import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; +import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -81,7 +82,7 @@ export function getSignInProviders( export const useSignInProviders = ( providers: SignInProviderType, - onResult: SignInPageProps['onResult'], + onSignInSuccess: SignInPageProps['onSignInSuccess'], ) => { const errorApi = useApi(errorApiRef); const apiHolder = useApiHolder(); @@ -89,16 +90,18 @@ export const useSignInProviders = ( // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( - (result: SignInResult) => { - onResult({ - ...result, - signOut: async () => { - localStorage.removeItem(PROVIDER_STORAGE_KEY); - await result.signOut?.(); - }, - }); + (identityApi: IdentityApi) => { + onSignInSuccess( + IdentityApiSignOutProxy.from({ + identityApi, + signOut: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await identityApi.signOut?.(); + }, + }), + ); }, - [onResult], + [onSignInSuccess], ); // In this effect we check if the user has already selected an existing login @@ -151,7 +154,14 @@ export const useSignInProviders = ( return () => { didCancel = true; }; - }, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]); + }, [ + loading, + errorApi, + onSignInSuccess, + apiHolder, + providers, + handleWrappedResult, + ]); // This renders all available sign-in providers const elements = useMemo( @@ -161,7 +171,7 @@ export const useSignInProviders = ( const { Component } = provider.components; - const handleResult = (result: SignInResult) => { + const handleSignInSuccess = (result: IdentityApi) => { localStorage.setItem(PROVIDER_STORAGE_KEY, provider.id); handleWrappedResult(result); @@ -171,7 +181,7 @@ export const useSignInProviders = ( ); }), diff --git a/packages/core-components/src/layout/SignInPage/types.ts b/packages/core-components/src/layout/SignInPage/types.ts index 1e4e4a2997..5b458448bf 100644 --- a/packages/core-components/src/layout/SignInPage/types.ts +++ b/packages/core-components/src/layout/SignInPage/types.ts @@ -17,12 +17,12 @@ import { ComponentType } from 'react'; import { SignInPageProps, - SignInResult, ApiHolder, ApiRef, ProfileInfoApi, BackstageIdentityApi, SessionApi, + IdentityApi, } from '@backstage/core-plugin-api'; export type SignInProviderConfig = { @@ -41,7 +41,7 @@ export type ProviderComponent = ComponentType< export type ProviderLoader = ( apis: ApiHolder, apiRef: ApiRef, -) => Promise; +) => Promise; export type SignInProvider = { Component: ProviderComponent; diff --git a/packages/core-components/src/overridableComponents.ts b/packages/core-components/src/overridableComponents.ts index af35445e24..c53c47da1b 100644 --- a/packages/core-components/src/overridableComponents.ts +++ b/packages/core-components/src/overridableComponents.ts @@ -34,6 +34,7 @@ import { LifecycleClassKey, MarkdownContentClassKey, LoginRequestListItemClassKey, + LogViewerClassKey, OAuthRequestDialogClassKey, OverflowTooltipClassKey, GaugeClassKey, @@ -82,6 +83,9 @@ import { ItemCardHeaderClassKey, PageClassKey, SidebarClassKey, + SidebarSpaceClassKey, + SidebarSpacerClassKey, + SidebarDividerClassKey, SidebarIntroClassKey, SidebarItemClassKey, SidebarPageClassKey, @@ -110,6 +114,7 @@ type BackstageComponentsNameToClassKey = { BackstageLifecycle: LifecycleClassKey; BackstageMarkdownContent: MarkdownContentClassKey; BackstageLoginRequestListItem: LoginRequestListItemClassKey; + BackstageLogViewer: LogViewerClassKey; OAuthRequestDialog: OAuthRequestDialogClassKey; BackstageOverflowTooltip: OverflowTooltipClassKey; BackstageGauge: GaugeClassKey; @@ -155,6 +160,9 @@ type BackstageComponentsNameToClassKey = { BackstageItemCardHeader: ItemCardHeaderClassKey; BackstagePage: PageClassKey; BackstageSidebar: SidebarClassKey; + BackstageSidebarSpace: SidebarSpaceClassKey; + BackstageSidebarSpacer: SidebarSpacerClassKey; + BackstageSidebarDivider: SidebarDividerClassKey; BackstageSidebarIntro: SidebarIntroClassKey; BackstageSidebarItem: SidebarItemClassKey; BackstageSidebarPage: SidebarPageClassKey; diff --git a/packages/core-plugin-api/CHANGELOG.md b/packages/core-plugin-api/CHANGELOG.md index 0022c99c68..06d36ef486 100644 --- a/packages/core-plugin-api/CHANGELOG.md +++ b/packages/core-plugin-api/CHANGELOG.md @@ -1,5 +1,53 @@ # @backstage/core-plugin-api +## 0.3.1 + +### Patch Changes + +- 18d4f500af: Deprecated the `AnyAnalyticsContext` type and mark the `AnalyticsApi` experimental. +- 8a7372cfd5: Deprecated `auth0AuthApiRef`, `oauth2ApiRef`, `oidcAuthApiRef`, `samlAuthApiRef`, and marked the rest of the auth `ApiRef`s as experimental. For more information on how to address the deprecations, see https://backstage.io/docs/api/deprecations#generic-auth-api-refs. +- 760791a642: Renamed `AuthProvider` to `AuthProviderInfo` and add a required 'id' property to match the majority of usage. The `AuthProvider` type without the `id` property still exists but is deprecated, and all usage of it without an `id` is deprecated as well. For example, calling `createAuthRequest` without a `provider.id` is deprecated and it will be required in the future. + + The following types have been renamed. The old names are still exported but deprecated, and are scheduled for removal in a future release. + + - Renamed `AuthRequesterOptions` to `OAuthRequesterOptions` + - Renamed `AuthRequester` to `OAuthRequester` + - Renamed `PendingAuthRequest` to `PendingOAuthRequest` + +## 0.3.0 + +### Minor Changes + +- a036b65c2f: The `IdentityApi` has received several updates. The `getUserId`, `getProfile`, and `getIdToken` have all been deprecated. + + The replacement for `getUserId` is the new `getBackstageIdentity` method, which provides both the `userEntityRef` as well as the `ownershipEntityRefs` that are used to resolve ownership. Existing usage of the user ID would typically be using a fixed entity kind and namespace, for example `` `user:default/${identityApi.getUserId()}` ``, this kind of usage should now instead use the `userEntityRef` directly. + + The replacement for `getProfile` is the new async `getProfileInfo`. + + The replacement for `getIdToken` is the new `getCredentials` method, which provides an optional token to the caller like before, but it is now wrapped in an object for forwards compatibility. + + The deprecated `idToken` field of the `BackstageIdentity` type has been removed, leaving only the new `token` field, which should be used instead. The `BackstageIdentity` also received a new `identity` field, which is a decoded version of the information within the token. Furthermore the `BackstageIdentity` has been renamed to `BackstageIdentityResponse`, with the old name being deprecated. + + We expect most of the breaking changes in this update to have low impact since the `IdentityApi` implementation is provided by the app, but it is likely that some tests need to be updated. + + Another breaking change is that the `SignInPage` props have been updated, and the `SignInResult` type is now deprecated. This is unlikely to have any impact on the usage of this package, but it is an important change that you can find more information about in the [`@backstage/core-app-api` CHANGELOG.md](https://github.com/backstage/backstage/blob/master/packages/core-app-api/CHANGELOG.md). + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/version-bridge@0.1.1 + +## 0.2.2 + +### Patch Changes + +- b291d0ed7e: Tweaked the logged deprecation warning for `createRouteRef` to hopefully make it more clear. +- bacb94ea8f: Documented the options of each of the extension creation functions. +- Updated dependencies + - @backstage/theme@0.2.14 + ## 0.2.1 ### Patch Changes diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 560071e1fa..c9045a21d4 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -6,23 +6,21 @@ /// import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api'; -import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api'; -import { Observable as Observable_2 } from '@backstage/types'; -import { Observer as Observer_2 } from '@backstage/types'; +import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/types'; import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; -import { Subscription as Subscription_2 } from '@backstage/types'; import { SvgIconProps } from '@material-ui/core'; // @public export type AlertApi = { post(alert: AlertMessage): void; - alert$(): Observable_2; + alert$(): Observable; }; // @public @@ -34,28 +32,26 @@ export type AlertMessage = { severity?: 'success' | 'info' | 'warning' | 'error'; }; -// @public +// @alpha export type AnalyticsApi = { captureEvent(event: AnalyticsEvent): void; }; -// @public +// @alpha export const analyticsApiRef: ApiRef; -// @public -export const AnalyticsContext: ({ - attributes, - children, -}: { +// @alpha +export const AnalyticsContext: (options: { attributes: Partial; children: ReactNode; }) => JSX.Element; -// @public -export type AnalyticsContextValue = CommonAnalyticsContext & - AnyAnalyticsContext; +// @alpha +export type AnalyticsContextValue = CommonAnalyticsContext & { + [param in string]: string | boolean | number | undefined; +}; -// @public +// @alpha export type AnalyticsEvent = { action: string; subject: string; @@ -64,12 +60,12 @@ export type AnalyticsEvent = { context: AnalyticsContextValue; }; -// @public +// @alpha export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; }; -// @public +// @alpha export type AnalyticsTracker = { captureEvent: ( action: string, @@ -81,7 +77,7 @@ export type AnalyticsTracker = { ) => void; }; -// @public +// @public @deprecated export type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; }; @@ -142,21 +138,8 @@ export type ApiRef = { // @public export type ApiRefConfig = { id: string; - description?: string; }; -// @public @deprecated -export type ApiRefsToTypes< - T extends { - [key in string]: ApiRef; - }, -> = { - [key in keyof T]: ApiRefType; -}; - -// @public @deprecated -export type ApiRefType = T extends ApiRef ? U : never; - // @public export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; @@ -180,15 +163,14 @@ export type AppTheme = { id: string; title: string; variant: 'light' | 'dark'; - theme: BackstageTheme; icon?: React.ReactElement; - Provider?(props: { children: ReactNode }): JSX.Element | null; + Provider(props: { children: ReactNode }): JSX.Element | null; }; // @public export type AppThemeApi = { getInstalledThemes(): AppTheme[]; - activeThemeId$(): Observable_2; + activeThemeId$(): Observable; getActiveThemeId(): string | undefined; setActiveThemeId(themeId?: string): void; }; @@ -196,7 +178,7 @@ export type AppThemeApi = { // @public export const appThemeApiRef: ApiRef; -// @public +// @alpha export const atlassianAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; @@ -208,27 +190,26 @@ export function attachComponentData

( data: unknown, ): void; -// @public +// @public @deprecated export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; +// @public @deprecated (undocumented) +export type AuthProvider = Omit; + // @public -export type AuthProvider = { +export type AuthProviderInfo = { + id: string; title: string; icon: IconComponent; }; -// @public -export type AuthRequester = ( - scopes: Set, -) => Promise; +// @public @deprecated (undocumented) +export type AuthRequester = OAuthRequester; -// @public -export type AuthRequesterOptions = { - provider: AuthProvider; - onAuthRequest(scopes: Set): Promise; -}; +// @public @deprecated (undocumented) +export type AuthRequesterOptions = OAuthRequesterOptions; // @public export type AuthRequestOptions = { @@ -236,18 +217,21 @@ export type AuthRequestOptions = { instantPopup?: boolean; }; -// @public -export type BackstageIdentity = { - id: string; - idToken: string; - token: string; -}; +// @public @deprecated +export type BackstageIdentity = BackstageIdentityResponse; // @public export type BackstageIdentityApi = { getBackstageIdentity( options?: AuthRequestOptions, - ): Promise; + ): Promise; +}; + +// @public +export type BackstageIdentityResponse = { + id: string; + token: string; + identity: BackstageUserIdentity; }; // @public @@ -265,6 +249,13 @@ export type BackstagePlugin< }; // @public +export type BackstageUserIdentity = { + type: 'user'; + userEntityRef: string; + ownershipEntityRefs: string[]; +}; + +// @alpha export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; @@ -275,7 +266,7 @@ export type BootErrorPageProps = { error: Error; }; -// @public +// @alpha export type CommonAnalyticsContext = { pluginId: string; routeRef: string; @@ -366,11 +357,8 @@ export function createRouteRef< }, ParamKey extends string = never, >(config: { - id?: string; + id: string; params?: ParamKey[]; - path?: string; - icon?: OldIconComponent; - title?: string; }): RouteRef>; // @public @@ -405,14 +393,10 @@ export interface ElementCollection { }): ElementCollection; } -// @public @deprecated (undocumented) -type Error_2 = ErrorApiError; -export { Error_2 as Error }; - // @public export type ErrorApi = { post(error: ErrorApiError, context?: ErrorApiErrorContext): void; - error$(): Observable_2<{ + error$(): Observable<{ error: ErrorApiError; context?: ErrorApiErrorContext; }>; @@ -440,9 +424,6 @@ export type ErrorBoundaryFallbackProps = { resetError: () => void; }; -// @public @deprecated (undocumented) -export type ErrorContext = ErrorApiErrorContext; - // @public export type Extension = { expose(plugin: BackstagePlugin): T; @@ -498,23 +479,31 @@ export enum FeatureFlagState { None = 0, } +// @public +export type FetchApi = { + fetch: typeof fetch; +}; + +// @public +export const fetchApiRef: ApiRef; + // @public export function getComponentData( node: ReactNode, type: string, ): T | undefined; -// @public +// @alpha export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// @public +// @alpha export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// @public +// @alpha export const googleAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -531,8 +520,13 @@ export type IconComponent = ComponentType<{ // @public export type IdentityApi = { getUserId(): string; - getProfile(): ProfileInfo; getIdToken(): Promise; + getProfile(): ProfileInfo; + getProfileInfo(): Promise; + getBackstageIdentity(): Promise; + getCredentials(): Promise<{ + token?: string; + }>; signOut(): Promise; }; @@ -557,7 +551,7 @@ export type MergeParams< P2 extends AnyParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); -// @public +// @alpha export const microsoftAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -566,7 +560,7 @@ export const microsoftAuthApiRef: ApiRef< SessionApi >; -// @public +// @public @deprecated export const oauth2ApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -585,25 +579,32 @@ export type OAuthApi = { // @public export type OAuthRequestApi = { - createAuthRequester( - options: AuthRequesterOptions, - ): AuthRequester; - authRequest$(): Observable_2; + createAuthRequester( + options: OAuthRequesterOptions, + ): OAuthRequester; + authRequest$(): Observable; }; // @public export const oauthRequestApiRef: ApiRef; +// @public +export type OAuthRequester = ( + scopes: Set, +) => Promise; + +// @public +export type OAuthRequesterOptions = { + provider: Omit & { + id?: string; + }; + onAuthRequest(scopes: Set): Promise; +}; + // @public export type OAuthScope = string | string[]; // @public @deprecated -export type Observable = Observable_2; - -// @public @deprecated -export type Observer = Observer_2; - -// @public export const oidcAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -612,7 +613,7 @@ export const oidcAuthApiRef: ApiRef< SessionApi >; -// @public +// @alpha export const oktaAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -624,7 +625,7 @@ export const oktaAuthApiRef: ApiRef< // @public export type OldIconComponent = ComponentType; -// @public +// @alpha export const oneloginAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -666,10 +667,15 @@ export type PathParams = { [name in ParamNames]: string; }; +// @public @deprecated (undocumented) +export type PendingAuthRequest = PendingOAuthRequest; + // @public -export type PendingAuthRequest = { - provider: AuthProvider; - reject: () => void; +export type PendingOAuthRequest = { + provider: Omit & { + id?: string; + }; + reject(): void; trigger(): Promise; }; @@ -720,12 +726,9 @@ export type RouteFunc = ( export type RouteRef = { $$routeRefType: 'absolute'; params: ParamKeys; - path: string; - icon?: OldIconComponent; - title?: string; }; -// @public +// @public @deprecated export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi >; @@ -734,7 +737,7 @@ export const samlAuthApiRef: ApiRef< export type SessionApi = { signIn(): Promise; signOut(): Promise; - sessionState$(): Observable_2; + sessionState$(): Observable; }; // @public @@ -745,10 +748,10 @@ export enum SessionState { // @public export type SignInPageProps = { - onResult(result: SignInResult): void; + onSignInSuccess(identityApi: IdentityApi_2): void; }; -// @public +// @public @deprecated export type SignInResult = { userId: string; profile: ProfileInfo_2; @@ -760,7 +763,7 @@ export type SignInResult = { export interface StorageApi { forBucket(name: string): StorageApi; get(key: string): T | undefined; - observe$(key: string): Observable_2>; + observe$(key: string): Observable>; remove(key: string): Promise; set(key: string, data: any): Promise; } @@ -782,15 +785,12 @@ export type SubRouteRef = { params: ParamKeys; }; -// @public @deprecated -export type Subscription = Subscription_2; - // @public export type TypesToApiRefs = { [key in keyof T]: ApiRef; }; -// @public +// @alpha export function useAnalytics(): AnalyticsTracker; // @public diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index b6567dcb89..20479acfeb 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-plugin-api", "description": "Core API used by Backstage plugins", - "version": "0.2.1", + "version": "0.3.1", "private": false, "publishConfig": { "access": "public", @@ -30,22 +30,24 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", - "@backstage/version-bridge": "^0.1.0", + "@backstage/version-bridge": "^0.1.1", "@material-ui/core": "^4.12.2", - "@types/react": "*", "history": "^5.0.0", "prop-types": "^15.7.2", - "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index bb2141d47f..af362cd921 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -60,15 +60,14 @@ export const useAnalyticsContext = (): AnalyticsContextValue => { * Analytics contexts are additive, meaning the context ultimately emitted with * an event is the combination of all contexts in the parent tree. * - * @public + * @alpha */ -export const AnalyticsContext = ({ - attributes, - children, -}: { +export const AnalyticsContext = (options: { attributes: Partial; children: ReactNode; }) => { + const { attributes, children } = options; + const parentValues = useAnalyticsContext(); const combinedValue = { ...parentValues, diff --git a/packages/core-plugin-api/src/analytics/types.ts b/packages/core-plugin-api/src/analytics/types.ts index d001c38d3e..431e453a61 100644 --- a/packages/core-plugin-api/src/analytics/types.ts +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -17,7 +17,7 @@ /** * Common analytics context attributes. * - * @public + * @alpha */ export type CommonAnalyticsContext = { /** @@ -40,6 +40,7 @@ export type CommonAnalyticsContext = { * Allows arbitrary scalar values as context attributes too. * * @public + * @deprecated Will be removed, use `AnalyticsContextValue` instead */ export type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; @@ -48,7 +49,8 @@ export type AnyAnalyticsContext = { /** * Analytics context envelope. * - * @public + * @alpha */ -export type AnalyticsContextValue = CommonAnalyticsContext & - AnyAnalyticsContext; +export type AnalyticsContextValue = CommonAnalyticsContext & { + [param in string]: string | boolean | number | undefined; +}; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index 5a047e5a63..f1297c5e4e 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -35,7 +35,7 @@ function useAnalyticsApi(): AnalyticsApi { /** * Gets a pre-configured analytics tracker. * - * @public + * @alpha */ export function useAnalytics(): AnalyticsTracker { const trackerRef = useRef(null); diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts index ef190d6c21..dc38d7a250 100644 --- a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -21,7 +21,7 @@ import { AnalyticsContextValue } from '../../analytics/types'; * Represents an event worth tracking in an analytics system that could inform * how users of a Backstage instance are using its features. * - * @public + * @alpha */ export type AnalyticsEvent = { /** @@ -79,7 +79,7 @@ export type AnalyticsEvent = { * A structure allowing other arbitrary metadata to be provided by analytics * event emitters. * - * @public + * @alpha */ export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; @@ -89,7 +89,7 @@ export type AnalyticsEventAttributes = { * Represents a tracker with methods that can be called to track events in a * configured analytics service. * - * @public + * @alpha */ export type AnalyticsTracker = { captureEvent: ( @@ -103,6 +103,8 @@ export type AnalyticsTracker = { }; /** + * **EXPERIMENTAL** + * * The Analytics API is used to track user behavior in a Backstage instance. * * @remarks @@ -111,7 +113,7 @@ export type AnalyticsTracker = { * useAnalytics() hook. This will return a pre-configured AnalyticsTracker * with relevant methods for instrumentation. * - * @public + * @alpha */ export type AnalyticsApi = { /** @@ -122,9 +124,11 @@ export type AnalyticsApi = { }; /** + * **EXPERIMENTAL** + * * The {@link ApiRef} of {@link AnalyticsApi}. * - * @public + * @alpha */ export const analyticsApiRef: ApiRef = createApiRef({ id: 'core.analytics', diff --git a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts index 8053c449a1..e771fad597 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -16,7 +16,6 @@ import { ReactNode } from 'react'; import { ApiRef, createApiRef } from '../system'; -import { BackstageTheme } from '@backstage/theme'; import { Observable } from '@backstage/types'; /** @@ -40,18 +39,12 @@ export type AppTheme = { */ variant: 'light' | 'dark'; - /** - * The specialized MaterialUI theme instance. - * @deprecated use Provider instead, see https://backstage.io/docs/api/deprecations#app-theme - */ - theme: BackstageTheme; - /** * An Icon for the theme mode setting. */ icon?: React.ReactElement; - Provider?(props: { children: ReactNode }): JSX.Element | null; + Provider(props: { children: ReactNode }): JSX.Element | null; }; /** diff --git a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts index 45ee0901c8..9c73d94cac 100644 --- a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts @@ -29,28 +29,24 @@ export type ErrorApiError = { stack?: string; }; -/** - * @public - * @deprecated Use ErrorApiError instead - */ -export type Error = ErrorApiError; - /** * Provides additional information about an error that was posted to the application. * * @public */ export type ErrorApiErrorContext = { - // If set to true, this error should not be displayed to the user. Defaults to false. + /** + * If set to true, this error should not be displayed to the user. + * + * Hidden errors are typically not displayed in the UI, but the ErrorApi + * implementation may still report them to error tracking services + * or other utilities that care about all errors. + * + * @defaultValue false + */ hidden?: boolean; }; -/** - * @public - * @deprecated Use ErrorApiErrorContext instead - */ -export type ErrorContext = ErrorApiErrorContext; - /** * The error API is used to report errors to the app, and display them to the user. * diff --git a/packages/core-plugin-api/src/apis/definitions/FetchApi.ts b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts new file mode 100644 index 0000000000..ba304157ea --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ApiRef, createApiRef } from '../system'; + +/** + * A wrapper for the fetch API, that has additional behaviors such as the + * ability to automatically inject auth information where necessary. + * + * @public + */ +export type FetchApi = { + fetch: typeof fetch; +}; + +/** + * A wrapper for the fetch API, that has additional behaviors such as the + * ability to automatically inject auth information where necessary. + * + * @public + */ +export const fetchApiRef: ApiRef = createApiRef({ + id: 'core.fetch', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts b/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts index 9f68a8b2cc..fe4de89106 100644 --- a/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { ApiRef, createApiRef } from '../system'; -import { ProfileInfo } from './auth'; +import { BackstageUserIdentity, ProfileInfo } from './auth'; /** * The Identity API used to identify and get information about the signed in user. @@ -26,25 +26,45 @@ export type IdentityApi = { * The ID of the signed in user. This ID is not meant to be presented to the user, but used * as an opaque string to pass on to backends or use in frontend logic. * - * TODO: The intention of the user ID is to be able to tie the user to an identity - * that is known by the catalog and/or identity backend. It should for example - * be possible to fetch all owned components using this ID. + * @deprecated use {@link IdentityApi.getBackstageIdentity} instead. */ getUserId(): string; - /** - * The profile of the signed in user. - */ - getProfile(): ProfileInfo; - /** * An OpenID Connect ID Token which proves the identity of the signed in user. * * The ID token will be undefined if the signed in user does not have a verified * identity, such as a demo user or mocked user for e2e tests. + * + * @deprecated use {@link IdentityApi.getCredentials} instead. */ getIdToken(): Promise; + /** + * The profile of the signed in user. + * + * @deprecated use {@link IdentityApi.getProfileInfo} instead. + */ + getProfile(): ProfileInfo; + + /** + * The profile of the signed in user. + */ + getProfileInfo(): Promise; + + /** + * User identity information within Backstage. + */ + getBackstageIdentity(): Promise; + + /** + * Provides credentials in the form of a token which proves the identity of the signed in user. + * + * The token will be undefined if the signed in user does not have a verified + * identity, such as a demo user or mocked user for e2e tests. + */ + getCredentials(): Promise<{ token?: string }>; + /** * Sign out the current user */ diff --git a/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts index 03e129ef6a..5b8dab29f7 100644 --- a/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -14,31 +14,15 @@ * limitations under the License. */ -import { IconComponent } from '../../icons/types'; import { Observable } from '@backstage/types'; import { ApiRef, createApiRef } from '../system'; +import { AuthProviderInfo } from './auth'; /** - * Information about the auth provider that we're requesting a login towards. - * - * @remarks - * - * This should be shown to the user so that they can be informed about what login is being requested - * before a popup is shown. - * * @public + * @deprecated Use AuthProviderInfo instead */ -export type AuthProvider = { - /** - * Title for the auth provider, for example "GitHub" - */ - title: string; - - /** - * Icon for the auth provider. - */ - icon: IconComponent; -}; +export type AuthProvider = Omit; /** * Describes how to handle auth requests. Both how to show them to the user, and what to do when @@ -46,26 +30,34 @@ export type AuthProvider = { * * @public */ -export type AuthRequesterOptions = { +export type OAuthRequesterOptions = { /** * Information about the auth provider, which will be forwarded to auth requests. + * + * Not passing in an `id` is deprecated, and it will be required in the future. */ - provider: AuthProvider; + provider: Omit & { id?: string }; /** * Implementation of the auth flow, which will be called synchronously when * trigger() is called on an auth requests. */ - onAuthRequest(scopes: Set): Promise; + onAuthRequest(scopes: Set): Promise; }; +/** + * @public + * @deprecated Use OAuthRequesterOptions instead + */ +export type AuthRequesterOptions = OAuthRequesterOptions; + /** * Function used to trigger new auth requests for a set of scopes. * * @remarks * * The returned promise will resolve to the same value returned by the onAuthRequest in the - * {@link AuthRequesterOptions}. Or rejected, if the request is rejected. + * {@link OAuthRequesterOptions}. Or rejected, if the request is rejected. * * This function can be called multiple times before the promise resolves. All calls * will be merged into one request, and the scopes forwarded to the onAuthRequest will be the @@ -73,9 +65,15 @@ export type AuthRequesterOptions = { * * @public */ -export type AuthRequester = ( +export type OAuthRequester = ( scopes: Set, -) => Promise; +) => Promise; + +/** + * @public + * @deprecated Use OAuthRequester instead + */ +export type AuthRequester = OAuthRequester; /** * An pending auth request for a single auth provider. The request will remain in this pending @@ -88,16 +86,18 @@ export type AuthRequester = ( * * @public */ -export type PendingAuthRequest = { +export type PendingOAuthRequest = { /** * Information about the auth provider, as given in the AuthRequesterOptions + * + * Not passing in an `id` is deprecated, and it will be required in the future. */ - provider: AuthProvider; + provider: Omit & { id?: string }; /** * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". */ - reject: () => void; + reject(): void; /** * Trigger the auth request to continue the auth flow, by for example showing a popup. @@ -107,6 +107,12 @@ export type PendingAuthRequest = { trigger(): Promise; }; +/** + * @public + * @deprecated Use PendingOAuthRequest instead + */ +export type PendingAuthRequest = PendingOAuthRequest; + /** * Provides helpers for implemented OAuth login flows within Backstage. * @@ -125,9 +131,9 @@ export type OAuthRequestApi = { * * See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. */ - createAuthRequester( - options: AuthRequesterOptions, - ): AuthRequester; + createAuthRequester( + options: OAuthRequesterOptions, + ): OAuthRequester; /** * Observers pending auth requests. The returned observable will emit all @@ -140,7 +146,7 @@ export type OAuthRequestApi = { * If a auth is triggered, and the auth handler resolves successfully, then all currently pending * AuthRequester calls will resolve to the value returned by the onAuthRequest call. */ - authRequest$(): Observable; + authRequest$(): Observable; }; /** diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index 8fe532f3a6..a362d16750 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -15,6 +15,7 @@ */ import { ApiRef, createApiRef } from '../system'; +import { IconComponent } from '../../icons/types'; import { Observable } from '@backstage/types'; /** @@ -28,6 +29,33 @@ import { Observable } from '@backstage/types'; * const googleAuthApiRef = createApiRef({ ... }) */ +/** + * Information about the auth provider. + * + * @remarks + * + * This information is used both to connect the correct auth provider in the backend, as + * well as displaying the provider to the user. + * + * @public + */ +export type AuthProviderInfo = { + /** + * The ID of the auth provider. This should match with ID of the provider in the `@backstage/auth-backend`. + */ + id: string; + + /** + * Title for the auth provider, for example "GitHub" + */ + title: string; + + /** + * Icon for the auth provider. + */ + icon: IconComponent; +}; + /** * An array of scopes, or a scope string formatted according to the * auth provider, which is typically a space separated list. @@ -160,31 +188,65 @@ export type BackstageIdentityApi = { */ getBackstageIdentity( options?: AuthRequestOptions, - ): Promise; + ): Promise; }; /** - * A (user id, token) pair. + * User identity information within Backstage. * * @public */ -export type BackstageIdentity = { +export type BackstageUserIdentity = { /** - * The backstage user ID. + * The type of identity that this structure represents. In the frontend app + * this will currently always be 'user'. */ - id: string; + type: 'user'; /** - * @deprecated This is deprecated, use `token` instead. + * The entityRef of the user in the catalog. + * For example User:default/sandra */ - idToken: string; + userEntityRef: string; + + /** + * The user and group entities that the user claims ownership through + */ + ownershipEntityRefs: string[]; +}; + +/** + * Token and Identity response, with the users claims in the Identity. + * + * @public + */ +export type BackstageIdentityResponse = { + /** + * The backstage user ID. + * + * @deprecated The identity is now provided via the `identity` field instead. + */ + id: string; /** * The token used to authenticate the user within Backstage. */ token: string; + + /** + * Identity information derived from the token. + */ + identity: BackstageUserIdentity; }; +/** + * The old exported symbol for {@link BackstageIdentityResponse}. + * + * @public + * @deprecated use {@link BackstageIdentityResponse} instead. + */ +export type BackstageIdentity = BackstageIdentityResponse; + /** * Profile information of the user. * @@ -248,14 +310,14 @@ export type SessionApi = { /** * Provides authentication towards Google APIs and identities. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. + * * @remarks * * See {@link https://developers.google.com/identity/protocols/googlescopes} for a full list of supported scopes. * * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. - * - * @public */ export const googleAuthApiRef: ApiRef< OAuthApi & @@ -270,12 +332,12 @@ export const googleAuthApiRef: ApiRef< /** * Provides authentication towards GitHub APIs. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. + * * @remarks * * See {@link https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/} * for a full list of supported scopes. - * - * @public */ export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -286,12 +348,12 @@ export const githubAuthApiRef: ApiRef< /** * Provides authentication towards Okta APIs. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. + * * @remarks * * See {@link https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/} * for a full list of supported scopes. - * - * @public */ export const oktaAuthApiRef: ApiRef< OAuthApi & @@ -306,12 +368,12 @@ export const oktaAuthApiRef: ApiRef< /** * Provides authentication towards GitLab APIs. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. + * * @remarks * * See {@link https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token} * for a full list of supported scopes. - * - * @public */ export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -328,6 +390,7 @@ export const gitlabAuthApiRef: ApiRef< * for a full list of supported scopes. * * @public + * @deprecated See https://backstage.io/docs/api/deprecations#generic-auth-api-refs */ export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -338,13 +401,13 @@ export const auth0AuthApiRef: ApiRef< /** * Provides authentication towards Microsoft APIs and identities. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. + * * @remarks * * For more info and a full list of supported scopes, see: * - {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent} * - {@link https://docs.microsoft.com/en-us/graph/permissions-reference} - * - * @public */ export const microsoftAuthApiRef: ApiRef< OAuthApi & @@ -360,6 +423,7 @@ export const microsoftAuthApiRef: ApiRef< * Provides authentication for custom identity providers. * * @public + * @deprecated See https://backstage.io/docs/api/deprecations#generic-auth-api-refs */ export const oauth2ApiRef: ApiRef< OAuthApi & @@ -375,6 +439,7 @@ export const oauth2ApiRef: ApiRef< * Provides authentication for custom OpenID Connect identity providers. * * @public + * @deprecated See https://backstage.io/docs/api/deprecations#generic-auth-api-refs */ export const oidcAuthApiRef: ApiRef< OAuthApi & @@ -390,6 +455,7 @@ export const oidcAuthApiRef: ApiRef< * Provides authentication for SAML-based identity providers. * * @public + * @deprecated See https://backstage.io/docs/api/deprecations#generic-auth-api-refs */ export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -400,7 +466,7 @@ export const samlAuthApiRef: ApiRef< /** * Provides authentication towards OneLogin APIs. * - * @public + * @alpha This API is **EXPERIMENTAL** and might change in the future. */ export const oneloginAuthApiRef: ApiRef< OAuthApi & @@ -415,12 +481,11 @@ export const oneloginAuthApiRef: ApiRef< /** * Provides authentication towards Bitbucket APIs. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. * @remarks * * See {@link https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/} * for a full list of supported scopes. - * - * @public */ export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -431,12 +496,11 @@ export const bitbucketAuthApiRef: ApiRef< /** * Provides authentication towards Atlassian APIs. * + * @alpha This API is **EXPERIMENTAL** and might change in the future. * @remarks * * See {@link https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/} * for a full list of supported scopes. - * - * @public */ export const atlassianAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index b7666bcb54..67d442587d 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -29,6 +29,7 @@ export * from './ConfigApi'; export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './FetchApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts index 7e13a1963f..adedff9f73 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts @@ -23,10 +23,6 @@ import type { ApiRef } from './types'; */ export type ApiRefConfig = { id: string; - /** - * @deprecated Will be removed in the future - */ - description?: string; }; class ApiRefImpl implements ApiRef { @@ -46,12 +42,6 @@ class ApiRefImpl implements ApiRef { return this.config.id; } - get description() { - // eslint-disable-next-line no-console - console.warn('Deprecated use of ApiRef.description'); - return this.config.description; - } - // Utility for getting type of an api, using `typeof apiRef.T` get T(): T { throw new Error(`tried to read ApiRef.T of ${this}`); diff --git a/packages/core-plugin-api/src/apis/system/types.ts b/packages/core-plugin-api/src/apis/system/types.ts index a449e83450..96614c320c 100644 --- a/packages/core-plugin-api/src/apis/system/types.ts +++ b/packages/core-plugin-api/src/apis/system/types.ts @@ -31,33 +31,13 @@ export type ApiRef = { */ export type AnyApiRef = ApiRef; -/** - * Transforms ApiRef type into its inner API type. - * - * @public - * @deprecated unused type. - */ -export type ApiRefType = T extends ApiRef ? U : never; - /** * Wraps a type with API properties into a type holding their respective {@link ApiRef}s. - * Reverse type transform of {@link ApiRefsToTypes}. * * @public */ export type TypesToApiRefs = { [key in keyof T]: ApiRef }; -/** - * Unwraps type with {@link ApiRef} properties into a type holding their respective API types. - * Reverse type transform of {@link TypesToApiRefs}. - * - * @public - * @deprecated unused type. - */ -export type ApiRefsToTypes }> = { - [key in keyof T]: ApiRefType; -}; - /** * Provides lookup of APIs through their {@link ApiRef}s. * diff --git a/packages/core-plugin-api/src/deprecatedTypes.ts b/packages/core-plugin-api/src/deprecatedTypes.ts deleted file mode 100644 index 5596ff9f46..0000000000 --- a/packages/core-plugin-api/src/deprecatedTypes.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { - Observer as CoreObserver, - Subscription as CoreSubscription, - Observable as CoreObservable, -} from '@backstage/types'; - -/** - * Observer interface for consuming an Observer, see TC39. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Observer = CoreObserver; - -/** - * Subscription returned when subscribing to an Observable, see TC39. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Subscription = CoreSubscription; - -/** - * Observable sequence of values and errors, see TC39. - * - * @remarks - * - * {@link https://github.com/tc39/proposal-observable} - * This is used as a common return type for observable values and can be created - * using many different observable implementations, such as zen-observable or RxJS 5. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Observable = CoreObservable; diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index ca5ada8478..19da082ee1 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -51,8 +51,27 @@ export type ComponentLoader = export function createRoutableExtension< T extends (props: any) => JSX.Element | null, >(options: { + /** + * A loader for the component that is rendered by this extension. + */ component: () => Promise; + + /** + * The mount point to bind this routable extension to. + * + * If this extension is placed somewhere in the app element tree of a Backstage + * app, callers will be able to route to this extensions by calling, + * `useRouteRef` with this mount point. + */ mountPoint: RouteRef; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ name?: string; }): Extension { const { component, mountPoint, name } = options; @@ -127,7 +146,21 @@ export function createRoutableExtension< */ export function createComponentExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: ComponentLoader; name?: string }): Extension { +>(options: { + /** + * A loader or synchronously supplied component that is rendered by this extension. + */ + component: ComponentLoader; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ + name?: string; +}): Extension { const { component, name } = options; return createReactExtension({ component, name }); } @@ -148,8 +181,23 @@ export function createComponentExtension< export function createReactExtension< T extends (props: any) => JSX.Element | null, >(options: { + /** + * A loader or synchronously supplied component that is rendered by this extension. + */ component: ComponentLoader; + + /** + * Additional component data that is attached to the top-level extension component. + */ data?: Record; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ name?: string; }): Extension { const { data = {}, name } = options; diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts index 7a4f2e72ba..30fc35b62a 100644 --- a/packages/core-plugin-api/src/index.ts +++ b/packages/core-plugin-api/src/index.ts @@ -27,4 +27,3 @@ export * from './extensions'; export * from './icons'; export * from './plugin'; export * from './routing'; -export * from './deprecatedTypes'; diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index 8bd3d604f6..36ca3f073d 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -21,18 +21,6 @@ import { ParamKeys, OptionalParams, } from './types'; -import { OldIconComponent } from '../icons/types'; - -/** - * @deprecated - * @internal - */ -export type RouteRefConfig = { - params?: ParamKeys; - path?: string; - icon?: OldIconComponent; - title: string; -}; /** * @internal @@ -47,48 +35,10 @@ export class RouteRefImpl constructor( private readonly id: string, readonly params: ParamKeys, - private readonly config: { - /** @deprecated */ - path?: string; - /** @deprecated */ - icon?: OldIconComponent; - /** @deprecated */ - title?: string; - }, - ) { - if (config.path) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing a path to createRouteRef is deprecated, please remove the path for ${this}.`, - ); - } - - if (config.icon) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing an icon to createRouteRef is deprecated, please remove the icon for ${this}.`, - ); - } - - if (config.title) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing a title to createRouteRef is deprecated, please remove the title for ${this}.`, - ); - } - } - - /** @deprecated use `useRouteRef` instead */ - get path() { - return this.config.path ?? ''; - } - - get icon() { - return this.config.icon; - } + ) {} get title() { - return this.config.title ?? this.id; + return this.id; } toString() { @@ -112,23 +62,12 @@ export function createRouteRef< ParamKey extends string = never, >(config: { /** The id of the route ref, used to identify it when printed */ - id?: string; + id: string; /** A list of parameter names that the path that this route ref is bound to must contain */ params?: ParamKey[]; - /** @deprecated Route refs no longer decide their own path */ - path?: string; - /** @deprecated Route refs no longer decide their own icon */ - icon?: OldIconComponent; - /** @deprecated Route refs no longer decide their own title */ - title?: string; }): RouteRef> { - const id = config.id || config.title; - if (!id) { - throw new Error('RouteRef must be provided a non-empty id'); - } return new RouteRefImpl( - id, + config.id, (config.params ?? []) as ParamKeys>, - config, ); } diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index bd419abb76..80653518bb 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { OldIconComponent } from '../icons/types'; import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; /** @@ -85,14 +84,6 @@ export type RouteRef = { $$routeRefType: 'absolute'; // See routeRefType above params: ParamKeys; - - // TODO(Rugvip): Remove all of these once plugins don't rely on the path - /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ - path: string; - /** @deprecated icons are no longer accessed via RouteRefs */ - icon?: OldIconComponent; - /** @deprecated titles are no longer accessed via RouteRefs */ - title?: string; }; /** @@ -142,23 +133,6 @@ export type AnyRouteRef = | SubRouteRef | ExternalRouteRef; -// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release -/** - * @deprecated - * @internal - */ -export type ConcreteRoute = {}; -/** - * @deprecated - * @internal - */ -export type AbsoluteRouteRef = RouteRef<{}>; -/** - * @deprecated - * @internal - */ -export type MutableRouteRef = RouteRef<{}>; - /** * A duplicate of the react-router RouteObject, but with routeRef added * @internal diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 97a5ef9588..60d8a55635 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,133 @@ # @backstage/create-app +## 0.4.8 + +### Patch Changes + +- 25dfc2d483: Updated the root `package.json` to include files with `.cjs` and `.mjs` extensions in the `"lint-staged"` configuration. + + To make this change to an existing app, apply the following changes to the `package.json` file: + + ```diff + "lint-staged": { + - "*.{js,jsx,ts,tsx}": [ + + "*.{js,jsx,ts,tsx,mjs,cjs}": [ + ``` + +## 0.4.7 + +### Patch Changes + +- 9603827bb5: Addressed some peer dependency warnings +- 1bada775a9: TechDocs Backend may now (optionally) leverage a cache store to improve + performance when reading content from a cloud storage provider. + + To apply this change to an existing app, pass the cache manager from the plugin + environment to the `createRouter` function in your backend: + + ```diff + // packages/backend/src/plugins/techdocs.ts + + export default async function createPlugin({ + logger, + config, + discovery, + reader, + + cache, + }: PluginEnvironment): Promise { + + // ... + + return await createRouter({ + preparers, + generators, + publisher, + logger, + config, + discovery, + + cache, + }); + ``` + + If your `PluginEnvironment` does not include a cache manager, be sure you've + applied [the cache management change][cm-change] to your backend as well. + + [Additional configuration][td-rec-arch] is required if you wish to enable + caching in TechDocs. + + [cm-change]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#patch-changes-6 + [td-rec-arch]: https://backstage.io/docs/features/techdocs/architecture#recommended-deployment + +- 4862fbc64f: Bump @spotify/prettier-config +- 36bb4fb2e9: Removed the `scaffolder.github.visibility` configuration that is no longer used from the default app template. + +## 0.4.6 + +### Patch Changes + +- 24d2ce03f3: Search Modal now relies on the Search Context to access state and state setter. If you use the SidebarSearchModal as described in the [getting started documentation](https://backstage.io/docs/features/search/getting-started#using-the-search-modal), make sure to update your code with the SearchContextProvider. + + ```diff + export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + - + + + + + + + + ... + ``` + +- 905dd952ac: Incorporate usage of the tokenManager into the backend created using `create-app`. + + In existing backends, update the `PluginEnvironment` to include a `tokenManager`: + + ```diff + // packages/backend/src/types.ts + + ... + import { + ... + + TokenManager, + } from '@backstage/backend-common'; + + export type PluginEnvironment = { + ... + + tokenManager: TokenManager; + }; + ``` + + Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. + + ```diff + // packages/backend/src/index.ts + + ... + import { + ... + + ServerTokenManager, + } from '@backstage/backend-common'; + ... + + function makeCreateEnv(config: Config) { + ... + // CHOOSE ONE + // TokenManager not requiring a secret + + const tokenManager = ServerTokenManager.noop(); + // OR TokenManager requiring a secret + + const tokenManager = ServerTokenManager.fromConfig(config); + + ... + return (plugin: string): PluginEnvironment => { + ... + - return { logger, cache, database, config, reader, discovery }; + + return { logger, cache, database, config, reader, discovery, tokenManager }; + }; + } + ``` + ## 0.4.5 ### Patch Changes diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 9f86cdb448..d5b5039d4e 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -22,4 +22,4 @@ yarn backstage-create-app ## Documentation - [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) +- [Backstage Documentation](https://backstage.io/docs/) diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 49f6e87376..7a6c7ad551 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "A CLI that helps you create your own Backstage app", - "version": "0.4.5", + "version": "0.4.8", "private": false, "publishConfig": { "access": "public" @@ -41,7 +41,8 @@ }, "devDependencies": { "@types/fs-extra": "^9.0.1", - "@types/inquirer": "^7.3.1", + "@types/inquirer": "^8.1.3", + "@types/node": "^14.14.32", "@types/recursive-readdir": "^2.2.0", "mock-fs": "^5.1.1", "ts-node": "^10.0.0" diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index d301792380..ac42ed0ff3 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -65,7 +65,7 @@ export default async (cmd: Command, version: string): Promise => { const templateDir = paths.resolveOwn('templates/default-app'); const tempDir = resolvePath(os.tmpdir(), answers.name); - // Use `--path` argument as applicaiton directory when specified, otherwise + // Use `--path` argument as application directory when specified, otherwise // create a directory using `answers.name` const appDir = cmd.path ? resolvePath(paths.targetDir, cmd.path) diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index be144d91f7..c804b4b561 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -77,9 +77,7 @@ auth: providers: {} scaffolder: - github: - token: ${GITHUB_TOKEN} - visibility: public # or 'internal' or 'private' + # see https://backstage.io/docs/features/software-templates/configuration for software template options catalog: rules: diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index bdae71d9e3..a73045c050 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -31,14 +31,14 @@ }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@spotify/prettier-config": "^11.0.0", + "@spotify/prettier-config": "^12.0.0", "concurrently": "^6.0.0", "lerna": "^4.0.0", "prettier": "^2.3.2" }, "prettier": "@spotify/prettier-config", "lint-staged": { - "*.{js,jsx,ts,tsx}": [ + "*.{js,jsx,ts,tsx,mjs,cjs}": [ "eslint --fix", "prettier --write" ], diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 9e15608a01..68848d2de9 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -22,7 +22,6 @@ "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", - "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@backstage/theme": "^{{version '@backstage/theme'}}", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -34,6 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { + "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -48,11 +48,11 @@ "scripts": { "start": "backstage-cli app:serve", "build": "backstage-cli app:build", - "test": "backstage-cli test", - "lint": "backstage-cli lint", "clean": "backstage-cli clean", + "test": "backstage-cli test", "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "lint": "backstage-cli lint", "cy:dev": "cypress open", "cy:run": "cypress run" }, diff --git a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx index ec59b0b116..b4fa04f1fc 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx @@ -25,7 +25,10 @@ import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { NavLink } from 'react-router-dom'; import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; -import { SidebarSearchModal } from '@backstage/plugin-search'; +import { + SidebarSearchModal, + SearchContextProvider, +} from '@backstage/plugin-search'; import { Sidebar, SidebarPage, @@ -74,7 +77,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - + + + {/* Global nav, not org-specific */} diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 906d86d4a2..054c64db65 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -14,6 +14,7 @@ export default async function createPlugin({ config, discovery, reader, + cache, }: PluginEnvironment): Promise { // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -49,5 +50,6 @@ export default async function createPlugin({ logger, config, discovery, + cache, }); } diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index b882155a3d..e15249bf91 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/dev-utils +## 0.2.14 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/core-app-api@0.2.0 + - @backstage/app-defaults@0.1.2 + - @backstage/integration-react@0.1.15 + - @backstage/test-utils@0.1.24 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.13 ### Patch Changes diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md index c10de7d529..e1cb93313f 100644 --- a/packages/dev-utils/api-report.md +++ b/packages/dev-utils/api-report.md @@ -44,11 +44,9 @@ export type DevAppPageOptions = { }; // @public (undocumented) -export const EntityGridItem: ({ - entity, - classes, - ...rest -}: Omit, 'container' | 'item'> & { - entity: Entity; -}) => JSX.Element; +export const EntityGridItem: ( + props: Omit & { + entity: Entity; + }, +) => JSX.Element; ``` diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 32d287d35d..8f77f4a72d 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.2.13", + "version": "0.2.14", "private": false, "publishConfig": { "access": "public", @@ -29,31 +29,33 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/app-defaults": "^0.1.1", - "@backstage/core-app-api": "^0.1.21", - "@backstage/core-components": "^0.7.4", - "@backstage/core-plugin-api": "^0.2.0", + "@backstage/app-defaults": "^0.1.2", + "@backstage/core-app-api": "^0.2.0", + "@backstage/core-components": "^0.8.0", + "@backstage/core-plugin-api": "^0.3.0", "@backstage/catalog-model": "^0.9.7", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/test-utils": "^0.1.22", - "@backstage/theme": "^0.2.11", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/test-utils": "^0.1.24", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", - "@types/react": "*", - "react": "^16.12.0", "react-use": "^17.2.4", - "react-dom": "^16.12.0", "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx index 55327f35e5..a66550a4d2 100644 --- a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx @@ -35,11 +35,10 @@ const useStyles = makeStyles(theme => ({ })); /** @public */ -export const EntityGridItem = ({ - entity, - classes, - ...rest -}: Omit & { entity: Entity }): JSX.Element => { +export const EntityGridItem = ( + props: Omit & { entity: Entity }, +): JSX.Element => { + const { entity, classes, ...rest } = props; const itemClasses = useStyles({ entity }); return ( diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx index 43eaa6218c..9229bc61dc 100644 --- a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx @@ -16,7 +16,6 @@ import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { BackstageTheme } from '@backstage/theme'; import userEvent from '@testing-library/user-event'; import React from 'react'; import ObservableImpl from 'zen-observable'; @@ -41,13 +40,13 @@ describe('SidebarThemeSwitcher', () => { id: 'dark', title: 'Dark Theme', variant: 'dark', - theme: {} as unknown as BackstageTheme, + Provider: jest.fn(), }, { id: 'light', title: 'Light Theme', variant: 'light', - theme: {} as unknown as BackstageTheme, + Provider: jest.fn(), }, ]); }); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index adea9e14f2..36d6abcfd1 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -159,7 +159,7 @@ export class DevAppBuilder { * Build a DevApp component using the resources registered so far */ build(): ComponentType<{}> { - const dummyRouteRef = createRouteRef({ title: 'Page of another plugin' }); + const dummyRouteRef = createRouteRef({ id: 'dummy' }); const DummyPage = () => Page belonging to another plugin.; attachComponentData(DummyPage, 'core.mountPoint', dummyRouteRef); diff --git a/packages/embedded-techdocs-app/CHANGELOG.md b/packages/embedded-techdocs-app/CHANGELOG.md index 6a9583c2dc..f1ae8abdf5 100644 --- a/packages/embedded-techdocs-app/CHANGELOG.md +++ b/packages/embedded-techdocs-app/CHANGELOG.md @@ -1,5 +1,32 @@ # embedded-techdocs-app +## 0.2.56 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog@0.7.4 + - @backstage/core-app-api@0.2.0 + - @backstage/app-defaults@0.1.2 + - @backstage/integration-react@0.1.15 + - @backstage/test-utils@0.1.24 + - @backstage/plugin-techdocs@0.12.9 + - @backstage/cli@0.10.1 + +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/cli@0.10.0 + - @backstage/core-plugin-api@0.2.2 + - @backstage/core-app-api@0.1.24 + - @backstage/plugin-techdocs@0.12.8 + ## 0.2.53 ### Patch Changes diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json index b2d7882f8f..6a13acf37f 100644 --- a/packages/embedded-techdocs-app/package.json +++ b/packages/embedded-techdocs-app/package.json @@ -1,21 +1,21 @@ { "name": "embedded-techdocs-app", - "version": "0.2.53", + "version": "0.2.56", "private": true, "bundled": true, "dependencies": { - "@backstage/app-defaults": "^0.1.1", + "@backstage/app-defaults": "^0.1.2", "@backstage/catalog-model": "^0.9.7", - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@backstage/config": "^0.1.10", - "@backstage/core-app-api": "^0.1.21", - "@backstage/core-components": "^0.7.4", - "@backstage/core-plugin-api": "^0.2.0", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog": "^0.7.3", - "@backstage/plugin-techdocs": "^0.12.6", - "@backstage/test-utils": "^0.1.22", - "@backstage/theme": "^0.2.11", + "@backstage/core-app-api": "^0.2.0", + "@backstage/core-components": "^0.8.0", + "@backstage/core-plugin-api": "^0.3.0", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog": "^0.7.4", + "@backstage/plugin-techdocs": "^0.12.9", + "@backstage/test-utils": "^0.1.24", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "history": "^5.0.0", @@ -26,7 +26,7 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/errors/package.json b/packages/errors/package.json index f49451a6a8..d0d59ffd6a 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -35,7 +35,7 @@ "serialize-error": "^8.0.1" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7" }, "files": [ diff --git a/packages/integration-react/CHANGELOG.md b/packages/integration-react/CHANGELOG.md index e972b4d4e8..b56d4f52d1 100644 --- a/packages/integration-react/CHANGELOG.md +++ b/packages/integration-react/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/integration-react +## 0.1.15 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.1.14 ### Patch Changes diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index f3c79cc56c..988fd14032 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration-react", "description": "Frontend package for managing integrations towards external systems", - "version": "0.1.14", + "version": "0.1.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,21 +22,22 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.4", - "@backstage/core-plugin-api": "^0.2.0", - "@backstage/integration": "^0.6.9", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.8.0", + "@backstage/core-plugin-api": "^0.3.0", + "@backstage/integration": "^0.6.10", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.0", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.22", + "@backstage/cli": "^0.10.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/integration-react/src/api/ScmIntegrationsApi.ts b/packages/integration-react/src/api/ScmIntegrationsApi.ts index 06f28a6c4d..ec2185701b 100644 --- a/packages/integration-react/src/api/ScmIntegrationsApi.ts +++ b/packages/integration-react/src/api/ScmIntegrationsApi.ts @@ -45,5 +45,4 @@ export class ScmIntegrationsApi { export const scmIntegrationsApiRef: ApiRef = createApiRef({ id: 'integration.scmintegrations', - description: 'All of the registered SCM integrations of your config', }); diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index b9c6601e6c..e3380082f7 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/integration +## 0.6.10 + +### Patch Changes + +- 47619da24c: Narrow the types returned by the request option functions, to only the specifics that they actually do return. The reason for this change is that a full `RequestInit` is unfortunate to return because it's different between `cross-fetch` and `node-fetch`. + ## 0.6.9 ### Patch Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index 4ccde52a70..a6d91511c3 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration", "description": "Helpers for managing integrations towards external systems", - "version": "0.6.9", + "version": "0.6.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -39,8 +39,8 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@backstage/cli": "^0.9.0", - "@backstage/config-loader": "^0.8.0", + "@backstage/cli": "^0.10.0", + "@backstage/config-loader": "^0.8.1", "@backstage/test-utils": "^0.1.22", "@types/jest": "^26.0.7", "@types/luxon": "^2.0.4", diff --git a/packages/search-common/package.json b/packages/search-common/package.json index d65e9bcbdc..51cea5a28e 100644 --- a/packages/search-common/package.json +++ b/packages/search-common/package.json @@ -39,7 +39,7 @@ "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.9.0" + "@backstage/cli": "^0.10.0" }, "jest": { "roots": [ diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 2750324c17..4c0813a8e9 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -10,6 +10,7 @@ import { OktaAuth, Auth0Auth, ConfigReader, + LocalStorageFeatureFlags, } from '@backstage/core-app-api'; import { @@ -24,9 +25,11 @@ import { oktaAuthApiRef, auth0AuthApiRef, configApiRef, + featureFlagsApiRef, } from '@backstage/core-plugin-api'; const configApi = new ConfigReader({}); +const featureFlagsApi = new LocalStorageFeatureFlags(); const alertApi = new AlertApiForwarder(); const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); const identityApi = { @@ -69,6 +72,7 @@ const oauth2Api = OAuth2.create({ export const apis = [ [configApiRef, configApi], + [featureFlagsApiRef, featureFlagsApi], [alertApiRef, alertApi], [errorApiRef, errorApi], [identityApiRef, identityApi], diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 72ff42311d..f5852b0164 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -59,7 +59,7 @@ module.exports = ({ args }) => { }, }, { - test: /\.(jsx?|mjs)$/, + test: /\.(jsx?|mjs|cjs)$/, exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 736afbb8d8..5df0c06632 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@backstage/theme": "^0.2.0", - "react": "^16.12.0", - "react-dom": "^16.12.0" + "react": "^16.13.1", + "react-dom": "^16.13.1" }, "devDependencies": { "@storybook/addon-a11y": "^6.3.4", diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md index 6d69e452ad..cf94063b89 100644 --- a/packages/techdocs-cli/CHANGELOG.md +++ b/packages/techdocs-cli/CHANGELOG.md @@ -1,5 +1,21 @@ # @techdocs/cli +## 0.8.8 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.13 + - @backstage/techdocs-common@0.11.0 + +## 0.8.7 + +### Patch Changes + +- e7230ef814: Bump react-dev-utils to v12 +- Updated dependencies + - @backstage/backend-common@0.9.12 + ## 0.8.6 ### Patch Changes diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 05759e39d1..292db9820f 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "Utility CLI for managing TechDocs sites in Backstage.", - "version": "0.8.6", + "version": "0.8.8", "private": false, "publishConfig": { "access": "public" @@ -32,7 +32,7 @@ "techdocs-cli": "bin/techdocs-cli" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@types/commander": "^2.12.2", "@types/fs-extra": "^9.0.6", "@types/http-proxy": "^1.17.4", @@ -41,7 +41,7 @@ "@types/react-dev-utils": "^9.0.4", "@types/serve-handler": "^6.1.0", "@types/webpack-env": "^1.15.3", - "embedded-techdocs-app": "0.2.53", + "embedded-techdocs-app": "0.2.56", "find-process": "^1.4.5", "nodemon": "^2.0.2", "ts-node": "^10.0.0" @@ -56,10 +56,10 @@ "ext": "ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", + "@backstage/backend-common": "^0.9.13", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/techdocs-common": "^0.10.7", + "@backstage/techdocs-common": "^0.11.0", "@types/dockerode": "^3.3.0", "commander": "^6.1.0", "dockerode": "^3.3.1", diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 386c584196..e925ad35e9 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/techdocs-common +## 0.11.0 + +### Minor Changes + +- 1bada775a9: Added the ability for the TechDocs Backend to (optionally) leverage a cache + store to improve performance when reading files from a cloud storage provider. + +### Patch Changes + +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/backend-common@0.9.13 + ## 0.10.8 ### Patch Changes diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 61371525e5..5c0a9f87f6 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -49,22 +49,6 @@ export type GeneratorBuilder = { get(entity: Entity): GeneratorBase; }; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-optional-name) The @param should not include a JSDoc-style optional name; it must not be enclosed in '[ ]' brackets. -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-optional-name) The @param should not include a JSDoc-style optional name; it must not be enclosed in '[ ]' brackets. -// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// Warning: (ae-missing-release-tag) "GeneratorRunOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type GeneratorRunOptions = { inputDir: string; @@ -82,10 +66,7 @@ export class Generators implements GeneratorBuilder { // (undocumented) static fromConfig( config: Config, - { - logger, - containerRunner, - }: { + options: { logger: Logger_2; containerRunner: ContainerRunner; }, @@ -185,13 +166,10 @@ export class Publisher { ): Promise; } -// Warning: (ae-missing-release-tag) "PublisherBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface PublisherBase { docsRouter(): express.Handler; fetchTechDocsMetadata(entityName: EntityName): Promise; - // Warning: (ae-forgotten-export) The symbol "ReadinessResponse" needs to be exported by the entry point index.d.ts getReadiness(): Promise; hasDocsBeenGenerated(entityName: Entity): Promise; // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag @@ -202,7 +180,6 @@ export interface PublisherBase { // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (ae-forgotten-export) The symbol "MigrateRequest" needs to be exported by the entry point index.d.ts migrateDocsCase?(migrateRequest: MigrateRequest): Promise; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-forgotten-export) The symbol "PublishRequest" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "PublishResponse" needs to be exported by the entry point index.d.ts publish(request: PublishRequest): Promise; @@ -218,6 +195,11 @@ export type PublisherType = | 'azureBlobStorage' | 'openStackSwift'; +// @public +export type ReadinessResponse = { + isAvailable: boolean; +}; + // Warning: (ae-missing-release-tag) "RemoteProtocol" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -245,12 +227,7 @@ export interface TechDocsDocument extends IndexableDocument { // // @public (undocumented) export class TechdocsGenerator implements GeneratorBase { - constructor({ - logger, - containerRunner, - config, - scmIntegrations, - }: { + constructor(options: { logger: Logger_2; containerRunner: ContainerRunner; config: Config; @@ -260,26 +237,15 @@ export class TechdocsGenerator implements GeneratorBase { // (undocumented) static fromConfig( config: Config, - { - containerRunner, - logger, - }: { + options: { containerRunner: ContainerRunner; logger: Logger_2; }, ): TechdocsGenerator; // (undocumented) - run({ - inputDir, - outputDir, - parsedLocationAnnotation, - etag, - logger: childLogger, - logStream, - }: GeneratorRunOptions): Promise; + run(options: GeneratorRunOptions): Promise; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-missing-release-tag) "TechDocsMetadata" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -287,6 +253,8 @@ export type TechDocsMetadata = { site_name: string; site_description: string; etag: string; + build_timestamp: number; + files?: string[]; }; // Warning: (ae-missing-release-tag) "transformDirLocation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -317,7 +285,7 @@ export class UrlPreparer implements PreparerBase { // Warnings were encountered during analysis: // -// src/stages/generate/types.d.ts:44:5 - (ae-forgotten-export) The symbol "SupportedGeneratorKey" needs to be exported by the entry point index.d.ts +// src/stages/generate/types.d.ts:45:5 - (ae-forgotten-export) The symbol "SupportedGeneratorKey" needs to be exported by the entry point index.d.ts // src/stages/prepare/types.d.ts:18:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/stages/prepare/types.d.ts:19:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters // src/stages/prepare/types.d.ts:21:33 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 3f0d0edf5b..c9c67881f7 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.10.8", + "version": "0.11.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,12 +38,12 @@ "dependencies": { "@azure/identity": "^1.5.0", "@azure/storage-blob": "^12.5.0", - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.13", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", "@backstage/search-common": "^0.2.1", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@google-cloud/storage": "^5.6.0", "@trendyol-js/openstack-swift-sdk": "^0.0.5", "@types/express": "^4.17.6", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.1", "@types/fs-extra": "^9.0.5", "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", diff --git a/packages/techdocs-common/src/stages/generate/generators.ts b/packages/techdocs-common/src/stages/generate/generators.ts index 3d1ad2b73b..de39d4e7c3 100644 --- a/packages/techdocs-common/src/stages/generate/generators.ts +++ b/packages/techdocs-common/src/stages/generate/generators.ts @@ -31,17 +31,11 @@ export class Generators implements GeneratorBuilder { static async fromConfig( config: Config, - { - logger, - containerRunner, - }: { logger: Logger; containerRunner: ContainerRunner }, + options: { logger: Logger; containerRunner: ContainerRunner }, ): Promise { const generators = new Generators(); - const techdocsGenerator = TechdocsGenerator.fromConfig(config, { - logger, - containerRunner, - }); + const techdocsGenerator = TechdocsGenerator.fromConfig(config, options); generators.register('techdocs', techdocsGenerator); return generators; diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index 9e01c3e2ad..9323ab4f18 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -23,7 +23,7 @@ import os from 'os'; import path, { resolve as resolvePath } from 'path'; import { ParsedLocationAnnotation } from '../../helpers'; import { - addBuildTimestampMetadata, + createOrUpdateMetadata, getGeneratorKey, getMkdocsYml, getRepoUrlFromLocationAnnotation, @@ -369,13 +369,15 @@ describe('helpers', () => { }); describe('addBuildTimestampMetadata', () => { + const mockFiles = { + 'invalid_techdocs_metadata.json': 'dsds', + 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', + }; + beforeEach(() => { mockFs.restore(); mockFs({ - [rootDir]: { - 'invalid_techdocs_metadata.json': 'dsds', - 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', - }, + [rootDir]: mockFiles, }); }); @@ -385,7 +387,7 @@ describe('helpers', () => { it('should create the file if it does not exist', async () => { const filePath = path.join(rootDir, 'wrong_techdocs_metadata.json'); - await addBuildTimestampMetadata(filePath, mockLogger); + await createOrUpdateMetadata(filePath, mockLogger); // Check if the file exists await expect( @@ -397,18 +399,28 @@ describe('helpers', () => { const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json'); await expect( - addBuildTimestampMetadata(filePath, mockLogger), + createOrUpdateMetadata(filePath, mockLogger), ).rejects.toThrowError('Unexpected token d in JSON at position 0'); }); it('should add build timestamp to the metadata json', async () => { const filePath = path.join(rootDir, 'techdocs_metadata.json'); - await addBuildTimestampMetadata(filePath, mockLogger); + await createOrUpdateMetadata(filePath, mockLogger); const json = await fs.readJson(filePath); expect(json.build_timestamp).toBeLessThanOrEqual(Date.now()); }); + + it('should add list of files to the metadata json', async () => { + const filePath = path.join(rootDir, 'techdocs_metadata.json'); + + await createOrUpdateMetadata(filePath, mockLogger); + + const json = await fs.readJson(filePath); + expect(json.files[0]).toEqual(Object.keys(mockFiles)[0]); + expect(json.files[1]).toEqual(Object.keys(mockFiles)[1]); + }); }); describe('storeEtagMetadata', () => { diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index f77b557076..7c46f4e966 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -18,7 +18,7 @@ import { isChildPath } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { assertError, ForwardedError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { spawn } from 'child_process'; +import { SpawnOptionsWithoutStdio, spawn } from 'child_process'; import fs from 'fs-extra'; import gitUrlParse from 'git-url-parse'; import yaml, { DEFAULT_SCHEMA, Type } from 'js-yaml'; @@ -27,6 +27,7 @@ import { PassThrough, Writable } from 'stream'; import { Logger } from 'winston'; import { ParsedLocationAnnotation } from '../../helpers'; import { SupportedGeneratorKey } from './types'; +import { getFileTreeRecursively } from '../publish/helpers'; // TODO: Implement proper support for more generators. export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { @@ -38,19 +39,18 @@ export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { } export type RunCommandOptions = { + /** command to run */ command: string; + /** arguments to pass the command */ args: string[]; - options: object; + /** options to pass to spawn */ + options: SpawnOptionsWithoutStdio; + /** stream to capture stdout and stderr output */ logStream?: Writable; }; /** - * - * @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 + * Run a command in a sub-process, normally a shell command. */ export const runCommand = async ({ command, @@ -345,14 +345,20 @@ export const patchIndexPreBuild = async ({ }; /** - * Update the techdocs_metadata.json to add a new build timestamp metadata. Create the .json file if it doesn't exist. + * Create or update the techdocs_metadata.json. Values initialized/updated are: + * - The build_timestamp (now) + * - The list of files generated * * @param {string} techdocsMetadataPath File path to techdocs_metadata.json */ -export const addBuildTimestampMetadata = async ( +export const createOrUpdateMetadata = async ( techdocsMetadataPath: string, logger: Logger, ): Promise => { + const techdocsMetadataDir = techdocsMetadataPath + .split(path.sep) + .slice(0, -1) + .join(path.sep); // check if file exists, create if it does not. try { await fs.access(techdocsMetadataPath, fs.constants.F_OK); @@ -372,6 +378,19 @@ export const addBuildTimestampMetadata = async ( } json.build_timestamp = Date.now(); + + // Get and write generated files to the metadata JSON. Each file string is in + // a form appropriate for invalidating the associated object from cache. + try { + json.files = (await getFileTreeRecursively(techdocsMetadataDir)).map(file => + file.replace(`${techdocsMetadataDir}${path.sep}`, ''), + ); + } catch (err) { + assertError(err); + json.files = []; + logger.warn(`Unable to add files list to metadata: ${err.message}`); + } + await fs.writeJson(techdocsMetadataPath, json); return; }; diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index 728cab5a81..a6eb8d38d2 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -23,7 +23,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { - addBuildTimestampMetadata, + createOrUpdateMetadata, getMkdocsYml, patchIndexPreBuild, patchMkdocsYmlPreBuild, @@ -52,11 +52,9 @@ export class TechdocsGenerator implements GeneratorBase { static fromConfig( config: Config, - { - containerRunner, - logger, - }: { containerRunner: ContainerRunner; logger: Logger }, + options: { containerRunner: ContainerRunner; logger: Logger }, ) { + const { containerRunner, logger } = options; const scmIntegrations = ScmIntegrations.fromConfig(config); return new TechdocsGenerator({ logger, @@ -66,31 +64,28 @@ export class TechdocsGenerator implements GeneratorBase { }); } - constructor({ - logger, - containerRunner, - config, - scmIntegrations, - }: { + constructor(options: { logger: Logger; containerRunner: ContainerRunner; config: Config; scmIntegrations: ScmIntegrationRegistry; }) { - this.logger = logger; - this.options = readGeneratorConfig(config, logger); - this.containerRunner = containerRunner; - this.scmIntegrations = scmIntegrations; + this.logger = options.logger; + this.options = readGeneratorConfig(options.config, options.logger); + this.containerRunner = options.containerRunner; + this.scmIntegrations = options.scmIntegrations; } - public async run({ - inputDir, - outputDir, - parsedLocationAnnotation, - etag, - logger: childLogger, - logStream, - }: GeneratorRunOptions): Promise { + public async run(options: GeneratorRunOptions): Promise { + const { + inputDir, + outputDir, + parsedLocationAnnotation, + etag, + logger: childLogger, + logStream, + } = options; + // Do some updates to mkdocs.yml before generating docs e.g. adding repo_url const { path: mkdocsYmlPath, content } = await getMkdocsYml(inputDir); @@ -164,9 +159,9 @@ export class TechdocsGenerator implements GeneratorBase { * Post Generate steps */ - // Add build timestamp to techdocs_metadata.json + // Add build timestamp and files to techdocs_metadata.json // Creates techdocs_metadata.json if file does not exist. - await addBuildTimestampMetadata( + await createOrUpdateMetadata( path.join(outputDir, 'techdocs_metadata.json'), childLogger, ); diff --git a/packages/techdocs-common/src/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts index 3559716be0..2a090d5584 100644 --- a/packages/techdocs-common/src/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -34,12 +34,13 @@ export type GeneratorConfig = { /** * The values that the generator will receive. * - * @param {string} inputDir The directory of the uncompiled documentation, with the values from the frontend - * @param {string} outputDir Directory to store generated docs in. Usually - a newly created temporary directory. - * @param {ParsedLocationAnnotation} parsedLocationAnnotation backstage.io/techdocs-ref annotation of an entity - * @param {string} etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json. - * @param {Logger} [logger] A logger that forwards the messages to the caller to be displayed outside of the backend. - * @param {Writable} [logStream] A log stream that can send raw log messages to the caller to be displayed outside of the backend.. + * @public + * @param inputDir - The directory of the uncompiled documentation, with the values from the frontend + * @param outputDir - Directory to store generated docs in. Usually - a newly created temporary directory. + * @param parsedLocationAnnotation - backstage.io/techdocs-ref annotation of an entity + * @param etag - A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json. + * @param logger - A logger that forwards the messages to the caller to be displayed outside of the backend. + * @param logStream - A log stream that can send raw log messages to the caller to be displayed outside of the backend. */ export type GeneratorRunOptions = { inputDir: string; diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 1f49668d35..3e4c4c705b 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -95,6 +95,7 @@ describe('AwsS3Publish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -149,21 +150,39 @@ describe('AwsS3Publish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified', async () => { const publisher = createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/component/backstage/404.html', + `backstage-data/techdocs/default/component/backstage/index.html`, + `backstage-data/techdocs/default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified and legacy casing is used', async () => { @@ -171,14 +190,26 @@ describe('AwsS3Publish', () => { bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/Component/backstage/404.html', + `backstage-data/techdocs/default/Component/backstage/index.html`, + `backstage-data/techdocs/default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when sse is specified', async () => { const publisher = createPublisherFromConfig({ sse: 'aws:kms', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + 'default/component/backstage/index.html', + 'default/component/backstage/assets/main.css', + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index abfa8ddd24..ed76edc0cb 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -39,6 +39,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -214,7 +215,11 @@ export class AwsS3Publish implements PublisherBase { * Upload all the files from the generated `directory` to the S3 bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; const bucketRootPath = this.bucketRootPath; const sse = this.sse; @@ -263,6 +268,7 @@ export class AwsS3Publish implements PublisherBase { ...(sse && { ServerSideEncryption: sse }), } as aws.S3.PutObjectRequest; + objects.push(params.Key); return this.storageClient.upload(params).promise(); }, absoluteFilesToUpload, @@ -311,6 +317,7 @@ export class AwsS3Publish implements PublisherBase { const errorMessage = `Unable to delete file(s) from AWS S3. ${error}`; this.logger.error(errorMessage); } + return { objects }; } async fetchTechDocsMetadata( diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index a2503bd7b2..e6fd45eed7 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -89,6 +89,7 @@ describe('AzureBlobStoragePublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -154,14 +155,26 @@ describe('AzureBlobStoragePublish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 7a58d92095..b082079be2 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -39,6 +39,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -156,7 +157,11 @@ export class AzureBlobStoragePublish implements PublisherBase { * Upload all the files from the generated `directory` to the Azure Blob Storage container. * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; // First, try to retrieve a list of all individual files currently existing @@ -194,14 +199,14 @@ export class AzureBlobStoragePublish implements PublisherBase { const relativeFilePath = path.normalize( path.relative(directory, absoluteFilePath), ); + const remotePath = getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + ); + objects.push(remotePath); const response = await container - .getBlockBlobClient( - getCloudPathForLocalPath( - entity, - relativeFilePath, - useLegacyPathCasing, - ), - ) + .getBlockBlobClient(remotePath) .uploadFile(absoluteFilePath); if (response._response.status >= 400) { @@ -264,6 +269,8 @@ export class AzureBlobStoragePublish implements PublisherBase { const errorMessage = `Unable to delete file(s) from Azure. ${error}`; this.logger.error(errorMessage); } + + return { objects }; } private download(containerName: string, blobPath: string): Promise { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index a52fd0f0bd..ed35f2e445 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -88,6 +88,7 @@ describe('GoogleGCSPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -142,21 +143,39 @@ describe('GoogleGCSPublish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified', async () => { const publisher = createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/component/backstage/404.html', + `backstage-data/techdocs/default/component/backstage/index.html`, + `backstage-data/techdocs/default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified and legacy casing is used', async () => { @@ -164,7 +183,13 @@ describe('GoogleGCSPublish', () => { bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/Component/backstage/404.html', + `backstage-data/techdocs/default/Component/backstage/index.html`, + `backstage-data/techdocs/default/Component/backstage/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index f072412c20..ba70c36e27 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -36,6 +36,7 @@ import { MigrateWriteStream } from './migrations'; import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -145,7 +146,11 @@ export class GoogleGCSPublish implements PublisherBase { * Upload all the files from the generated `directory` to the GCS bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; const bucket = this.storageClient.bucket(this.bucketName); const bucketRootPath = this.bucketRootPath; @@ -178,14 +183,14 @@ export class GoogleGCSPublish implements PublisherBase { await bulkStorageOperation( async absoluteFilePath => { const relativeFilePath = path.relative(directory, absoluteFilePath); - return await bucket.upload(absoluteFilePath, { - destination: getCloudPathForLocalPath( - entity, - relativeFilePath, - useLegacyPathCasing, - bucketRootPath, - ), - }); + const destination = getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + bucketRootPath, + ); + objects.push(destination); + return await bucket.upload(absoluteFilePath, { destination }); }, absoluteFilesToUpload, { concurrencyLimit: 10 }, @@ -228,6 +233,8 @@ export class GoogleGCSPublish implements PublisherBase { const errorMessage = `Unable to delete file(s) from Google Cloud Storage. ${error}`; this.logger.error(errorMessage); } + + return { objects }; } fetchTechDocsMetadata(entityName: EntityName): Promise { diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts index b342f33287..083cf2b8ff 100644 --- a/packages/techdocs-common/src/stages/publish/index.ts +++ b/packages/techdocs-common/src/stages/publish/index.ts @@ -14,4 +14,9 @@ * limitations under the License. */ export { Publisher } from './publish'; -export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; +export type { + PublisherBase, + PublisherType, + TechDocsMetadata, + ReadinessResponse, +} from './types'; diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 70b4eb3ff2..9c146d2935 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -98,7 +98,10 @@ export class LocalPublish implements PublisherBase { }; } - publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; const publishDir = this.staticEntityPathJoin( @@ -112,27 +115,30 @@ export class LocalPublish implements PublisherBase { fs.mkdirSync(publishDir, { recursive: true }); } - return new Promise((resolve, reject) => { - fs.copy(directory, publishDir, err => { - if (err) { - this.logger.debug( - `Failed to copy docs from ${directory} to ${publishDir}`, - ); - reject(err); - } - this.logger.info(`Published site stored at ${publishDir}`); - this.discovery - .getBaseUrl('techdocs') - .then(techdocsApiUrl => { - resolve({ - remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, - }); - }) - .catch(reason => { - reject(reason); - }); - }); - }); + try { + await fs.copy(directory, publishDir); + this.logger.info(`Published site stored at ${publishDir}`); + } catch (error) { + this.logger.debug( + `Failed to copy docs from ${directory} to ${publishDir}`, + ); + throw error; + } + + // Generate publish response. + const techdocsApiUrl = await this.discovery.getBaseUrl('techdocs'); + const publishedFilePaths = (await getFileTreeRecursively(publishDir)).map( + abs => { + return abs.split(`${staticDocsDir}/`)[1]; + }, + ); + + return { + remoteUrl: `${techdocsApiUrl}/static/docs/${encodeURIComponent( + entity.metadata.name, + )}`, + objects: publishedFilePaths, + }; } async fetchTechDocsMetadata( diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index c2236fb880..ef06e26cfa 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -170,7 +170,13 @@ describe('OpenStackSwiftPublish', () => { entity, directory: entityRootDir, }), - ).toBeUndefined(); + ).toMatchObject({ + objects: expect.arrayContaining([ + 'test-namespace/TestKind/test-component-name/404.html', + `test-namespace/TestKind/test-component-name/index.html`, + `test-namespace/TestKind/test-component-name/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { @@ -241,7 +247,7 @@ describe('OpenStackSwiftPublish', () => { mockFs({ [entityRootDir]: { 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', + '{"site_name": "backstage", "site_description": "site_content", "etag": "etag", "build_timestamp": 612741599}', }, }); @@ -249,6 +255,7 @@ describe('OpenStackSwiftPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; expect( await publisher.fetchTechDocsMetadata(entityNameMock), @@ -263,7 +270,7 @@ describe('OpenStackSwiftPublish', () => { mockFs({ [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, + 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag', 'build_timestamp': 612741599}`, }, }); @@ -271,6 +278,7 @@ describe('OpenStackSwiftPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; expect( await publisher.fetchTechDocsMetadata(entityNameMock), diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 7b623933e3..62b40f9a76 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -32,6 +32,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -139,8 +140,13 @@ export class OpenStackSwiftPublish implements PublisherBase { * Upload all the files from the generated `directory` to the OpenStack Swift container. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { try { + const objects: string[] = []; + // Note: OpenStack Swift manages creation of parent directories if they do not exist. // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); @@ -161,6 +167,7 @@ export class OpenStackSwiftPublish implements PublisherBase { // The / delimiter is intentional since it represents the cloud storage and not the local file system. const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Swift container file relative path + objects.push(destination); // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) const uploadFile = limiter(async () => { @@ -178,7 +185,7 @@ export class OpenStackSwiftPublish implements PublisherBase { this.logger.info( `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, ); - return; + return { objects }; } catch (e) { const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${e}`; this.logger.error(errorMessage); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 229c853427..b7415722d6 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -32,13 +32,27 @@ export type PublishRequest = { directory: string; }; -/* `remoteUrl` is the URL which serves files from the local publisher's static directory. */ +/** + * Response containing metadata about where files were published and what may + * have been published or updated. + */ export type PublishResponse = { + /** + * The URL which serves files from the local publisher's static directory. + */ remoteUrl?: string; + /** + * The list of objects (specifically their paths) that were published. + * Objects do not have a preceding slash, and match how one would load the + * object over the `/static/docs/*` TechDocs Backend Plugin endpoint. + */ + objects?: string[]; } | void; /** * Result for the validation check. + * + * @public */ export type ReadinessResponse = { /** If true, the publisher is able to interact with the backing storage. */ @@ -47,12 +61,14 @@ export type ReadinessResponse = { /** * Type to hold metadata found in techdocs_metadata.json and associated with each site - * @param etag ETag of the resource used to generate the site. Usually the latest commit sha of the source repository. + * @param etag - ETag of the resource used to generate the site. Usually the latest commit sha of the source repository. */ export type TechDocsMetadata = { site_name: string; site_description: string; etag: string; + build_timestamp: number; + files?: string[]; }; export type MigrateRequest = { @@ -72,6 +88,8 @@ export type MigrateRequest = { * Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.) * The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs. * It also provides APIs to communicate with the storage service. + * + * @public */ export interface PublisherBase { /** @@ -85,8 +103,8 @@ export interface PublisherBase { /** * Store the generated static files onto a storage service (either local filesystem or external service). * - * @param request Object containing the entity from the service - * catalog, and the directory that contains the generated static files from TechDocs. + * @param request - Object containing the entity from the service + * catalog, and the directory that contains the generated static files from TechDocs. */ publish(request: PublishRequest): Promise; diff --git a/packages/test-utils-core/.npmrc b/packages/test-utils-core/.npmrc deleted file mode 100644 index 214c29d139..0000000000 --- a/packages/test-utils-core/.npmrc +++ /dev/null @@ -1 +0,0 @@ -registry=https://registry.npmjs.org/ diff --git a/packages/test-utils-core/.snyk b/packages/test-utils-core/.snyk deleted file mode 100644 index f368a3ddb9..0000000000 --- a/packages/test-utils-core/.snyk +++ /dev/null @@ -1,10 +0,0 @@ -# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. -version: v1.22.1 -# ignores vulnerabilities until expiry date; change duration by modifying expiry date -ignore: - SNYK-JS-ANSIREGEX-1583908: - - '*': - reason: This only exposes the user's machine or build worker to a ReDos attack, which we don't consider an issue - expires: 2022-03-06T17:18:55.019Z - created: 2021-09-06T17:18:55.027Z -patch: {} diff --git a/packages/test-utils-core/CHANGELOG.md b/packages/test-utils-core/CHANGELOG.md deleted file mode 100644 index 35bdf1b2c6..0000000000 --- a/packages/test-utils-core/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -# @backstage/test-utils-core - -## 0.1.4 - -### Patch Changes - -- bb12aae352: Migrates all utility methods from `test-utils-core` into `test-utils` and delete exports from the old package. - This should have no impact since this package is considered internal and have no usages outside core packages. - - Notable changes are that the testing tool `msw.setupDefaultHandlers()` have been deprecated in favour of `setupRequestMockHandlers()`. - -## 0.1.3 - -### Patch Changes - -- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. - -## 0.1.2 - -### Patch Changes - -- 56c773909: Switched `@types/react` dependency to request `*` rather than a specific version. diff --git a/packages/test-utils-core/README.md b/packages/test-utils-core/README.md deleted file mode 100644 index 63b6ece0a6..0000000000 --- a/packages/test-utils-core/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @backstage/test-utils-core - -This package provides utilities for testing the Backstage core packages. - -## Installation - -This package should not be used directly, use `@backstage/test-utils` instead. All exports from this -package are re-exported by `@backstage/test-utils`. - -The reason this package exists is to allow the Backstage core packages to use the testing utils exposed in this package. Since `@backstage/test-utils` needs to depend on `@backstage/core`, core is not able to use those test-utils. We put any test-utils that don't need to depend on other Backstage packages in this package, in order for them to be usable by any other `@backstage` packages. - -## Documentation - -- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md deleted file mode 100644 index f21c749727..0000000000 --- a/packages/test-utils-core/api-report.md +++ /dev/null @@ -1,7 +0,0 @@ -## API Report File for "@backstage/test-utils-core" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index fd8ce852db..4da8ef195f 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/test-utils +## 0.1.24 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-plugin-api@0.3.0 + - @backstage/core-app-api@0.2.0 + ## 0.1.23 ### Patch Changes diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 83c209122d..24f17e0b84 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -34,52 +34,6 @@ export type ErrorWithContext = { context?: ErrorApiErrorContext; }; -// @public @deprecated (undocumented) -export class Keyboard { - constructor( - target: any, - { - debug, - }?: { - debug?: boolean | undefined; - }, - ); - // (undocumented) - click(): Promise; - // (undocumented) - debug: boolean; - // (undocumented) - document: any; - // (undocumented) - enter(value: any): Promise; - // (undocumented) - escape(): Promise; - // (undocumented) - get focused(): any; - // (undocumented) - static fromReadableInput(input: any): any; - // (undocumented) - _log(message: any, ...args: any[]): void; - // (undocumented) - _pretty(element: any): string; - // (undocumented) - send(chars: any): Promise; - // (undocumented) - _sendKey(key: any, charCode: any, action: any): Promise; - // (undocumented) - tab(): Promise; - // (undocumented) - static toReadableInput(chars: any): any; - // (undocumented) - toString(): string; - // (undocumented) - static type(target: any, input: any): Promise; - // (undocumented) - type(input: any): Promise; - // (undocumented) - static typeDebug(target: any, input: any): Promise; -} - // @public export type LogCollector = AsyncLogCollector | SyncLogCollector; @@ -89,23 +43,13 @@ export type LogFuncs = 'log' | 'warn' | 'error'; // @public export class MockAnalyticsApi implements AnalyticsApi { // (undocumented) - captureEvent({ - action, - subject, - value, - attributes, - context, - }: AnalyticsEvent): void; + captureEvent(event: AnalyticsEvent): void; // (undocumented) getEvents(): AnalyticsEvent[]; } // @public -export function mockBreakpoint({ - matches, -}: { - matches?: boolean | undefined; -}): void; +export function mockBreakpoint(options: { matches: boolean }): void; // @public export class MockErrorApi implements ErrorApi { @@ -149,15 +93,6 @@ export type MockStorageBucket = { [key: string]: any; }; -// @public @deprecated (undocumented) -export const msw: { - setupDefaultHandlers: (worker: { - listen: (t: any) => void; - close: () => void; - resetHandlers: () => void; - }) => void; -}; - // @public export function renderInTestApp( Component: ComponentType | ReactNode, @@ -178,10 +113,9 @@ export function setupRequestMockHandlers(worker: { export type SyncLogCollector = () => void; // @public -export const TestApiProvider: ({ - apis, - children, -}: TestApiProviderProps) => JSX.Element; +export const TestApiProvider: ( + props: TestApiProviderProps, +) => JSX.Element; // @public export type TestApiProviderProps = { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7e684adbfc..a8519160df 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.23", + "version": "0.1.24", "private": false, "publishConfig": { "access": "public", @@ -29,24 +29,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-app-api": "^0.1.23", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-app-api": "^0.2.0", + "@backstage/core-plugin-api": "^0.3.0", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", - "@types/react": "*", - "react": "^16.12.0", - "react-dom": "^16.12.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.1", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/Keyboard.js b/packages/test-utils/src/testUtils/Keyboard.js deleted file mode 100644 index 2a7928f8cd..0000000000 --- a/packages/test-utils/src/testUtils/Keyboard.js +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { act, fireEvent } from '@testing-library/react'; - -const codes = { - Tab: 9, - Enter: 10, - Click: 17 /* This keyboard can click, deal with it */, - Esc: 27, -}; - -/** - * @public - * @deprecated superseded by {@link @testing-library/user-event#userEvent} - */ -export class Keyboard { - static async type(target, input) { - await new Keyboard(target).type(input); - } - - static async typeDebug(target, input) { - await new Keyboard(target, { debug: true }).type(input); - } - - static toReadableInput(chars) { - return chars.split('').map(char => { - switch (char.charCodeAt(0)) { - case codes.Tab: - return ''; - case codes.Enter: - return ''; - case codes.Click: - return ''; - case codes.Esc: - return ''; - default: - return char; - } - }); - } - - static fromReadableInput(input) { - return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => { - if (name in codes) { - return String.fromCharCode(codes[name]); - } - throw new Error(`Unknown char name: '${name}'`); - }); - } - - constructor(target, { debug = false } = {}) { - this.debug = debug; - - if (target.ownerDocument) { - this.document = target.ownerDocument; - } else if (target.baseElement) { - this.document = target.baseElement.ownerDocument; - } else { - throw new TypeError( - 'Keyboard(target): target must be DOM node or react-testing-library render() output', - ); - } - } - - toString() { - return `Keyboard{document=${this.document}, debug=${this.debug}}`; - } - - _log(message, ...args) { - if (this.debug) { - // eslint-disable-next-line no-console - console.log(`[Keyboard] ${message}`, ...args); - } - } - - _pretty(element) { - const attrs = [...element.attributes] - .map(attr => `${attr.name}="${attr.value}"`) - .join(' '); - return `<${element.nodeName.toLocaleLowerCase('en-US')} ${attrs}>`; - } - - get focused() { - return this.document.activeElement; - } - - async type(input) { - this._log( - `sending sequence '${input}' with initial focus ${this._pretty( - this.focused, - )}`, - ); - await this.send(Keyboard.fromReadableInput(input)); - } - - async send(chars) { - for (const key of chars.split('')) { - const charCode = key.charCodeAt(0); - - if (charCode === codes.Tab) { - await this.tab(); - continue; - } - - const focused = this.focused; - if (!focused || focused === this.document.body) { - throw Error( - `No element focused in document while trying to type '${Keyboard.toReadableInput( - chars, - )}'`, - ); - } - const nextValue = (focused.value || '') + key; - - if (charCode >= 32) { - await this._sendKey(key, charCode, () => { - this._log( - `sending +${key} = '${nextValue}' to ${this._pretty(focused)}`, - ); - fireEvent.change(focused, { - target: { value: nextValue }, - bubbles: true, - cancelable: true, - }); - }); - } else if (charCode === codes.Enter) { - await this.enter(focused.value || ''); - } else if (charCode === codes.Esc) { - await this.escape(); - } else if (charCode === codes.Click) { - await this.click(); - } else { - throw new Error(`Unsupported char code, ${charCode}`); - } - } - } - - async click() { - this._log(`clicking ${this._pretty(this.focused)}`); - await act(async () => fireEvent.click(this.focused)); - } - - async tab() { - await this._sendKey('Tab', codes.Tab, () => { - const focusable = this.document.querySelectorAll( - [ - 'a[href]', - 'area[href]', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - 'button:not([disabled])', - 'iframe', - 'object', - 'embed', - '*[tabindex]', - '*[contenteditable]', - ].join(','), - ); - - const tabbable = [...focusable].filter(el => { - return el.tabIndex >= 0; - }); - - const focused = this.document.activeElement; - const focusedIndex = tabbable.indexOf(focused); - const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)]; - - this._log( - `tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`, - ); - nextFocus.focus(); - }); - } - - async enter(value) { - this._log(`submitting '${value}' via ${this._pretty(this.focused)}`); - await act(() => - this._sendKey('Enter', codes.Enter, () => { - if (this.focused.type === 'button') { - fireEvent.click(this.focused, { target: { value } }); - } else { - fireEvent.submit(this.focused, { - target: { value }, - bubbles: true, - cancelable: true, - }); - } - }), - ); - } - - async escape() { - this._log(`escape from ${this._pretty(this.focused)}`); - await act(async () => this._sendKey('Escape', codes.Esc)); - } - - async _sendKey(key, charCode, action) { - const event = { key, charCode, keyCode: charCode, which: charCode }; - const focused = this.focused; - - if (fireEvent.keyDown(focused, event)) { - if (fireEvent.keyPress(focused, event)) { - if (action) { - action(); - } - } - } - fireEvent.keyUp(focused, event); - } -} diff --git a/packages/test-utils/src/testUtils/Keyboard.test.js b/packages/test-utils/src/testUtils/Keyboard.test.js deleted file mode 100644 index 41ee3a12d6..0000000000 --- a/packages/test-utils/src/testUtils/Keyboard.test.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { Keyboard } from './Keyboard'; -import { render } from '@testing-library/react'; - -describe('testUtils.Keyboard', () => { - it('types into some inputs with focus and submits a form', async () => { - const typed1 = []; - const typed2 = []; - const typed3 = []; - - let submitted = false; - const handleSubmit = event => { - event.preventDefault(); - submitted = true; - }; - - const rendered = render( -

- typed1.push(value)} /> - typed2.push(value)} - /* eslint-disable-next-line jsx-a11y/no-autofocus */ - autoFocus - /> - typed3.push(value)} /> - , - ); - - const keyboard = new Keyboard(rendered); - await keyboard.send('xy'); - await keyboard.tab(); - await keyboard.send('abc'); - await keyboard.enter(); - - expect(typed1).toEqual([]); - expect(typed2).toEqual(['x', 'xy']); - expect(typed3).toEqual(['a', 'ab', 'abc']); - expect(submitted).toBe(true); - }); - - it('can use Keyboard.type to send readable input', async () => { - const typed1 = []; - const typed2 = []; - const typed3 = []; - - let submitted = false; - const handleSubmit = event => { - event.preventDefault(); - submitted = true; - }; - - const rendered = render( -
- typed1.push(value)} - /> - typed2.push(value)} - /> - typed3.push(value)} - /> -
, - ); - - await Keyboard.type(rendered, ' a b c '); - - expect(typed1).toEqual(['1a']); - expect(typed2).toEqual(['2b']); - expect(typed3).toEqual(['3c']); - expect(submitted).toBe(true); - }); - - it('should be able to navigate a radio input with click', async () => { - const selections = []; - - const rendered = render( -
selections.push(value)}> - - - -
, - ); - - await Keyboard.type(rendered, ' '); - - expect(selections).toEqual(['a', 'c']); - }); -}); diff --git a/packages/test-utils/src/testUtils/TestApiProvider.tsx b/packages/test-utils/src/testUtils/TestApiProvider.tsx index b1499b0cde..6e0be81d53 100644 --- a/packages/test-utils/src/testUtils/TestApiProvider.tsx +++ b/packages/test-utils/src/testUtils/TestApiProvider.tsx @@ -120,11 +120,13 @@ export class TestApiRegistry implements ApiHolder { * * @public **/ -export const TestApiProvider = ({ - apis, - children, -}: TestApiProviderProps) => { +export const TestApiProvider = ( + props: TestApiProviderProps, +) => { return ( - + ); }; diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts index 3e2fc2a01c..6da225df1c 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts @@ -19,18 +19,15 @@ import { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; /** * Mock implementation of {@link core-plugin-api#AnalyticsApi} with helpers to ensure that events are sent correctly. * Use getEvents in tests to verify captured events. + * * @public */ export class MockAnalyticsApi implements AnalyticsApi { private events: AnalyticsEvent[] = []; - captureEvent({ - action, - subject, - value, - attributes, - context, - }: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent) { + const { action, subject, value, attributes, context } = event; + this.events.push({ action, subject, diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index c778c5c837..a7850fc158 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -19,7 +19,6 @@ export { default as mockBreakpoint } from './mockBreakpoint'; export { wrapInTestApp, renderInTestApp } from './appWrappers'; export type { TestAppOptions } from './appWrappers'; export * from './msw'; -export * from './Keyboard'; export * from './logCollector'; export * from './testingLibrary'; export { TestApiProvider, TestApiRegistry } from './TestApiProvider'; diff --git a/packages/test-utils/src/testUtils/logCollector.ts b/packages/test-utils/src/testUtils/logCollector.ts index 25629f2c13..b9fa6220ed 100644 --- a/packages/test-utils/src/testUtils/logCollector.ts +++ b/packages/test-utils/src/testUtils/logCollector.ts @@ -73,7 +73,6 @@ export function withLogCollector( /** * Log collector that collect logs either from a sync or async collector. * @public - * @deprecated import from test-utils instead * */ export function withLogCollector( logsToCollect: LogFuncs[] | LogCollector, diff --git a/packages/test-utils/src/testUtils/mockBreakpoint.ts b/packages/test-utils/src/testUtils/mockBreakpoint.ts index 3bc285a81d..ee759c07a5 100644 --- a/packages/test-utils/src/testUtils/mockBreakpoint.ts +++ b/packages/test-utils/src/testUtils/mockBreakpoint.ts @@ -15,8 +15,8 @@ */ /** - * This is a mocking method suggested in the Jest Doc's, as it is not implemented in JSDOM yet. - * It can be used to mock values when the MUI `useMediaQuery` hook if it is used in a tested component. + * This is a mocking method suggested in the Jest docs, as it is not implemented in JSDOM yet. + * It can be used to mock values for the MUI `useMediaQuery` hook if it is used in a tested component. * * For issues checkout the documentation: * https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom @@ -26,11 +26,11 @@ * * @public */ -export default function mockBreakpoint({ matches = false }) { +export default function mockBreakpoint(options: { matches: boolean }) { Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ - matches: matches, + matches: options.matches ?? false, media: query, onchange: null, addListener: jest.fn(), // deprecated diff --git a/packages/test-utils/src/testUtils/msw/index.ts b/packages/test-utils/src/testUtils/msw/index.ts index 40bc47e8f0..625ef0e0fc 100644 --- a/packages/test-utils/src/testUtils/msw/index.ts +++ b/packages/test-utils/src/testUtils/msw/index.ts @@ -14,20 +14,6 @@ * limitations under the License. */ -/** - * @deprecated use {@link setupRequestMockHandlers} instead which can be called directly with the worker. - * @public - */ -export const msw = { - setupDefaultHandlers: (worker: { - listen: (t: any) => void; - close: () => void; - resetHandlers: () => void; - }) => { - setupRequestMockHandlers(worker); - }, -}; - /** * Sets up handlers for request mocking * @public diff --git a/packages/test-utils/src/testUtils/testingLibrary.ts b/packages/test-utils/src/testUtils/testingLibrary.ts index 44ea20a4cc..9fb47cbc90 100644 --- a/packages/test-utils/src/testUtils/testingLibrary.ts +++ b/packages/test-utils/src/testUtils/testingLibrary.ts @@ -15,8 +15,7 @@ */ import { ReactElement } from 'react'; -import { act } from 'react-dom/test-utils'; -import { render, RenderResult } from '@testing-library/react'; +import { act, render, RenderResult } from '@testing-library/react'; /** * @public diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 1c8661d7cc..cfa6ee3633 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/theme +## 0.2.14 + +### Patch Changes + +- e34f174fc5: Added `` and `` to enable building better sidebars. You can check out the storybook for more inspiration and how to get started. + + Added two new theme props for styling the sidebar too, `navItem.hoverBackground` and `submenu.background`. + +- 59677fadb1: Improvements to API Reference documentation + ## 0.2.13 ### Patch Changes diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 2dc6ab7b55..f650636710 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -41,6 +41,12 @@ export type BackstagePaletteAdditions = { indicator: string; color: string; selectedColor: string; + navItem?: { + hoverBackground: string; + }; + submenu?: { + background: string; + }; }; tabbar: { indicator: string; diff --git a/packages/theme/package.json b/packages/theme/package.json index 28a05af98d..08477dccd0 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.13", + "version": "0.2.14", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.12.2" }, "devDependencies": { - "@backstage/cli": "^0.9.0" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 93fec977f9..41f827bf43 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -15,7 +15,7 @@ */ /** - * material-ui theme for use with Backstage. + * {@link https://mui.com | material-ui} theme for use with Backstage * * @packageDocumentation */ diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 7e99ce8e8a..b048c70fec 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -76,6 +76,12 @@ export const lightTheme = createTheme({ indicator: '#9BF0E1', color: '#b5b5b5', selectedColor: '#FFF', + navItem: { + hoverBackground: '#404040', + }, + submenu: { + background: '#404040', + }, }, pinSidebarButton: { icon: '#181818', @@ -151,6 +157,12 @@ export const darkTheme = createTheme({ indicator: '#9BF0E1', color: '#b5b5b5', selectedColor: '#FFF', + navItem: { + hoverBackground: '#404040', + }, + submenu: { + background: '#404040', + }, }, pinSidebarButton: { icon: '#404040', diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index e1acba3a01..3d1a64c38f 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -53,6 +53,12 @@ export type BackstagePaletteAdditions = { indicator: string; color: string; selectedColor: string; + navItem?: { + hoverBackground: string; + }; + submenu?: { + background: string; + }; }; tabbar: { indicator: string; diff --git a/packages/types/package.json b/packages/types/package.json index 197d51b061..822b2d678e 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -31,7 +31,7 @@ }, "dependencies": {}, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@types/zen-observable": "^0.8.0", "zen-observable": "^0.8.15" }, diff --git a/packages/version-bridge/CHANGELOG.md b/packages/version-bridge/CHANGELOG.md new file mode 100644 index 0000000000..e079cd6c61 --- /dev/null +++ b/packages/version-bridge/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/version-bridge + +## 0.1.1 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. diff --git a/packages/version-bridge/package.json b/packages/version-bridge/package.json index 35a6e8f71d..0291cdc208 100644 --- a/packages/version-bridge/package.json +++ b/packages/version-bridge/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/version-bridge", "description": "Utilities used by @backstage packages to support multiple concurrent versions", - "version": "0.1.0", + "version": "0.1.1", "private": false, "publishConfig": { "access": "public", @@ -28,12 +28,12 @@ "postpack": "backstage-cli postpack", "clean": "backstage-cli clean" }, - "dependencies": { - "@types/react": "*", - "react": "^16.12.0" + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2" diff --git a/plugins/allure/CHANGELOG.md b/plugins/allure/CHANGELOG.md index f769b6e133..6b8336b251 100644 --- a/plugins/allure/CHANGELOG.md +++ b/plugins/allure/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-allure +## 0.1.8 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.1.7 ### Patch Changes diff --git a/plugins/allure/package.json b/plugins/allure/package.json index 33fc4db9d9..261c5e1879 100644 --- a/plugins/allure/package.json +++ b/plugins/allure/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-allure", "description": "A Backstage plugin that integrates with Allure", - "version": "0.1.7", + "version": "0.1.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -23,23 +23,24 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/analytics-module-ga/CHANGELOG.md b/plugins/analytics-module-ga/CHANGELOG.md index acc5c235f9..77a6089118 100644 --- a/plugins/analytics-module-ga/CHANGELOG.md +++ b/plugins/analytics-module-ga/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-analytics-module-ga +## 0.1.4 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- 704235786a: Support self hosted analytics.js script via `scriptSrc` config option +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index 7ca3b1646f..b0f3b736c3 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -17,13 +17,7 @@ export const analyticsModuleGA: BackstagePlugin<{}, {}>; // // @public export class GoogleAnalytics implements AnalyticsApi { - captureEvent({ - context, - action, - subject, - value, - attributes, - }: AnalyticsEvent): void; + captureEvent(event: AnalyticsEvent): void; static fromConfig(config: Config): GoogleAnalytics; } diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index 4ccd66e1dd..ac534daae8 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -27,6 +27,13 @@ export interface Config { */ trackingId: string; + /** + * URL to Google Analytics analytics.js script + * Defaults to fetching from GA source (eg. https://www.google-analytics.com/analytics.js) + * @visibility frontend + */ + scriptSrc?: string; + /** * Whether or not to log analytics debug statements to the console. * Defaults to false. diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index ffd6dd364c..5f48d4404e 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-analytics-module-ga", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,22 +22,23 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-ga": "^3.3.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 1e6216936c..28202607be 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -39,21 +39,24 @@ export class GoogleAnalytics implements AnalyticsApi { /** * Instantiate the implementation and initialize ReactGA. */ - private constructor({ - cdmConfig, - trackingId, - testMode, - debug, - }: { + private constructor(options: { cdmConfig: CustomDimensionOrMetricConfig[]; trackingId: string; + scriptSrc?: string; testMode: boolean; debug: boolean; }) { + const { cdmConfig, trackingId, scriptSrc, testMode, debug } = options; + this.cdmConfig = cdmConfig; // Initialize Google Analytics. - ReactGA.initialize(trackingId, { testMode, debug, titleCase: false }); + ReactGA.initialize(trackingId, { + testMode, + debug, + gaAddress: scriptSrc, + titleCase: false, + }); } /** @@ -62,6 +65,7 @@ export class GoogleAnalytics implements AnalyticsApi { static fromConfig(config: Config) { // Get all necessary configuration. const trackingId = config.getString('app.analytics.ga.trackingId'); + const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc'); const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false; const testMode = config.getOptionalBoolean('app.analytics.ga.testMode') ?? false; @@ -82,6 +86,7 @@ export class GoogleAnalytics implements AnalyticsApi { // Return an implementation instance. return new GoogleAnalytics({ trackingId, + scriptSrc, cdmConfig, testMode, debug, @@ -93,13 +98,8 @@ export class GoogleAnalytics implements AnalyticsApi { * pageview and the rest as custom events. All custom dimensions/metrics are * applied as they should be (set on pageview, merged object on events). */ - captureEvent({ - context, - action, - subject, - value, - attributes, - }: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent) { + const { context, action, subject, value, attributes } = event; const customMetadata = this.getCustomDimensionMetrics(context, attributes); if (action === 'navigate' && context.extension === 'App') { diff --git a/packages/test-utils-core/.eslintrc.js b/plugins/apache-airflow/.eslintrc.js similarity index 100% rename from packages/test-utils-core/.eslintrc.js rename to plugins/apache-airflow/.eslintrc.js diff --git a/plugins/apache-airflow/.gitignore b/plugins/apache-airflow/.gitignore new file mode 100644 index 0000000000..352a14e3cc --- /dev/null +++ b/plugins/apache-airflow/.gitignore @@ -0,0 +1,2 @@ +airflow/ +node_modules/ diff --git a/plugins/apache-airflow/CHANGELOG.md b/plugins/apache-airflow/CHANGELOG.md new file mode 100644 index 0000000000..64abdb929a --- /dev/null +++ b/plugins/apache-airflow/CHANGELOG.md @@ -0,0 +1,22 @@ +# @backstage/plugin-apache-airflow + +## 0.1.0 + +### Minor Changes + +- 9aea335911: Introduces a new plugin for the Apache Airflow workflow management platform. + This implementation has been tested with the Apache Airflow v2 API, + authenticating with basic authentication through the Backstage proxy plugin. + + Supported functionality includes: + + - Information card of version information of the Airflow instance + - Information card of instance health for the meta-database and scheduler + - Table of DAGs with meta information and status, along with a link to view + details in the Airflow UI + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 diff --git a/plugins/apache-airflow/README.md b/plugins/apache-airflow/README.md new file mode 100644 index 0000000000..bdbb1b981f --- /dev/null +++ b/plugins/apache-airflow/README.md @@ -0,0 +1,127 @@ +# Apache Airflow Plugin + +Welcome to the apache-airflow plugin! + +This plugin serves as frontend to the REST API exposed by Apache Airflow. +Note only [Airflow v2 (and later)](https://airflow.apache.org/docs/apache-airflow/stable/deprecated-rest-api-ref.html) integrate with the plugin. + +## Feature Requests & Ideas + +- [ ] Add support for running multiple instances of Airflow for monitoring + various deployment stages or business domains. ([Suggested by @JGoldman110](https://github.com/backstage/backstage/issues/735#issuecomment-985063468)) +- [ ] Make owner chips in the DAG table clickable, resolving to a user or group + in the entity catalog. ([Suggested by @julioz](https://github.com/backstage/backstage/pull/8348#discussion_r764766295)) + +## Installation + +1. Install the plugin with `yarn` in the root of your Backstage directory + +```sh +yarn --cwd packages/app add @backstage/plugin-apache-airflow +``` + +2. Import and use the plugin extension in `spp/src/App.tsx` + +```diff +--- a/packages/app/src/App.tsx ++++ b/packages/app/src/App.tsx +@@ -86,6 +86,7 @@ import { providers } from './identityProviders'; + import * as plugins from './plugins'; + + import { techDocsPage } from './components/techdocs/TechDocsPage'; ++import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; + + const app = createApp({ + apis, +@@ -203,6 +204,7 @@ const routes = ( + element={} + /> + } /> ++ } /> + + ); +``` + +## Configuration + +For links to the Airflow instance, the `baseUrl` must be defined in +`app-config.yaml`. + +```yaml +apacheAirflow: + baseUrl: https://your.airflow.instance.com +``` + +This plugin uses the Backstage proxy to securely communicate with the Apache +Airflow API. Add the following to your `app-config.yaml` to enable this +configuration: + +```yaml +proxy: + '/airflow': + target: https://your.airflow.instance.com/api/v1 + headers: + Authorization: ${AIRFLOW_BASIC_AUTH_HEADER} +``` + +In your production deployment of Backstage, you would also need to ensure that +you've set the `AIRFLOW_BASIC_AUTH_HEADER` environment variable before starting +the backend. + +While working locally, you may wish to hard-code your API key in your +`app-config.local.yaml` like this: + +```yaml +# app-config.local.yaml +proxy: + '/airflow': + target: http://localhost:8080/api/v1 + headers: + Authorization: Basic YWlyZmxvdzphaXJmbG93 +``` + +Where the basic authorization token is the base64 encoding of the username and +password of your instance. + +```sh +echo -n "airflow:airflow" | base64 -w0 +``` + +## Development + +For local development, you can setup a local Airflow instance for development +purposes by [running Airflow with Docker Compose][2]. + +To verify that Airflow is running, and the API is functioning as expected, you +can run the following `curl` command: + +```sh +curl -X GET \ + --user "airflow:airflow" \ + localhost:8080/api/v1/dags +``` + +To run the Backstage proxy, you will have to run start the `example-backend` +plugin. + +```sh +yarn workspace example-backend start +``` + +To verify that the proxy is configured correctly, you can curl the Backstage +proxy endpoint. If using basic authentication, you will have to base64 encode +the username and password: + +```sh +curl http://localhost:7007/api/proxy/airflow/dags +``` + +And finally, to run an instance of this plugin, you can run: + +```sh +yarn start +``` + +[1]: https://airflow.apache.org/docs/apache-airflow/stable/security/api.html +[2]: https://airflow.apache.org/docs/apache-airflow/stable/start/docker.html +[3]: https://airflow.apache.org/docs/apache-airflow/stable/stable-rest-api-ref.html diff --git a/plugins/apache-airflow/api-report.md b/plugins/apache-airflow/api-report.md new file mode 100644 index 0000000000..ca151f0734 --- /dev/null +++ b/plugins/apache-airflow/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-apache-airflow" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// Warning: (ae-missing-release-tag) "ApacheAirflowPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const ApacheAirflowPage: () => JSX.Element; + +// Warning: (ae-missing-release-tag) "apacheAirflowPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const apacheAirflowPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; +``` diff --git a/plugins/apache-airflow/config.d.ts b/plugins/apache-airflow/config.d.ts new file mode 100644 index 0000000000..3282116491 --- /dev/null +++ b/plugins/apache-airflow/config.d.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + /** Configurations for the Apache Airflow plugin */ + apacheAirflow: { + /** + * The base url of the Apache Airflow installation. + * @visibility frontend + */ + baseUrl: string; + }; +} diff --git a/plugins/apache-airflow/dev/index.tsx b/plugins/apache-airflow/dev/index.tsx new file mode 100644 index 0000000000..bc8ce977a5 --- /dev/null +++ b/plugins/apache-airflow/dev/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createDevApp } from '@backstage/dev-utils'; +import { apacheAirflowPlugin, ApacheAirflowPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(apacheAirflowPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/apache-airflow', + }) + .render(); diff --git a/plugins/apache-airflow/package.json b/plugins/apache-airflow/package.json new file mode 100644 index 0000000000..58c5dceb77 --- /dev/null +++ b/plugins/apache-airflow/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-apache-airflow", + "version": "0.1.0", + "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/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "cross-fetch": "^3.0.6", + "qs": "^6.10.1", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "msw": "^0.35.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/apache-airflow/src/api/ApacheAirflowApi.ts b/plugins/apache-airflow/src/api/ApacheAirflowApi.ts new file mode 100644 index 0000000000..dde8c57802 --- /dev/null +++ b/plugins/apache-airflow/src/api/ApacheAirflowApi.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import { Dag, InstanceStatus, InstanceVersion } from './types'; + +export const apacheAirflowApiRef = createApiRef({ + id: 'plugin.apacheairflow.service', +}); + +export type ApacheAirflowApi = { + discoveryApi: DiscoveryApi; + baseUrl: string; + listDags(options?: { objectsPerRequest: number }): Promise; + updateDag(dagId: string, isPaused: boolean): Promise; + getInstanceStatus(): Promise; + getInstanceVersion(): Promise; +}; diff --git a/plugins/apache-airflow/src/api/ApacheAirflowClient.test.ts b/plugins/apache-airflow/src/api/ApacheAirflowClient.test.ts new file mode 100644 index 0000000000..704b17eb34 --- /dev/null +++ b/plugins/apache-airflow/src/api/ApacheAirflowClient.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { UrlPatternDiscovery } from '@backstage/core-app-api'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ApacheAirflowClient } from './index'; +import { Dag } from './types'; + +const server = setupServer(); + +const dags: Dag[] = [ + { + dag_id: 'mock_dag_1', + fileloc: '', + file_token: '', + owners: ['admin'], + schedule_interval: { __type: 'CronExpression', value: '* * 0 0 0' }, + tags: [{ name: 'exmaple' }], + }, + { + dag_id: 'mock_dag_2', + fileloc: '', + file_token: '', + owners: ['admin'], + schedule_interval: { __type: 'CronExpression', value: '* * 0 0 0' }, + tags: [{ name: 'exmaple' }], + }, + { + dag_id: 'mock_dag_3', + fileloc: '', + file_token: '', + owners: ['admin'], + schedule_interval: { __type: 'CronExpression', value: '* * 0 0 0' }, + tags: [{ name: 'exmaple' }], + }, + { + dag_id: 'mock_dag_4', + fileloc: '', + file_token: '', + owners: ['admin'], + schedule_interval: { __type: 'CronExpression', value: '* * 0 0 0' }, + tags: [{ name: 'exmaple' }], + }, + { + dag_id: 'mock_dag_5', + fileloc: '', + file_token: '', + owners: ['admin'], + schedule_interval: { __type: 'CronExpression', value: '* * 0 0 0' }, + tags: [{ name: 'exmaple' }], + }, +]; + +describe('ApacheAirflowClient', () => { + setupRequestMockHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const setupHandlers = () => { + server.use( + rest.get(`${mockBaseUrl}/airflow/dags`, (req, res, ctx) => { + expect(req.url.searchParams.get('limit')).toBe('2'); + + // emulate paging to check if everything is requested + if (req.url.searchParams.get('offset') === '0') { + return res( + ctx.json({ + dags: dags.slice(0, 2), + total_entries: dags.length, + }), + ); + } + + // page offset 2 + if (req.url.searchParams.get('offset') === '2') { + return res( + ctx.json({ + dags: dags.slice(2, 4), + total_entries: dags.length, + }), + ); + } + + // page offset 4 + expect(req.url.searchParams.get('offset')).toBe('4'); + return res( + ctx.json({ + dags: dags.slice(4), + total_entries: dags.length, + }), + ); + }), + + rest.patch(`${mockBaseUrl}/airflow/dags/:dag_id`, (req, res, ctx) => { + const { dag_id } = req.params; + const body = JSON.parse(req.body as string); + expect(body.is_paused).toBeDefined(); + return res( + ctx.json({ + dag_id: dag_id, + root_dag_id: 'string', + is_paused: body.is_paused, + is_active: true, + is_subdag: true, + fileloc: 'string', + file_token: 'string', + owners: ['string'], + description: 'string', + schedule_interval: { + __type: 'string', + days: 0, + seconds: 0, + microseconds: 0, + }, + tags: [{}], + }), + ); + }), + ); + }; + + it('list dags should return all dags with emulated pagination', async () => { + setupHandlers(); + const client = new ApacheAirflowClient({ + discoveryApi: discoveryApi, + baseUrl: 'localhost:8080/', + }); + + // call with limit of 2, to force two paginations in requesting all dags + // as our mocked response has 4 total entries + const responseDags = await client.listDags({ objectsPerRequest: 2 }); + expect(responseDags.length).toEqual(5); + expect(responseDags).toEqual(dags); + }); + + it('update dag should return dag information with updated paused attribute', async () => { + setupHandlers(); + const client = new ApacheAirflowClient({ + discoveryApi: discoveryApi, + baseUrl: 'localhost:8080/', + }); + const dagId = 'mock_dag_1'; + const response: Dag = await client.updateDag(dagId, true); + expect(response.dag_id).toEqual(dagId); + expect(response.is_paused).toEqual(true); + }); +}); diff --git a/plugins/apache-airflow/src/api/ApacheAirflowClient.ts b/plugins/apache-airflow/src/api/ApacheAirflowClient.ts new file mode 100644 index 0000000000..c184e304b4 --- /dev/null +++ b/plugins/apache-airflow/src/api/ApacheAirflowClient.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import fetch from 'cross-fetch'; +import qs from 'qs'; +import { ApacheAirflowApi } from './ApacheAirflowApi'; +import { + Dag, + Dags, + InstanceStatus, + InstanceVersion, + ListDagsParams, +} from './types'; + +export class ApacheAirflowClient implements ApacheAirflowApi { + discoveryApi: DiscoveryApi; + baseUrl: string; + + constructor({ + discoveryApi, + baseUrl = 'http://localhost:8080', + }: { + discoveryApi: DiscoveryApi; + baseUrl: string; + }) { + this.discoveryApi = discoveryApi; + this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + } + + /** + * List all DAGs in the Airflow instance + * + * @remarks + * All DAGs with a limit of 100 results per request are returned; this may be + * bogged-down for instances with many DAGs, in which case table pagination + * should be implemented + * + * @param {number} objectsPerRequest records returned per request in pagination + * @returns {Promise} + */ + async listDags(options = { objectsPerRequest: 100 }): Promise { + const dags: Dag[] = []; + const searchParams: ListDagsParams = { + limit: options.objectsPerRequest, + offset: 0, + }; + + for (;;) { + const response = await this.fetch( + `/dags?${qs.stringify(searchParams)}`, + ); + dags.push(...response.dags); + + if (dags.length >= response.total_entries) { + break; + } + if (typeof searchParams.offset !== 'undefined') { + searchParams.offset += options.objectsPerRequest; + } + } + return dags; + } + + async updateDag(dagId: string, isPaused: boolean): Promise { + const init = { + method: 'PATCH', + body: JSON.stringify({ is_paused: isPaused }), + }; + return await this.fetch(`/dags/${dagId}`, init); + } + + async getInstanceStatus(): Promise { + return await this.fetch('/health'); + } + + async getInstanceVersion(): Promise { + return await this.fetch('/version'); + } + + private async fetch(input: string, init?: RequestInit): Promise { + const proxyUri = `${await this.discoveryApi.getBaseUrl('proxy')}/airflow`; + const response = await fetch(`${proxyUri}${input}`, init); + if (!response.ok) throw new Error(response.statusText); + return await response.json(); + } +} diff --git a/plugins/apache-airflow/src/api/index.ts b/plugins/apache-airflow/src/api/index.ts new file mode 100644 index 0000000000..c6dfe2205e --- /dev/null +++ b/plugins/apache-airflow/src/api/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * An API definition and client for integrating with the Apache Airflow v2+ + * API + * + * @packageDocumentation + */ + +export * from './ApacheAirflowApi'; +export * from './ApacheAirflowClient'; diff --git a/plugins/apache-airflow/src/api/types/Dags.ts b/plugins/apache-airflow/src/api/types/Dags.ts new file mode 100644 index 0000000000..8f1d68fb2d --- /dev/null +++ b/plugins/apache-airflow/src/api/types/Dags.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Query parameters for listing DAGs + */ +export interface ListDagsParams { + limit?: number; + offset?: number; + order_by?: string; + tags?: Tag[]; + only_active?: boolean; +} + +export interface Dags { + dags: Dag[]; + total_entries: number; +} + +export interface Dag { + dag_id: string; + root_dag_id?: string; + is_paused?: boolean; + is_active?: boolean; + is_subdag?: boolean; + fileloc: string; + file_token: string; + owners: string[]; + description?: string; + schedule_interval: ScheduleInterval; + tags: Tag[]; +} + +export interface TimeDelta { + __type: 'TimeDelta'; + days: number; + seconds: number; + microseconds: number; +} + +export interface RelativeDelta { + __type: 'RelativeDelta'; + years: number; + months: number; + days: number; + leapdays: number; + hours: number; + minutes: number; + seconds: number; + microseconds: number; + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + microsecond: number; +} + +export interface CronExpression { + __type: 'CronExpression'; + value: string; +} + +// discrimant union of possible schedule interval types +export type ScheduleInterval = TimeDelta | RelativeDelta | CronExpression; + +export interface Tag { + name: string; +} diff --git a/plugins/apache-airflow/src/api/types/InstanceStatus.ts b/plugins/apache-airflow/src/api/types/InstanceStatus.ts new file mode 100644 index 0000000000..3b7a5ce416 --- /dev/null +++ b/plugins/apache-airflow/src/api/types/InstanceStatus.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 InstanceStatus { + metadatabase: MetadatabaseStatus; + scheduler: SchedulerStatus; +} + +export interface MetadatabaseStatus { + status: 'healthy' | 'unhealthy'; +} + +export interface SchedulerStatus { + status: 'healthy' | 'unhealthy'; + latest_scheduler_heartbeat?: string; +} diff --git a/plugins/apache-airflow/src/api/types/InstanceVersion.ts b/plugins/apache-airflow/src/api/types/InstanceVersion.ts new file mode 100644 index 0000000000..b8d2727229 --- /dev/null +++ b/plugins/apache-airflow/src/api/types/InstanceVersion.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 InstanceVersion { + version: string; + git_version?: string; +} diff --git a/plugins/apache-airflow/src/api/types/index.ts b/plugins/apache-airflow/src/api/types/index.ts new file mode 100644 index 0000000000..67c2d293a2 --- /dev/null +++ b/plugins/apache-airflow/src/api/types/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + ListDagsParams, + Dag, + Dags, + Tag, + TimeDelta, + RelativeDelta, + CronExpression, + ScheduleInterval, +} from './Dags'; +export type { InstanceStatus } from './InstanceStatus'; +export type { InstanceVersion } from './InstanceVersion'; diff --git a/plugins/apache-airflow/src/components/DagTableComponent/DagTableComponent.tsx b/plugins/apache-airflow/src/components/DagTableComponent/DagTableComponent.tsx new file mode 100644 index 0000000000..13122ca552 --- /dev/null +++ b/plugins/apache-airflow/src/components/DagTableComponent/DagTableComponent.tsx @@ -0,0 +1,151 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + Progress, + StatusError, + StatusOK, + Table, + TableColumn, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import Box from '@material-ui/core/Box'; +import Chip from '@material-ui/core/Chip'; +import IconButton from '@material-ui/core/IconButton'; +import Switch from '@material-ui/core/Switch'; +import Tooltip from '@material-ui/core/Tooltip'; +import Typography from '@material-ui/core/Typography'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { apacheAirflowApiRef } from '../../api'; +import { Dag } from '../../api/types'; +import { ScheduleIntervalLabel } from '../ScheduleIntervalLabel'; + +type DagTableRow = Dag & { + id: string; + dagUrl: string; +}; + +const columns: TableColumn[] = [ + { + title: 'Paused', + field: 'is_paused', + render: (row: Partial) => ( + + + + ), + width: '5%', + }, + { + title: 'DAG', + field: 'id', + render: (row: Partial) => ( +
+ + {row.id} + + + {row.tags?.map((tag, ix) => ( + + ))} + +
+ ), + width: '60%', + }, + { + title: 'Owner', + field: 'owners', + render: (row: Partial) => ( + + {row.owners?.map((owner, ix) => ( + + ))} + + ), + width: '10%', + }, + { + title: 'Active', + render: (row: Partial) => ( + + {row.is_active ? : } + + {row.is_active ? 'Active' : 'Inactive'} + + + ), + width: '10%', + }, + { + title: 'Schedule', + render: (row: Partial) => ( + + ), + width: '10%', + }, + { + title: 'Link', + field: 'dagUrl', + render: (row: Partial) => ( + + + + + + ), + width: '5%', + }, +]; + +type DenseTableProps = { + dags: Dag[]; +}; + +export const DenseTable = ({ dags }: DenseTableProps) => { + return ( + + ); +}; + +export const DagTableComponent = () => { + const apiClient = useApi(apacheAirflowApiRef); + const { value, loading, error } = useAsync(async (): Promise => { + return await apiClient.listDags(); + }, []); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + const data = value?.map(el => ({ + ...el, + id: el.dag_id, // table records require `id` attribute + dagUrl: `${apiClient.baseUrl}dag_details?dag_id=${el.dag_id}`, // construct path to DAG using `baseUrl` + })); + + return ; +}; diff --git a/plugins/bazaar/src/components/DeleteProjectDialog/index.ts b/plugins/apache-airflow/src/components/DagTableComponent/index.ts similarity index 90% rename from plugins/bazaar/src/components/DeleteProjectDialog/index.ts rename to plugins/apache-airflow/src/components/DagTableComponent/index.ts index fdd488aeb9..714cbce47d 100644 --- a/plugins/bazaar/src/components/DeleteProjectDialog/index.ts +++ b/plugins/apache-airflow/src/components/DagTableComponent/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { DeleteProjectDialog } from './DeleteProjectDialog'; +export { DagTableComponent } from './DagTableComponent'; diff --git a/plugins/apache-airflow/src/components/HomePage/HomePage.test.tsx b/plugins/apache-airflow/src/components/HomePage/HomePage.test.tsx new file mode 100644 index 0000000000..23afdd0a1e --- /dev/null +++ b/plugins/apache-airflow/src/components/HomePage/HomePage.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { ApacheAirflowApi, apacheAirflowApiRef } from '../../api'; +import { HomePage } from './HomePage'; + +describe('', () => { + const mockApi: jest.Mocked = { + getInstanceStatus: jest.fn().mockResolvedValue({ + metadatabase: { status: 'healthy' }, + scheduler: { status: 'healthy' }, + }), + getInstanceVersion: jest.fn().mockResolvedValue({ + version: 'v2.0.0', + }), + listDags: jest.fn().mockResolvedValue([ + { + dag_id: 'mock_dag_1', + }, + ]), + } as any; + + it('homepage should render', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText('Apache Airflow')).toBeInTheDocument(); + }); +}); diff --git a/plugins/apache-airflow/src/components/HomePage/HomePage.tsx b/plugins/apache-airflow/src/components/HomePage/HomePage.tsx new file mode 100644 index 0000000000..442f1bf4a0 --- /dev/null +++ b/plugins/apache-airflow/src/components/HomePage/HomePage.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, + ContentHeader, + Header, + HeaderLabel, + Page, + SupportButton, +} from '@backstage/core-components'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { DagTableComponent } from '../DagTableComponent'; +import { StatusComponent } from '../StatusComponent'; +import { VersionComponent } from '../VersionComponent'; + +export const HomePage = () => ( + +
+ +
+ + + + See an overview of your Apache Airflow instance, and manage workflows + + + + + + + + + + + + + + +
+); diff --git a/plugins/bazaar/src/components/AlertBanner/index.ts b/plugins/apache-airflow/src/components/HomePage/index.ts similarity index 93% rename from plugins/bazaar/src/components/AlertBanner/index.ts rename to plugins/apache-airflow/src/components/HomePage/index.ts index 1728cab9fc..41d7bc5d03 100644 --- a/plugins/bazaar/src/components/AlertBanner/index.ts +++ b/plugins/apache-airflow/src/components/HomePage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { AlertBanner } from './AlertBanner'; +export { HomePage } from './HomePage'; diff --git a/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.test.tsx b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.test.tsx new file mode 100644 index 0000000000..bfe4119bcd --- /dev/null +++ b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { render } from '@testing-library/react'; +import React from 'react'; +import { CronExpression, RelativeDelta, TimeDelta } from '../../api/types'; +import { ScheduleIntervalLabel } from './ScheduleIntervalLabel'; + +describe('ScheduleIntervalLabel', () => { + it('should render with cronexpression interval', async () => { + const interval = { + __type: 'CronExpression', + value: '0 0 * * *', + } as CronExpression; + const { findByText } = render( + , + ); + expect(await findByText('0 0 * * *')).toBeInTheDocument(); + }); + + it('should render with time delta interval', async () => { + const interval = { + __type: 'TimeDelta', + days: 5, + seconds: 750, + microseconds: 600, + } as TimeDelta; + const { findByText } = render( + , + ); + expect(await findByText('5 days 00:12:30')).toBeInTheDocument(); + }); + + it('should not render days with time delta interval with 0 days', async () => { + const interval = { + __type: 'TimeDelta', + days: 0, + seconds: 750, + microseconds: 600, + } as TimeDelta; + const { findByText } = render( + , + ); + expect(await findByText('00:12:30')).toBeInTheDocument(); + }); + + it('should render singular day with time delta interval and 1 day', async () => { + const interval = { + __type: 'TimeDelta', + days: 1, + seconds: 750, + microseconds: 600, + } as TimeDelta; + const { findByText } = render( + , + ); + expect(await findByText('1 day 00:12:30')).toBeInTheDocument(); + }); + + it('should render with relative delta interval', async () => { + const interval = { + __type: 'RelativeDelta', + days: 15, + months: 6, + } as RelativeDelta; + const { findByText } = render( + , + ); + expect( + await findByText('relativedelta(days=+15,months=+6)'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.tsx b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.tsx new file mode 100644 index 0000000000..3ae72098d0 --- /dev/null +++ b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/ScheduleIntervalLabel.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 Chip from '@material-ui/core/Chip'; +import React from 'react'; +import { ScheduleInterval, TimeDelta, RelativeDelta } from '../../api/types'; + +interface Props { + interval: ScheduleInterval | undefined; +} + +const timeDeltaToLabel = (delta: TimeDelta): String => { + let label = ''; + const date = new Date(0); + date.setSeconds(delta.seconds); + const time = date.toISOString().substr(11, 8); + if (delta.days === 0) { + label = `${time}`; + } else if (delta.days === 1) { + label = `1 day ${time}`; + } else { + label = `${delta.days} days ${time}`; + } + return label; +}; + +const relativeDeltaToLabel = (delta: RelativeDelta) => { + const params = Object.entries(delta) + .filter(o => o[0] !== '__type' && o[1] !== null && o[1] !== 0) + .map(o => (o[1] > 0 ? `${o[0]}=+${o[1]}` : `${o[0]}=-${o[1]}`)); + return `relativedelta(${params})`; +}; + +export const ScheduleIntervalLabel = ({ interval }: Props) => { + let label: String = ''; + switch (interval?.__type) { + case 'TimeDelta': + label = timeDeltaToLabel(interval); + break; + case 'RelativeDelta': + label = relativeDeltaToLabel(interval); + break; + case 'CronExpression': + label = interval.value; + break; + default: + label = 'None'; + } + return ; +}; diff --git a/plugins/apache-airflow/src/components/ScheduleIntervalLabel/index.ts b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/index.ts new file mode 100644 index 0000000000..cb6b2092f5 --- /dev/null +++ b/plugins/apache-airflow/src/components/ScheduleIntervalLabel/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ScheduleIntervalLabel } from './ScheduleIntervalLabel'; diff --git a/plugins/apache-airflow/src/components/StatusComponent/StatusComponent.tsx b/plugins/apache-airflow/src/components/StatusComponent/StatusComponent.tsx new file mode 100644 index 0000000000..326be4b450 --- /dev/null +++ b/plugins/apache-airflow/src/components/StatusComponent/StatusComponent.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + InfoCard, + Progress, + StructuredMetadataTable, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { apacheAirflowApiRef } from '../../api'; +import { InstanceStatus } from '../../api/types'; + +export const StatusComponent = () => { + const apiClient = useApi(apacheAirflowApiRef); + const { value, loading, error } = + useAsync(async (): Promise => { + return await apiClient.getInstanceStatus(); + }, []); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (value) { + const metadata = { + 'Metadatabase Status': value.metadatabase.status, + 'Scheduler Status': value.scheduler.status, + 'Latest Scheduler Heartbeat': value.scheduler.latest_scheduler_heartbeat, + }; + + return ( + + + + ); + } + return No status information found...; +}; diff --git a/plugins/apache-airflow/src/components/StatusComponent/index.ts b/plugins/apache-airflow/src/components/StatusComponent/index.ts new file mode 100644 index 0000000000..4898c15e26 --- /dev/null +++ b/plugins/apache-airflow/src/components/StatusComponent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { StatusComponent } from './StatusComponent'; diff --git a/plugins/apache-airflow/src/components/VersionComponent/VersionComponent.tsx b/plugins/apache-airflow/src/components/VersionComponent/VersionComponent.tsx new file mode 100644 index 0000000000..482d1ff0a7 --- /dev/null +++ b/plugins/apache-airflow/src/components/VersionComponent/VersionComponent.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + InfoCard, + Progress, + StructuredMetadataTable, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { apacheAirflowApiRef } from '../../api'; +import { InstanceVersion } from '../../api/types'; + +export const VersionComponent = () => { + const apiClient = useApi(apacheAirflowApiRef); + const { value, loading, error } = + useAsync(async (): Promise => { + return await apiClient.getInstanceVersion(); + }, []); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (value) { + const metadata = { + Version: value.version, + 'Git Version': value.git_version, + }; + + return ( + + + + ); + } + return No status information found...; +}; diff --git a/plugins/apache-airflow/src/components/VersionComponent/index.ts b/plugins/apache-airflow/src/components/VersionComponent/index.ts new file mode 100644 index 0000000000..4ace64d320 --- /dev/null +++ b/plugins/apache-airflow/src/components/VersionComponent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { VersionComponent } from './VersionComponent'; diff --git a/plugins/apache-airflow/src/index.ts b/plugins/apache-airflow/src/index.ts new file mode 100644 index 0000000000..7186839c0a --- /dev/null +++ b/plugins/apache-airflow/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A Backstage plugin that integrates towards Apache Airflow + * + * @packageDocumentation + */ + +export { apacheAirflowPlugin, ApacheAirflowPage } from './plugin'; diff --git a/plugins/apache-airflow/src/plugin.test.ts b/plugins/apache-airflow/src/plugin.test.ts new file mode 100644 index 0000000000..26997d74d8 --- /dev/null +++ b/plugins/apache-airflow/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { apacheAirflowPlugin } from './plugin'; + +describe('apache-airflow', () => { + it('should export plugin', () => { + expect(apacheAirflowPlugin).toBeDefined(); + }); +}); diff --git a/plugins/apache-airflow/src/plugin.ts b/plugins/apache-airflow/src/plugin.ts new file mode 100644 index 0000000000..04ef1974d4 --- /dev/null +++ b/plugins/apache-airflow/src/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { rootRouteRef } from './routes'; +import { apacheAirflowApiRef, ApacheAirflowClient } from './api'; +import { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, + configApiRef, +} from '@backstage/core-plugin-api'; + +export const apacheAirflowPlugin = createPlugin({ + id: 'apache-airflow', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: apacheAirflowApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new ApacheAirflowClient({ + discoveryApi, + baseUrl: configApi.getString('apacheAirflow.baseUrl'), + }), + }), + ], +}); + +export const ApacheAirflowPage = apacheAirflowPlugin.provide( + createRoutableExtension({ + name: 'ApacheAirflowPage', + component: () => import('./components/HomePage').then(m => m.HomePage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/apache-airflow/src/routes.ts b/plugins/apache-airflow/src/routes.ts new file mode 100644 index 0000000000..2a0872eba4 --- /dev/null +++ b/plugins/apache-airflow/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'apache-airflow', +}); diff --git a/plugins/apache-airflow/src/setupTests.ts b/plugins/apache-airflow/src/setupTests.ts new file mode 100644 index 0000000000..427556fe26 --- /dev/null +++ b/plugins/apache-airflow/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index c880dc983c..833976fc2b 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-api-docs +## 0.6.18 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog@0.7.4 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.6.17 + +### Patch Changes + +- dde1681f33: chore(dependencies): bump `graphiql` package to latest +- ef64a444ca: Update AsyncAPI component to 1.0.0-x releases + +## 0.6.16 + +### Patch Changes + +- 752a53d94e: Improve theme integration for OpenApi definition +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.6.15 ### Patch Changes diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index d5ed198dc1..4919690568 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -160,6 +160,14 @@ by this plugin. Grab a copy of [oauth2-redirect.html](https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html) and put it in the `app/public/` directory in order to enable Swagger UI to complete this redirection. +This also may require you to adjust `Content Security Policy` header settings of your Backstage application, so that the script in `oauth2-redirect.html` can be executed. Since the script is static we can add the hash of it directly to our CSP policy, which we do by adding the following to the `csp` section of the app configuration: + +```yaml +script-src: + - "'self'" + - "'sha256-GeDavzSZ8O71Jggf/pQkKbt52dfZkrdNMQ3e+Ox+AkI='" # oauth2-redirect.html +``` + #### Configuring your OAuth2 Client You'll need to make sure your OAuth2 client has been registered in your OAuth2 Authentication Server (AS) diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 9f8b5fbf84..fd057cf358 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-api-docs", "description": "A Backstage plugin that helps represent API entities in the frontend", - "version": "0.6.15", + "version": "0.6.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,33 +30,34 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@asyncapi/react-component": "^0.23.0", + "@asyncapi/react-component": "^1.0.0-next.25", "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog": "^0.7.4", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", - "graphiql": "^1.0.0-alpha.10", - "graphql": "^15.3.0", + "graphiql": "^1.5.12", + "graphql": "^16.0.0", "isomorphic-form-data": "^2.0.0", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", "swagger-client": "3.16.1", "swagger-ui-react": "^4.0.0-rc.3" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx index cb89476c03..c9f979edad 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx @@ -18,6 +18,15 @@ import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; import { AsyncApiDefinition } from './AsyncApiDefinition'; +jest.mock('use-resize-observer', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })), +})); + describe('', () => { it('renders asyncapi spec', async () => { const definition = ` @@ -45,15 +54,7 @@ components: expect(getByText(/Account Service/i)).toBeInTheDocument(); expect(getByText(/user\/signedup/i)).toBeInTheDocument(); - expect(getByText(/UserSignedUp/i)).toBeInTheDocument(); - expect(getAllByText(/displayName/i)).toHaveLength(4); - }); - - it('renders error if definition is missing', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/Error/i)).toBeInTheDocument(); - expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument(); + expect(getAllByText(/UserSignedUp/i)).toHaveLength(2); + expect(getAllByText(/displayName/i)).toHaveLength(3); }); }); diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx index 1c993f67d7..3f9375921d 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx @@ -15,23 +15,72 @@ */ import AsyncApi from '@asyncapi/react-component'; -import '@asyncapi/react-component/lib/styles/fiori.css'; -import { alpha, makeStyles } from '@material-ui/core/styles'; +import '@asyncapi/react-component/styles/default.css'; +import { makeStyles, alpha, darken } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; import React from 'react'; +import { useTheme } from '@material-ui/core'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { - '& .asyncapi': { - 'font-family': 'inherit', + fontFamily: 'inherit', + '& .bg-white': { background: 'none', }, - '& h2': { - ...theme.typography.h6, + '& .text-4xl': { + ...theme.typography.h3, }, - '& .text-teal': { - color: theme.palette.primary.main, + ' & h2': { + ...theme.typography.h4, + }, + '& .border': { + borderColor: alpha(theme.palette.border, 0.1), + }, + '& .min-w-min': { + minWidth: 'fit-content', + }, + '& .examples': { + padding: '1rem', + }, + '& .bg-teal-500': { + backgroundColor: theme.palette.status.ok, + }, + '& .bg-blue-500': { + backgroundColor: theme.palette.info.main, + }, + '& .bg-blue-400': { + backgroundColor: theme.palette.info.light, + }, + '& .bg-indigo-400': { + backgroundColor: theme.palette.warning.main, + }, + '& .text-teal-50': { + color: theme.palette.status.ok, + }, + '& .text-red-600': { + color: theme.palette.error.main, + }, + '& .text-orange-600': { + color: theme.palette.warning.main, + }, + '& .text-teal-500': { + color: theme.palette.status.ok, + }, + '& .text-blue-500': { + color: theme.palette.info.main, + }, + '& .-rotate-90': { + '--tw-rotate': '0deg', }, '& button': { + ...theme.typography.button, + borderRadius: theme.shape.borderRadius, + color: theme.palette.primary.main, + }, + '& a': { + color: theme.palette.link, + }, + '& a.no-underline': { ...theme.typography.button, background: 'none', boxSizing: 'border-box', @@ -48,84 +97,47 @@ const useStyles = makeStyles(theme => ({ border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`, '&:hover': { textDecoration: 'none', - '&.Mui-disabled': { - backgroundColor: 'transparent', - }, border: `1px solid ${theme.palette.primary.main}`, backgroundColor: alpha( theme.palette.primary.main, theme.palette.action.hoverOpacity, ), - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - '&.Mui-disabled': { - color: theme.palette.action.disabled, }, }, - '& .asyncapi__collapse-button:hover': { - color: theme.palette.primary.main, - }, - '& button.asyncapi__toggle-button': { - 'min-width': 'inherit', - }, - '& .asyncapi__info-list li': { - 'border-color': theme.palette.primary.main, - '&:hover': { - color: theme.palette.text.primary, - 'border-color': theme.palette.primary.main, - 'background-color': theme.palette.primary.main, - }, - }, - '& .asyncapi__info-list li a': { - color: theme.palette.primary.main, - '&:hover': { + '& li.no-underline': { + '& a': { + textDecoration: 'none', color: theme.palette.getContrastText(theme.palette.primary.main), }, }, - '& .asyncapi__enum': { - color: theme.palette.secondary.main, + }, + dark: { + '& svg': { + fill: theme.palette.text.primary, }, - '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': - { - 'background-color': 'inherit', - }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': - { - 'background-color': 'inherit', + '& .prose': { + color: theme.palette.text.secondary, + '& h3': { color: theme.palette.text.primary, }, - '& .asyncapi__additional-properties-notice': { - color: theme.palette.text.hint, }, - '& .asyncapi__code, .asyncapi__code-pre': { - background: theme.palette.background.default, + '& .bg-gray-100, .bg-gray-200': { + backgroundColor: theme.palette.background.default, }, - '& .asyncapi__schema-example-header-title': { - color: theme.palette.text.secondary, + '& .text-gray-600': { + color: theme.palette.grey['50'], }, - '& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': - { - 'background-color': 'inherit', - color: theme.palette.text.secondary, + '& .text-gray-700': { + color: theme.palette.grey['100'], + }, + '& .panel--right': { + background: darken(theme.palette.navigation.background, 0.1), + }, + '& .examples': { + backgroundColor: darken(theme.palette.navigation.background, 0.1), + '& pre': { + backgroundColor: darken(theme.palette.background.default, 0.2), }, - '& .asyncapi__table-header': { - background: theme.palette.background.default, - }, - '& .asyncapi__table-body': { - color: theme.palette.text.primary, - }, - '& .asyncapi__server-security-flow': { - background: theme.palette.background.default, - border: 'none', - }, - '& .asyncapi__server-security-flows-list a': { - color: theme.palette.primary.main, - }, - '& .asyncapi__table-row--nested': { - color: theme.palette.text.secondary, }, }, })); @@ -134,11 +146,15 @@ type Props = { definition: string; }; -export const AsyncApiDefinition = ({ definition }: Props) => { +export const AsyncApiDefinition = ({ definition }: Props): JSX.Element => { const classes = useStyles(); + const theme = useTheme(); + const classNames = `${classes.root} ${ + theme.palette.type === 'dark' ? classes.dark : '' + }`; return ( -
+
); diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx index 8d0accb254..601352f21a 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx @@ -22,18 +22,9 @@ import 'swagger-ui-react/swagger-ui.css'; const useStyles = makeStyles(theme => ({ root: { '& .swagger-ui': { - fontFamily: 'inherit', + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, - [`& .info h1, - .info h2, - .info h3, - .info h4, - .info h5, - .info h6`]: { - fontFamily: 'inherit', - color: theme.palette.text.primary, - }, [`& .scheme-container`]: { backgroundColor: theme.palette.background.default, }, @@ -41,7 +32,7 @@ const useStyles = makeStyles(theme => ({ .opblock-tag small, table thead tr td, table thead tr th`]: { - fontFamily: 'inherit', + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, borderColor: theme.palette.divider, }, @@ -49,35 +40,30 @@ const useStyles = makeStyles(theme => ({ section.models.is-open h4`]: { borderColor: theme.palette.divider, }, - [`& .opblock .opblock-summary-description, - .parameter__type, - table.headers td, - .model-title, - .model .property.primitive, - section h3`]: { - fontFamily: 'inherit', - color: theme.palette.text.secondary, + [`& .model-title, + .model .renderedMarkdown, + .model .description`]: { + fontFamily: theme.typography.fontFamily, + fontWeight: theme.typography.fontWeightRegular, }, - [`& .opblock .opblock-summary-operation-id, - .opblock .opblock-summary-path, - .opblock .opblock-summary-path__deprecated, - .opblock h4, - .opblock h5, - .opblock a, - .opblock li, + [`& h1, h2, h3, h4, h5, h6, + .errors h4, .error h4, .opblock h4, section.models h4, + .response-control-media-type__accept-message, + .opblock-summary-description, + .opblock-summary-operation-id, + .opblock-summary-path, + .opblock-summary-path__deprecated, + .opblock-external-docs-wrapper, + .opblock-section-header .btn, + .opblock-section-header>label, + .scheme-container .schemes>label,a.nostyle, .parameter__name, .response-col_status, .response-col_links, - .opblock-section-header .btn, - .tab li, - .info li, - .info p, - .info table, - section.models h4, + .error .btn, .info .title, - table.model tr.description, - .property-row`]: { - fontFamily: 'inherit', + .info .base-url`]: { + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, }, [`& .opblock .opblock-section-header, @@ -89,23 +75,57 @@ const useStyles = makeStyles(theme => ({ .parameter__in`]: { color: theme.palette.text.disabled, }, - [`& .opblock-description-wrapper p, - .opblock-description-wrapper li, - .opblock-external-docs-wrapper p, - .opblock-title_normal p, - .response-control-media-type__accept-message, - .opblock .opblock-section-header>label, - .scheme-container .schemes>label, - .info .base-url, - .model`]: { - color: theme.palette.text.hint, + [`& table.model, + .parameter__type, + .model.model-title, + .model-title, + .model span, + .model .brace-open, + .model .brace-close, + .model .property.primitive, + .model .renderedMarkdown, + .model .description, + .errors small`]: { + color: theme.palette.text.secondary, }, [`& .parameter__name.required:after`]: { color: theme.palette.warning.dark, }, - [`& .prop-type`]: { + [`& table.model, + table.model .model, + .opblock-external-docs-wrapper`]: { + fontSize: theme.typography.fontSize, + }, + [`& table.headers td`]: { + color: theme.palette.text.primary, + fontWeight: theme.typography.fontWeightRegular, + }, + [`& .model-hint`]: { + color: theme.palette.text.hint, + backgroundColor: theme.palette.background.paper, + }, + [`& .opblock-summary-method, + .info a`]: { + fontFamily: theme.typography.fontFamily, + }, + [`& .info, .opblock, .tab`]: { + [`& li, p`]: { + fontFamily: theme.typography.fontFamily, + color: theme.palette.text.primary, + }, + }, + [`& a`]: { color: theme.palette.primary.main, }, + [`& .renderedMarkdown code`]: { + color: theme.palette.secondary.light, + }, + [`& .property-row td:first-child`]: { + color: theme.palette.text.primary, + }, + [`& span.prop-type`]: { + color: theme.palette.success.light, + }, }, }, })); diff --git a/plugins/api-docs/src/config.ts b/plugins/api-docs/src/config.ts index e035bcfbd1..979340164b 100644 --- a/plugins/api-docs/src/config.ts +++ b/plugins/api-docs/src/config.ts @@ -20,7 +20,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const apiDocsConfigRef = createApiRef({ id: 'plugin.api-docs.config', - description: 'Used to configure api-docs widgets', }); export interface ApiDocsConfig { diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index ebb0393192..927eae7022 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", - "@backstage/config-loader": "^0.8.0", + "@backstage/backend-common": "^0.9.12", + "@backstage/config-loader": "^0.8.1", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@backstage/types": "^0.1.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 38eaf268e6..cfdae9f4ef 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,47 @@ # @backstage/plugin-auth-backend +## 0.5.1 + +### Patch Changes + +- 699c2e9ddc: export minimal typescript types for OIDC provider +- Updated dependencies + - @backstage/backend-common@0.9.14 + - @backstage/catalog-model@0.9.8 + +## 0.5.0 + +### Minor Changes + +- a036b65c2f: **BREAKING CHANGE** The `idToken` field of `BackstageIdentity` has been removed, with the `token` taking its place. This means you may need to update existing `signIn.resolver` implementations to return an `token` rather than an `idToken`. This also applies to custom auth providers. + + The `BackstageIdentity` type has been deprecated and will be removed in the future. Taking its place is the new `BackstageSignInResult` type with the same shape. + + This change also introduces the new `BackstageIdentityResponse` that mirrors the type with the same name from `@backstage/core-plugin-api`. The `BackstageIdentityResponse` type is different from the `BackstageSignInResult` in that it also has a `identity` field which is of type `BackstageUserIdentity` and is a decoded version of the information within the token. + + When implementing a custom auth provider that is not based on the `OAuthAdapter` you may need to convert `BackstageSignInResult` into a `BackstageIdentityResponse`, this can be done using the new `prepareBackstageIdentityResponse` function. + +### Patch Changes + +- 8f461e6043: Fixes potential bug introduced in `0.4.10` which causes `OAuth2AuthProvider` to authenticate using credentials in both POST payload and headers. + This might break some stricter OAuth2 implementations so there is now a `includeBasicAuth` config option that can manually be set to `true` to enable this behavior. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/test-utils@0.1.24 + - @backstage/backend-common@0.9.13 + +## 0.4.10 + +### Patch Changes + +- 4bf4111902: Migrated the SAML provider to implement the `authHandler` and `signIn.resolver` options. +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- 36fa32216f: Added signIn and authHandler resolver for oidc provider +- 7071dce02d: Expose catalog lib in plugin-auth-backend, i.e `CatalogIdentityClient` class is exposed now. +- 1b69ed44f2: Added custom OAuth2.0 authorization header for generic oauth2 provider. +- Updated dependencies + - @backstage/backend-common@0.9.12 + ## 0.4.9 ### Patch Changes diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index eb7c53bd6b..9ed523c678 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -14,7 +14,9 @@ import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; +import { TokenSet } from 'openid-client'; import { UserEntity } from '@backstage/catalog-model'; +import { UserinfoResponse } from 'openid-client'; // Warning: (ae-missing-release-tag) "AtlassianAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -45,6 +47,16 @@ export type AtlassianProviderOptions = { }; }; +// @public +export type AuthHandler = ( + input: AuthResult, +) => Promise; + +// @public +export type AuthHandlerResult = { + profile: ProfileInfo; +}; + // Warning: (ae-missing-release-tag) "AuthProviderFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -101,7 +113,7 @@ export interface AuthProviderRouteHandlers { export type AuthResponse = { providerInfo: ProviderInfo; profile: ProfileInfo; - backstageIdentity?: BackstageIdentity; + backstageIdentity?: BackstageIdentityResponse; }; // Warning: (ae-missing-release-tag) "AwsAlbProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -114,14 +126,28 @@ export type AwsAlbProviderOptions = { }; }; -// Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type BackstageIdentity = { - id: string; - idToken?: string; - token?: string; +// @public @deprecated +export type BackstageIdentity = BackstageSignInResult; + +// @public +export interface BackstageIdentityResponse extends BackstageSignInResult { + identity: BackstageUserIdentity; +} + +// @public +export interface BackstageSignInResult { + // @deprecated entity?: Entity; + // @deprecated + id: string; + token: string; +} + +// @public +export type BackstageUserIdentity = { + type: 'user'; + userEntityRef: string; + ownershipEntityRefs: string[]; }; // Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -183,10 +209,7 @@ export class CatalogIdentityClient { // Warning: (ae-forgotten-export) The symbol "UserQuery" needs to be exported by the entry point index.d.ts findUser(query: UserQuery): Promise; // Warning: (ae-forgotten-export) The symbol "MemberClaimQuery" needs to be exported by the entry point index.d.ts - resolveCatalogMembership({ - entityRefs, - logger, - }: MemberClaimQuery): Promise; + resolveCatalogMembership(query: MemberClaimQuery): Promise; } // Warning: (ae-missing-release-tag) "createAtlassianProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -245,6 +268,13 @@ export const createOAuth2Provider: ( options?: OAuth2ProviderOptions | undefined, ) => AuthProviderFactory; +// Warning: (ae-missing-release-tag) "createOidcProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createOidcProvider: ( + options?: OidcProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createOktaProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -260,13 +290,12 @@ export function createOriginFilter(config: Config): (origin: string) => boolean; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function createRouter({ - logger, - config, - discovery, - database, - providerFactories, -}: RouterOptions): Promise; +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export const createSamlProvider: ( + options?: SamlProviderOptions | undefined, +) => AuthProviderFactory; // Warning: (ae-missing-release-tag) "factories" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -346,7 +375,7 @@ export type GoogleProviderOptions = { // @public export class IdentityClient { constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }); - authenticate(token: string | undefined): Promise; + authenticate(token: string | undefined): Promise; static getBearerToken( authorizationHeader: string | undefined, ): string | undefined; @@ -433,7 +462,7 @@ export interface OAuthHandlers { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' handler(req: express.Request): Promise<{ - response: AuthResponse; + response: OAuthResponse; refreshToken?: string; }>; logout?(): Promise; @@ -441,7 +470,7 @@ export interface OAuthHandlers { // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - refresh?(req: OAuthRefreshRequest): Promise>; + refresh?(req: OAuthRefreshRequest): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -476,10 +505,12 @@ export type OAuthRefreshRequest = express.Request<{}> & { refreshToken: string; }; -// Warning: (ae-missing-release-tag) "OAuthResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type OAuthResponse = AuthResponse; +// @public +export type OAuthResponse = { + profile: ProfileInfo; + providerInfo: OAuthProviderInfo; + backstageIdentity?: BackstageSignInResult; +}; // Warning: (ae-missing-release-tag) "OAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -512,6 +543,20 @@ export type OAuthState = { origin?: string; }; +// @public +export type OidcAuthResult = { + tokenset: TokenSet; + userinfo: UserinfoResponse; +}; + +// @public +export type OidcProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver?: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "oktaEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -536,8 +581,11 @@ export const postMessageResponse: ( response: WebMessageResponse, ) => void; -// Warning: (ae-missing-release-tag) "ProfileInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export function prepareBackstageIdentityResponse( + result: BackstageSignInResult, +): BackstageIdentityResponse; + // @public export type ProfileInfo = { email?: string; @@ -568,6 +616,35 @@ export interface RouterOptions { providerFactories?: ProviderFactories; } +// @public (undocumented) +export type SamlAuthResult = { + fullProfile: any; +}; + +// @public (undocumented) +export type SamlProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver?: SignInResolver; + }; +}; + +// @public +export type SignInInfo = { + profile: ProfileInfo; + result: AuthResult; +}; + +// @public +export type SignInResolver = ( + info: SignInInfo, + context: { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger_2; + }, +) => Promise; + // Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -599,8 +676,6 @@ export type WebMessageResponse = // Warnings were encountered during analysis: // // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts -// src/providers/atlassian/provider.d.ts:37:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts -// src/providers/atlassian/provider.d.ts:42:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts // src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts // src/providers/github/provider.d.ts:71:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag // src/providers/github/provider.d.ts:71:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag @@ -609,5 +684,4 @@ export type WebMessageResponse = // src/providers/github/provider.d.ts:71:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // src/providers/github/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:100:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:122:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative ``` diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index fecabf8ad1..c7d208eef2 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-auth-backend", "description": "A Backstage backend plugin that handles authentication", - "version": "0.4.9", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/catalog-client": "^0.5.2", - "@backstage/catalog-model": "^0.9.7", + "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", - "@backstage/test-utils": "^0.1.23", + "@backstage/test-utils": "^0.1.24", "@google-cloud/firestore": "^4.15.1", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", @@ -73,7 +73,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.2", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts index 5eca6bf8cd..9f5e1ce489 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -96,7 +96,15 @@ describe('IdentityClient', () => { it('should accept fresh token', async () => { const token = await factory.issueToken({ claims: { sub: 'foo' } }); const response = await client.authenticate(token); - expect(response).toEqual({ id: 'foo', idToken: token }); + expect(response).toEqual({ + id: 'foo', + token: token, + identity: { + ownershipEntityRefs: [], + type: 'user', + userEntityRef: 'foo', + }, + }); }); it('should throw on incorrect issuer', async () => { @@ -159,7 +167,15 @@ describe('IdentityClient', () => { jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); const token = await factory.issueToken({ claims: { sub: 'foo' } }); const response = await client.authenticate(token); - expect(response).toEqual({ id: 'foo', idToken: token }); + expect(response).toEqual({ + id: 'foo', + token: token, + identity: { + ownershipEntityRefs: [], + type: 'user', + userEntityRef: 'foo', + }, + }); }); it('should not be fooled by the none algorithm', async () => { diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts index d60829bf59..552d231e1f 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -16,8 +16,9 @@ import fetch from 'node-fetch'; import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; -import { BackstageIdentity } from '../providers'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { AuthenticationError } from '@backstage/errors'; +import { BackstageIdentityResponse } from '../providers/types'; const CLOCK_MARGIN_S = 10; @@ -45,15 +46,17 @@ export class IdentityClient { * Returns a BackstageIdentity (user) matching the token. * The method throws an error if verification fails. */ - async authenticate(token: string | undefined): Promise { + async authenticate( + token: string | undefined, + ): Promise { // Extract token from header if (!token) { - throw new Error('No token specified'); + throw new AuthenticationError('No token specified'); } // Get signing key matching token const key = await this.getKey(token); if (!key) { - throw new Error('No signing key matching token found'); + throw new AuthenticationError('No signing key matching token found'); } // Verify token claims and signature // Note: Claims must match those set by TokenFactory when issuing tokens @@ -62,12 +65,21 @@ export class IdentityClient { algorithms: ['ES256'], audience: 'backstage', issuer: this.issuer, - }) as { sub: string }; + }) as { sub: string; ent: string[] }; // Verified, return the matching user as BackstageIdentity // TODO: Settle internal user format/properties - const user: BackstageIdentity = { + if (!decoded.sub) { + throw new AuthenticationError('No user sub found in token'); + } + + const user: BackstageIdentityResponse = { id: decoded.sub, - idToken: token, + token, + identity: { + type: 'user', + userEntityRef: decoded.sub, + ownershipEntityRefs: decoded.ent ?? [], + }, }; return user; } diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 894f8c1e55..66c1b42dd9 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -29,7 +29,7 @@ export * from './providers'; // ensuresXRequestedWith and postMessageResponse to safely handle CORS requests for login. The WebMessageResponse type in flow is used to type the response from the login-popup export * from './lib/flow'; -// OAuth wrapper over a passport or a custom `startegy`. +// OAuth wrapper over a passport or a custom `strategy`. export * from './lib/oauth'; export * from './lib/catalog'; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 02fe9818cb..a1f915d152 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -84,10 +84,8 @@ export class CatalogIdentityClient { * * Returns a superset of the entity names that can be passed directly to `issueToken` as `ent`. */ - async resolveCatalogMembership({ - entityRefs, - logger, - }: MemberClaimQuery): Promise { + async resolveCatalogMembership(query: MemberClaimQuery): Promise { + const { entityRefs, logger } = query; const resolvedEntityRefs = entityRefs .map((ref: string) => { try { diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts index 117f1b86e4..07c8196dd2 100644 --- a/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts @@ -51,7 +51,12 @@ describe('oauth helpers', () => { }, backstageIdentity: { id: 'a', - idToken: 'a.b.c', + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, }, }, }; @@ -106,7 +111,12 @@ describe('oauth helpers', () => { }, backstageIdentity: { id: 'a', - idToken: 'a.b.c', + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, }, }, }; @@ -148,7 +158,12 @@ describe('oauth helpers', () => { }, backstageIdentity: { id: 'a', - idToken: 'a.b.c', + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, }, }, }; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index fa61d69d80..a3fc77bc02 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -17,7 +17,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; -import { OAuthHandlers } from './types'; +import { OAuthHandlers, OAuthResponse } from './types'; const mockResponseData = { providerInfo: { @@ -31,9 +31,17 @@ const mockResponseData = { }, backstageIdentity: { id: 'foo', + token: + 'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob', }, }; +function mkTokenBody(payload: unknown): string { + return Buffer.from(JSON.stringify(payload), 'utf8') + .toString('base64') + .replace(/=/g, ''); +} + describe('OAuthAdapter', () => { class MyAuthProvider implements OAuthHandlers { async start() { @@ -217,7 +225,13 @@ describe('OAuthAdapter', () => { ...mockResponseData, backstageIdentity: { id: mockResponseData.backstageIdentity.id, - token: 'my-id-token', + token: mockResponseData.backstageIdentity.token, + idToken: mockResponseData.backstageIdentity.token, + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'jimmymarkum', + }, }, }); }); @@ -241,4 +255,86 @@ describe('OAuthAdapter', () => { 'Refresh token is not supported for provider test-provider', ); }); + + it('correctly populates incomplete identities', async () => { + const mockRefresh = jest.fn, [express.Request]>(); + + const oauthProvider = new OAuthAdapter( + { + refresh: mockRefresh, + start: jest.fn(), + handler: jest.fn(), + } as OAuthHandlers, + { + ...oAuthProviderOptions, + tokenIssuer: { + issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`, + listPublicKeys: async () => ({ keys: [] }), + }, + disableRefresh: false, + isOriginAllowed: () => false, + }, + ); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'token', + }, + query: {}, + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + // Without a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: '', + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(1); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + identity: { + type: 'user', + userEntityRef: 'user:default/foo', + ownershipEntityRefs: [], + }, + }, + }); + + // With a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(2); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + identity: { + type: 'user', + userEntityRef: 'user:my-ns/foo', + ownershipEntityRefs: [], + }, + }, + }); + }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index e1a6fdf2f9..4d5d507aa1 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -17,10 +17,16 @@ import express from 'express'; import crypto from 'crypto'; import { URL } from 'url'; +import { + ENTITY_DEFAULT_NAMESPACE, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { AuthProviderRouteHandlers, - BackstageIdentity, AuthProviderConfig, + BackstageIdentityResponse, + BackstageSignInResult, } from '../../providers/types'; import { AuthenticationError, @@ -37,6 +43,7 @@ import { OAuthRefreshRequest, OAuthState, } from './types'; +import { prepareBackstageIdentityResponse } from '../../providers/prepareBackstageIdentityResponse'; export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; export const TEN_MINUTES_MS = 600 * 1000; @@ -150,12 +157,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { this.setRefreshTokenCookie(res, refreshToken); } - await this.populateIdentity(response.backstageIdentity); + const identity = await this.populateIdentity(response.backstageIdentity); // post message back to popup if successful return postMessageResponse(res, appOrigin, { type: 'authorization_response', - response, + response: { ...response, backstageIdentity: identity }, }); } catch (error) { const { name, message } = isError(error) @@ -209,7 +216,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { forwardReq as OAuthRefreshRequest, ); - await this.populateIdentity(response.backstageIdentity); + const backstageIdentity = await this.populateIdentity( + response.backstageIdentity, + ); if ( response.providerInfo.refreshToken && @@ -218,7 +227,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { this.setRefreshTokenCookie(res, response.providerInfo.refreshToken); } - res.status(200).json(response); + res.status(200).json({ ...response, backstageIdentity }); } catch (error) { throw new AuthenticationError('Refresh failed', error); } @@ -228,18 +237,28 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { * If the response from the OAuth provider includes a Backstage identity, we * make sure it's populated with all the information we can derive from the user ID. */ - private async populateIdentity(identity?: BackstageIdentity) { + private async populateIdentity( + identity?: BackstageSignInResult, + ): Promise { if (!identity) { - return; + return undefined; } - if (!(identity.token || identity.idToken)) { - identity.token = await this.options.tokenIssuer.issueToken({ - claims: { sub: identity.id }, - }); - } else if (!identity.token && identity.idToken) { - identity.token = identity.idToken; + if (identity.token) { + return prepareBackstageIdentityResponse(identity); } + + const userEntityRef = stringifyEntityRef( + parseEntityRef(identity.id, { + defaultKind: 'user', + defaultNamespace: ENTITY_DEFAULT_NAMESPACE, + }), + ); + const token = await this.options.tokenIssuer.issueToken({ + claims: { sub: userEntityRef }, + }); + + return prepareBackstageIdentityResponse({ ...identity, token }); } private setNonceCookie = (res: express.Response, nonce: string) => { diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index 7912dd16a5..cd1439b399 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -16,7 +16,11 @@ import express from 'express'; import { Profile as PassportProfile } from 'passport'; -import { AuthResponse, RedirectInfo } from '../../providers/types'; +import { + RedirectInfo, + BackstageSignInResult, + ProfileInfo, +} from '../../providers/types'; /** * Common options for passport.js-based OAuth providers @@ -47,7 +51,16 @@ export type OAuthResult = { refreshToken?: string; }; -export type OAuthResponse = AuthResponse; +/** + * The expected response from an OAuth flow. + * + * @public + */ +export type OAuthResponse = { + profile: ProfileInfo; + providerInfo: OAuthProviderInfo; + backstageIdentity?: BackstageSignInResult; +}; export type OAuthProviderInfo = { /** @@ -108,7 +121,7 @@ export interface OAuthHandlers { * @param {express.Request} req */ handler(req: express.Request): Promise<{ - response: AuthResponse; + response: OAuthResponse; refreshToken?: string; }>; @@ -117,7 +130,7 @@ export interface OAuthHandlers { * @param {string} refreshToken * @param {string} scope */ - refresh?(req: OAuthRefreshRequest): Promise>; + refresh?(req: OAuthRefreshRequest): Promise; /** * (Optional) Sign out of the auth provider. diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index 4cc3f33499..7aa98c3e65 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -151,7 +151,7 @@ export class Auth0AuthProvider implements OAuthHandlers { const id = profile.email.split('@')[0]; - return { ...response, backstageIdentity: { id } }; + return { ...response, backstageIdentity: { id, token: '' } }; } } diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index 3f72004d48..048128f942 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -122,7 +122,11 @@ describe('AwsAlbAuthProvider', () => { profile: makeProfileInfo(fullProfile), }), signInResolver: async () => { - return { id: 'user.name', token: 'TOKEN' }; + return { + id: 'user.name', + token: + 'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob', + }; }, }); @@ -133,7 +137,15 @@ describe('AwsAlbAuthProvider', () => { expect(mockResponse.json).toHaveBeenCalledWith({ backstageIdentity: { id: 'user.name', - token: 'TOKEN', + token: + 'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob', + idToken: + 'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob', + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'jimmymarkum', + }, }, profile: { displayName: 'User Name', diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index b0b9070d50..114bc9a204 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -32,6 +32,7 @@ import { CatalogIdentityClient } from '../../lib/catalog'; import { Profile as PassportProfile } from 'passport'; import { makeProfileInfo } from '../../lib/passport'; import { AuthenticationError } from '@backstage/errors'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; export const ALB_JWT_HEADER = 'x-amzn-oidc-data'; export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken'; @@ -198,7 +199,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { accessToken: result.accessToken, expiresInSeconds: result.expiresInSeconds, }, - backstageIdentity, + backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity), profile, }; } diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 4a9221ce21..9589e97265 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -19,10 +19,12 @@ export * from './gitlab'; export * from './google'; export * from './microsoft'; export * from './oauth2'; +export * from './oidc'; export * from './okta'; export * from './bitbucket'; export * from './atlassian'; export * from './aws-alb'; +export * from './saml'; export { factories as defaultAuthProviderFactories } from './factories'; @@ -32,8 +34,21 @@ export type { AuthProviderRouteHandlers, AuthProviderFactoryOptions, AuthProviderFactory, + AuthHandler, + AuthHandlerResult, + SignInResolver, + SignInInfo, } from './types'; // These types are needed for a postMessage from the login pop-up // to the frontend -export type { AuthResponse, BackstageIdentity, ProfileInfo } from './types'; +export type { + AuthResponse, + BackstageIdentity, + BackstageUserIdentity, + BackstageIdentityResponse, + BackstageSignInResult, + ProfileInfo, +} from './types'; + +export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse'; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index cf70580664..2f9c739860 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -59,6 +59,7 @@ export type OAuth2AuthProviderOptions = OAuthProviderOptions & { tokenUrl: string; scope?: string; logger: Logger; + includeBasicAuth?: boolean; }; export class OAuth2AuthProvider implements OAuthHandlers { @@ -85,6 +86,14 @@ export class OAuth2AuthProvider implements OAuthHandlers { tokenURL: options.tokenUrl, passReqToCallback: false as true, scope: options.scope, + customHeaders: options.includeBasicAuth + ? { + Authorization: `Basic ${this.encodeClientCredentials( + options.clientId, + options.clientSecret, + )}`, + } + : undefined, }, ( accessToken: any, @@ -187,6 +196,10 @@ export class OAuth2AuthProvider implements OAuthHandlers { return response; } + + encodeClientCredentials(clientID: string, clientSecret: string): string { + return Buffer.from(`${clientID}:${clientSecret}`).toString('base64'); + } } export const oAuth2DefaultSignInResolver: SignInResolver = async ( @@ -234,6 +247,7 @@ export const createOAuth2Provider = ( const authorizationUrl = envConfig.getString('authorizationUrl'); const tokenUrl = envConfig.getString('tokenUrl'); const scope = envConfig.getOptionalString('scope'); + const includeBasicAuth = envConfig.getOptionalBoolean('includeBasicAuth'); const disableRefresh = envConfig.getOptionalBoolean('disableRefresh') ?? false; @@ -270,6 +284,7 @@ export const createOAuth2Provider = ( tokenUrl, scope, logger, + includeBasicAuth, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index 8c755cd501..39bd02928d 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -13,6 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +export type { OidcAuthResult, OidcProviderOptions } from './provider'; export { createOidcProvider } from './provider'; -export type { OidcProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index ac5035e9a5..da96ae1a25 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -24,7 +24,8 @@ import { setupServer } from 'msw/node'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; import { OAuthAdapter } from '../../lib/oauth'; import { AuthProviderFactoryOptions } from '../types'; -import { createOidcProvider, OidcAuthProvider } from './provider'; +import { createOidcProvider, OidcAuthProvider, Options } from './provider'; +import { getVoidLogger } from '@backstage/backend-common'; const issuerMetadata = { issuer: 'https://oidc.test', @@ -42,7 +43,23 @@ const issuerMetadata = { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; -const clientMetadata = { +const catalogIdentityClient = { + findUser: jest.fn(), +}; +const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), +}; + +const clientMetadata: Options = { + authHandler: async input => ({ + profile: { + displayName: input.userinfo.email, + }, + }), + catalogIdentityClient: catalogIdentityClient as unknown as any, + logger: getVoidLogger(), + tokenIssuer: tokenIssuer as unknown as any, callbackUrl: 'https://oidc.test/callback', clientId: 'testclientid', clientSecret: 'testclientsecret', @@ -160,7 +177,7 @@ describe('OidcAuthProvider', () => { ...clientMetadata, metadataUrl: 'https://oidc.test/.well-known/openid-configuration', }, - }); + } as any); const options = { globalConfig: { appUrl: 'https://oidc.test', diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 092ffa7099..fe4c042500 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -16,28 +16,36 @@ import express from 'express'; import { - Issuer, Client, + Issuer, Strategy as OidcStrategy, TokenSet, UserinfoResponse, } from 'openid-client'; import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, OAuthRefreshRequest, + OAuthResponse, + OAuthStartRequest, } from '../../lib/oauth'; import { executeFrameHandlerStrategy, executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + AuthHandler, + AuthProviderFactory, + RedirectInfo, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken?: string; @@ -48,7 +56,11 @@ type OidcImpl = { client: Client; }; -type AuthResult = { +/** + * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) + * @public + */ +export type OidcAuthResult = { tokenset: TokenSet; userinfo: UserinfoResponse; }; @@ -58,6 +70,11 @@ export type Options = OAuthProviderOptions & { scope?: string; prompt?: string; tokenSignedResponseAlg?: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class OidcAuthProvider implements OAuthHandlers { @@ -65,16 +82,26 @@ export class OidcAuthProvider implements OAuthHandlers { private readonly scope?: string; private readonly prompt?: string; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + constructor(options: Options) { this.implementation = this.setupStrategy(options); this.scope = options.scope; this.prompt = options.prompt; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; } async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; const options: Record = { - accessType: 'offline', scope: req.scope || this.scope || 'openid profile email', state: encodeState(req.state), }; @@ -90,26 +117,15 @@ export class OidcAuthProvider implements OAuthHandlers { ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; const strategyResponse = await executeFrameHandlerStrategy< - AuthResult, + OidcAuthResult, PrivateInfo >(req, strategy); const { result: { userinfo, tokenset }, privateInfo, } = strategyResponse; - const identityResponse = await this.populateIdentity({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - providerInfo: { - idToken: tokenset.id_token, - accessToken: tokenset.access_token || '', - scope: tokenset.scope || '', - expiresInSeconds: tokenset.expires_in, - }, - }); + + const identityResponse = await this.handleResult({ tokenset, userinfo }); return { response: identityResponse, refreshToken: privateInfo.refreshToken, @@ -123,22 +139,13 @@ export class OidcAuthProvider implements OAuthHandlers { 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, - }); + return this.handleResult({ tokenset, userinfo: profile }); } private async setupStrategy(options: Options): Promise { const issuer = await Issuer.discover(options.metadataUrl); const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token client_id: options.clientId, client_secret: options.clientSecret, redirect_uris: [options.callbackUrl], @@ -155,7 +162,7 @@ export class OidcAuthProvider implements OAuthHandlers { ( tokenset: TokenSet, userinfo: UserinfoResponse, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { if (typeof done !== 'function') { throw new Error( @@ -177,26 +184,81 @@ export class OidcAuthProvider implements OAuthHandlers { // 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'); + private async handleResult(result: OidcAuthResult): Promise { + const { profile } = await this.authHandler(result); + const response: OAuthResponse = { + providerInfo: { + idToken: result.tokenset.id_token, + accessToken: result.tokenset.access_token!, + refreshToken: result.tokenset.refresh_token, + scope: result.tokenset.scope!, + expiresInSeconds: result.tokenset.expires_in, + }, + profile, + }; + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); } - const id = profile.email.split('@')[0]; - return { ...response, backstageIdentity: { id } }; + return response; } } -export type OidcProviderOptions = {}; +export const oAuth2DefaultSignInResolver: SignInResolver = + async (info, ctx) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Profile contained no email'); + } + const userId = profile.email.split('@')[0]; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + return { id: userId, token }; + }; + +/** + * OIDC provider callback options. An auth handler and a sign in resolver + * can be passed while creating a OIDC provider. + * + * authHandler : called after sign in was successful, a new object must be returned which includes a profile + * signInResolver: called after sign in was successful, expects to return a new {@link BackstageSignInResult} + * + * Both options are optional. There is fallback for authHandler where the default handler expect an e-mail explicitly + * otherwise it throws an error + * + * @public + */ +export type OidcProviderOptions = { + authHandler?: AuthHandler; + + signIn?: { + resolver?: SignInResolver; + }; +}; export const createOidcProvider = ( - _options?: OidcProviderOptions, + options?: OidcProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -207,6 +269,28 @@ export const createOidcProvider = ( ); const scope = envConfig.getOptionalString('scope'); const prompt = envConfig.getOptionalString('prompt'); + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ userinfo }) => ({ + profile: { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }, + }); + const signInResolverFn = + options?.signIn?.resolver ?? oAuth2DefaultSignInResolver; + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); const provider = new OidcAuthProvider({ clientId, @@ -216,6 +300,11 @@ export const createOidcProvider = ( metadataUrl, scope, prompt, + signInResolver, + authHandler, + logger, + tokenIssuer, + catalogIdentityClient, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts index 092d22189d..66e8b0bfc5 100644 --- a/plugins/auth-backend/src/providers/onelogin/provider.ts +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -148,7 +148,7 @@ export class OneLoginProvider implements OAuthHandlers { const id = profile.email.split('@')[0]; - return { ...response, backstageIdentity: { id } }; + return { ...response, backstageIdentity: { id, token: '' } }; } } diff --git a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts new file mode 100644 index 0000000000..31df7a4cef --- /dev/null +++ b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BackstageIdentityResponse, BackstageSignInResult } from './types'; + +function parseJwtPayload(token: string) { + const [_header, payload, _signature] = token.split('.'); + return JSON.parse(Buffer.from(payload, 'base64').toString()); +} + +/** + * Parses token and decorates the BackstageIdentityResponse with identity information sourced from the token + * + * @public + */ +export function prepareBackstageIdentityResponse( + result: BackstageSignInResult, +): BackstageIdentityResponse { + const { sub, ent } = parseJwtPayload(result.token); + return { + ...{ + // TODO: idToken is for backwards compatibility and can be removed in the future + idToken: result.token, + ...result, + }, + identity: { + type: 'user', + userEntityRef: sub, + ownershipEntityRefs: ent ?? [], + }, + }; +} diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts index fa5d9d9a82..0aea660941 100644 --- a/plugins/auth-backend/src/providers/saml/index.ts +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -15,4 +15,4 @@ */ export { createSamlProvider } from './provider'; -export type { SamlProviderOptions } from './provider'; +export type { SamlProviderOptions, SamlAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 8929a1dbf0..8a4afd1fa6 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -26,31 +26,53 @@ import { executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; +import { + AuthProviderRouteHandlers, + AuthProviderFactory, + AuthHandler, + SignInResolver, + AuthResponse, +} from '../types'; import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity/types'; import { isError } from '@backstage/errors'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { Logger } from 'winston'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; -type SamlInfo = { +/** @public */ +export type SamlAuthResult = { fullProfile: any; }; type Options = SamlConfig & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; appUrl: string; }; export class SamlAuthProvider implements AuthProviderRouteHandlers { private readonly strategy: SamlStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; private readonly appUrl: string; constructor(options: Options) { this.appUrl = options.appUrl; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this.strategy = new SamlStrategy({ ...options }, (( fullProfile: SamlProfile, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { // TODO: There's plenty more validation and profile handling to do here, // this provider is currently only intended to validate the provider pattern @@ -71,27 +93,38 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res: express.Response, ): Promise { try { - const { result } = await executeFrameHandlerStrategy( + const { result } = await executeFrameHandlerStrategy( req, this.strategy, ); - const id = result.fullProfile.nameID; + const { profile } = await this.authHandler(result); - const idToken = await this.tokenIssuer.issueToken({ - claims: { sub: id }, - }); + const response: AuthResponse<{}> = { + profile, + providerInfo: {}, + }; + + if (this.signInResolver) { + const signInResponse = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + + response.backstageIdentity = + prepareBackstageIdentityResponse(signInResponse); + } return postMessageResponse(res, this.appUrl, { type: 'authorization_response', - response: { - profile: { - email: result.fullProfile.email, - displayName: result.fullProfile.displayName, - }, - providerInfo: {}, - backstageIdentity: { id, idToken }, - }, + response, }); } catch (error) { const { name, message } = isError(error) @@ -105,30 +138,88 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } async logout(_req: express.Request, res: express.Response): Promise { - res.send('noop'); - } - - identifyEnv(): string | undefined { - return undefined; + res.end(); } } +const samlDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const id = info.result.fullProfile.nameID; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id }, + }); + + return { id, token }; +}; + type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha512'; -export type SamlProviderOptions = {}; +/** @public */ +export type SamlProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver?: SignInResolver; + }; +}; + +/** @public */ export const createSamlProvider = ( - _options?: SamlProviderOptions, + options?: SamlProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => { - const opts = { + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => { + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: { + email: fullProfile.email, + displayName: fullProfile.displayName, + }, + }); + + const signInResolverFn = + options?.signIn?.resolver ?? samlDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + + return new SamlAuthProvider({ callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, entryPoint: config.getString('entryPoint'), logoutUrl: config.getOptionalString('logoutUrl'), audience: config.getOptionalString('audience'), issuer: config.getString('issuer'), cert: config.getString('cert'), - privateCert: config.getOptionalString('privateKey'), + privateKey: config.getOptionalString('privateKey'), authnContext: config.getOptionalStringArray('authnContext'), identifierFormat: config.getOptionalString('identifierFormat'), decryptionPvk: config.getOptionalString('decryptionPvk'), @@ -140,8 +231,10 @@ export const createSamlProvider = ( tokenIssuer, appUrl: globalConfig.appUrl, - }; - - return new SamlAuthProvider(opts); + authHandler, + signInResolver, + logger, + catalogIdentityClient, + }); }; }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 089c82b293..ecb466c768 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -137,42 +137,92 @@ export type AuthProviderFactory = ( export type AuthResponse = { providerInfo: ProviderInfo; profile: ProfileInfo; - backstageIdentity?: BackstageIdentity; + backstageIdentity?: BackstageIdentityResponse; }; -export type BackstageIdentity = { +/** + * User identity information within Backstage. + * + * @public + */ +export type BackstageUserIdentity = { + /** + * The type of identity that this structure represents. In the frontend app + * this will currently always be 'user'. + */ + type: 'user'; + + /** + * The entityRef of the user in the catalog. + * For example User:default/sandra + */ + userEntityRef: string; + + /** + * The user and group entities that the user claims ownership through + */ + ownershipEntityRefs: string[]; +}; + +/** + * A representation of a successful Backstage sign-in. + * + * Compared to the {@link BackstageIdentityResponse} this type omits + * the decoded identity information embedded in the token. + * + * @public + */ +export interface BackstageSignInResult { /** * An opaque ID that uniquely identifies the user within Backstage. * * This is typically the same as the user entity `metadata.name`. + * + * @deprecated Use the `identity` field instead */ id: string; - /** - * This is deprecated, use `token` instead. - * @deprecated - */ - idToken?: string; - - /** - * The token used to authenticate the user within Backstage. - */ - token?: string; - /** * The entity that the user is represented by within Backstage. * * This entity may or may not exist within the Catalog, and it can be used * to read and store additional metadata about the user. + * + * @deprecated Use the `identity` field instead. */ entity?: Entity; -}; + + /** + * The token used to authenticate the user within Backstage. + */ + token: string; +} + +/** + * The old exported symbol for {@link BackstageSignInResult}. + * @public + * @deprecated Use the `BackstageSignInResult` type instead. + */ +export type BackstageIdentity = BackstageSignInResult; + +/** + * Response object containing the {@link BackstageUserIdentity} and the token from the authentication provider. + * @public + */ +export interface BackstageIdentityResponse extends BackstageSignInResult { + /** + * A plaintext description of the identity that is encapsulated within the token. + */ + identity: BackstageUserIdentity; +} /** * Used to display login information to user, i.e. sidebar popup. * * It is also temporarily used as the profile of the signed-in user's Backstage * identity, but we want to replace that with data from identity and/org catalog service + * + * @public */ export type ProfileInfo = { /** @@ -190,6 +240,10 @@ export type ProfileInfo = { picture?: string; }; +/** + * type of sign in information context, includes the profile information and authentication result which contains auth. related information + * @public + */ export type SignInInfo = { /** * The simple profile passed down for use in the frontend. @@ -202,6 +256,11 @@ export type SignInInfo = { result: AuthResult; }; +/** + * Sign in resolver type describes the function which handles the result of a successful authentication + * and it must return a valid {@link BackstageSignInResult} + * @public + */ export type SignInResolver = ( info: SignInInfo, context: { @@ -209,8 +268,12 @@ export type SignInResolver = ( catalogIdentityClient: CatalogIdentityClient; logger: Logger; }, -) => Promise; +) => Promise; +/** + * The return type of authentication handler which must contain a valid profile information + * @public + */ export type AuthHandlerResult = { profile: ProfileInfo }; /** @@ -220,6 +283,8 @@ export type AuthHandlerResult = { profile: ProfileInfo }; * * Throwing an error in the function will cause the authentication to fail, making it * possible to use this function as a way to limit access to a certain group of users. + * + * @public */ export type AuthHandler = ( input: AuthResult, diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 4aa9ed8778..6a96d421da 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -44,13 +44,10 @@ export interface RouterOptions { providerFactories?: ProviderFactories; } -export async function createRouter({ - logger, - config, - discovery, - database, - providerFactories, -}: RouterOptions): Promise { +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, config, discovery, database, providerFactories } = options; const router = Router(); const appUrl = config.getString('app.baseUrl'); diff --git a/plugins/azure-devops-backend/CHANGELOG.md b/plugins/azure-devops-backend/CHANGELOG.md index 26c64b3f9d..ff802a30e1 100644 --- a/plugins/azure-devops-backend/CHANGELOG.md +++ b/plugins/azure-devops-backend/CHANGELOG.md @@ -1,5 +1,96 @@ # @backstage/plugin-azure-devops-backend +## 0.2.5 + +### Patch Changes + +- daf32e2c9b: Created some initial filters that can be used to create pull request columns: + + - All + - AssignedToUser + - AssignedToCurrentUser + - AssignedToTeam + - AssignedToTeams + - AssignedToCurrentUsersTeams + - CreatedByUser + - CreatedByCurrentUser + - CreatedByTeam + - CreatedByTeams + - CreatedByCurrentUsersTeams + + Example custom column creation: + + ```tsx + const COLUMN_CONFIGS: PullRequestColumnConfig[] = [ + { + title: 'Created by me', + filters: [{ type: FilterType.CreatedByCurrentUser }], + }, + { + title: 'Created by Backstage Core', + filters: [ + { + type: FilterType.CreatedByTeam, + teamName: 'Backstage Core', + }, + ], + }, + { + title: 'Assigned to my teams', + filters: [{ type: FilterType.AssignedToCurrentUsersTeams }], + }, + { + title: 'Other PRs', + filters: [{ type: FilterType.All }], + simplified: true, + }, + ]; + + + } + />; + ``` + +- Updated dependencies + - @backstage/backend-common@0.9.14 + - @backstage/plugin-azure-devops-common@0.1.3 + +## 0.2.4 + +### Patch Changes + +- a77526afcd: Added getting builds by definition name +- Updated dependencies + - @backstage/backend-common@0.9.13 + - @backstage/plugin-azure-devops-common@0.1.2 + +## 0.2.3 + +### Patch Changes + +- 82cd709fdb: **Backend** + + - Created new `/dashboard-pull-requests/:projectName` endpoint + - Created new `/all-teams` endpoint + - Implemented pull request policy evaluation conversion + + **Frontend** + + - Refactored `PullRequestsPage` and added new properties for `projectName` and `pollingInterval` + - Fixed spacing issue between repo link and creation date in `PullRequestCard` + - Added missing condition to `PullRequestCardPolicy` for `RequiredReviewers` + - Updated `useDashboardPullRequests` hook to implement long polling for pull requests + +- Updated dependencies + - @backstage/plugin-azure-devops-common@0.1.1 + - @backstage/backend-common@0.9.12 + ## 0.2.2 ### Patch Changes diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 1eabccc720..9574215e3e 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -4,13 +4,17 @@ ```ts import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { BuildDefinitionReference } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { BuildRun } from '@backstage/plugin-azure-devops-common'; import { Config } from '@backstage/config'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; import express from 'express'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Logger as Logger_2 } from 'winston'; import { PullRequest } from '@backstage/plugin-azure-devops-common'; import { PullRequestOptions } from '@backstage/plugin-azure-devops-common'; import { RepoBuild } from '@backstage/plugin-azure-devops-common'; +import { Team } from '@backstage/plugin-azure-devops-common'; import { WebApi } from 'azure-devops-node-api'; // Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -19,12 +23,38 @@ import { WebApi } from 'azure-devops-node-api'; export class AzureDevOpsApi { constructor(logger: Logger_2, webApi: WebApi); // (undocumented) + getAllTeams(): Promise; + // (undocumented) + getBuildDefinitions( + projectName: string, + definitionName: string, + ): Promise; + // (undocumented) getBuildList( projectName: string, repoId: string, top: number, ): Promise; // (undocumented) + getBuildRuns( + projectName: string, + top: number, + repoName?: string, + definitionName?: string, + ): Promise; + // (undocumented) + getBuilds( + projectName: string, + top: number, + repoId?: string, + definitions?: number[], + ): Promise; + // (undocumented) + getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise; + // (undocumented) getGitRepository( projectName: string, repoName: string, diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 12f62355dd..a7aec3f2d6 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops-backend", - "version": "0.2.2", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/config": "^0.1.11", - "@backstage/plugin-azure-devops-common": "^0.1.0", + "@backstage/plugin-azure-devops-common": "^0.1.3", "@types/express": "^4.17.6", "azure-devops-node-api": "^11.0.1", "express": "^4.17.1", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.2", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "msw": "^0.35.0" diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts index 2cb7d93c0b..76b5089bbc 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -29,277 +29,510 @@ import { GitPullRequest, GitRepository, } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi'; +import { + mappedBuildRun, + mappedPullRequest, + mappedRepoBuild, +} from './AzureDevOpsApi'; import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; describe('AzureDevOpsApi', () => { describe('mappedRepoBuild', () => { - it('should return RepoBuild from Build', () => { - const inputBuildDefinition: DefinitionReference = { - name: 'My Build Definition', - }; + describe('mappedRepoBuild happy path', () => { + it('should return RepoBuild from Build', () => { + const inputBuildDefinition: DefinitionReference = { + name: 'My Build Definition', + }; - const inputLinks: any = { - web: { - href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - }, - }; + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputBuild: Build = { - id: 1, - buildNumber: 'Build-1', - status: BuildStatus.Completed, - result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.932Z'), - startTime: new Date('2020-09-12T06:15:23.932Z'), - finishTime: new Date('2020-09-12T06:20:23.932Z'), - sourceBranch: 'refs/heads/develop', - sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', - definition: inputBuildDefinition, - _links: inputLinks, - requestedFor: inputIdentityRef, - }; + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: inputBuildDefinition, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; - const outputRepoBuild: RepoBuild = { - id: 1, - title: 'My Build Definition - Build-1', - link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - status: BuildStatus.Completed, - result: BuildResult.Succeeded, - queueTime: '2020-09-12T06:10:23.932Z', - startTime: '2020-09-12T06:15:23.932Z', - finishTime: '2020-09-12T06:20:23.932Z', - source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAIN\\jdoe', - }; + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'My Build Definition - Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; - expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); }); - }); - describe('mappedRepoBuild with no Build definition name', () => { - it('should return RepoBuild with only Build Number for title', () => { - const inputLinks: any = { - web: { - href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - }, - }; + describe('mappedRepoBuild with no Build definition name', () => { + it('should return RepoBuild with only Build Number for title', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputBuild: Build = { - id: 1, - buildNumber: 'Build-1', - status: BuildStatus.Completed, - result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.932Z'), - startTime: new Date('2020-09-12T06:15:23.932Z'), - finishTime: new Date('2020-09-12T06:20:23.932Z'), - sourceBranch: 'refs/heads/develop', - sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', - definition: undefined, - _links: inputLinks, - requestedFor: inputIdentityRef, - }; + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; - const outputRepoBuild: RepoBuild = { - id: 1, - title: 'Build-1', - link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - status: BuildStatus.Completed, - result: BuildResult.Succeeded, - queueTime: '2020-09-12T06:10:23.932Z', - startTime: '2020-09-12T06:15:23.932Z', - finishTime: '2020-09-12T06:20:23.932Z', - source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAIN\\jdoe', - }; + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; - expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); }); - }); - describe('mappedRepoBuild with undefined status', () => { - it('should return BuildStatus of None for status', () => { - const inputLinks: any = { - web: { - href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - }, - }; + describe('mappedRepoBuild with undefined status', () => { + it('should return BuildStatus of None for status', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputBuild: Build = { - id: 1, - buildNumber: 'Build-1', - status: undefined, - result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.932Z'), - startTime: new Date('2020-09-12T06:15:23.932Z'), - finishTime: new Date('2020-09-12T06:20:23.932Z'), - sourceBranch: 'refs/heads/develop', - sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', - definition: undefined, - _links: inputLinks, - requestedFor: inputIdentityRef, - }; + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: undefined, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; - const outputRepoBuild: RepoBuild = { - id: 1, - title: 'Build-1', - link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - status: BuildStatus.None, - result: BuildResult.Succeeded, - queueTime: '2020-09-12T06:10:23.932Z', - startTime: '2020-09-12T06:15:23.932Z', - finishTime: '2020-09-12T06:20:23.932Z', - source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAIN\\jdoe', - }; + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.None, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; - expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); }); - }); - describe('mappedRepoBuild with undefined result', () => { - it('should return BuildResult of None for result', () => { - const inputLinks: any = { - web: { - href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - }, - }; + describe('mappedRepoBuild with undefined result', () => { + it('should return BuildResult of None for result', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputBuild: Build = { - id: 1, - buildNumber: 'Build-1', - status: BuildStatus.InProgress, - result: undefined, - queueTime: new Date('2020-09-12T06:10:23.932Z'), - startTime: new Date('2020-09-12T06:15:23.932Z'), - finishTime: new Date('2020-09-12T06:20:23.932Z'), - sourceBranch: 'refs/heads/develop', - sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', - definition: undefined, - _links: inputLinks, - requestedFor: inputIdentityRef, - }; + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.InProgress, + result: undefined, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; - const outputRepoBuild: RepoBuild = { - id: 1, - title: 'Build-1', - link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', - status: BuildStatus.InProgress, - result: BuildResult.None, - queueTime: '2020-09-12T06:10:23.932Z', - startTime: '2020-09-12T06:15:23.932Z', - finishTime: '2020-09-12T06:20:23.932Z', - source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAIN\\jdoe', - }; + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; - expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); }); - }); - describe('mappedRepoBuild with undefined link', () => { - it('should return empty string for link', () => { - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + describe('mappedRepoBuild with undefined link', () => { + it('should return empty string for link', () => { + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputBuild: Build = { - id: 1, - buildNumber: 'Build-1', - status: BuildStatus.InProgress, - result: undefined, - queueTime: new Date('2020-09-12T06:10:23.932Z'), - startTime: new Date('2020-09-12T06:15:23.932Z'), - finishTime: new Date('2020-09-12T06:20:23.932Z'), - sourceBranch: 'refs/heads/develop', - sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', - definition: undefined, - _links: undefined, - requestedFor: inputIdentityRef, - }; + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.InProgress, + result: undefined, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: undefined, + requestedFor: inputIdentityRef, + }; - const outputRepoBuild: RepoBuild = { - id: 1, - title: 'Build-1', - link: '', - status: BuildStatus.InProgress, - result: BuildResult.None, - queueTime: '2020-09-12T06:10:23.932Z', - startTime: '2020-09-12T06:15:23.932Z', - finishTime: '2020-09-12T06:20:23.932Z', - source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAIN\\jdoe', - }; + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: '', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; - expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); }); }); describe('mappedPullRequest', () => { - it('should return PullRequest from GitPullRequest', () => { - const inputGitRepository: GitRepository = { - name: 'super-feature-repo', - }; + describe('mappedPullRequest happy path', () => { + it('should return PullRequest from GitPullRequest', () => { + const inputGitRepository: GitRepository = { + name: 'super-feature-repo', + }; - const inputIdentityRef: IdentityRef = { - displayName: 'Jane Doe', - uniqueName: 'DOMAIN\\jdoe', - }; + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; - const inputPullRequest: GitPullRequest = { - pullRequestId: 7181, - repository: inputGitRepository, - title: 'My Awesome New Feature', - createdBy: inputIdentityRef, - creationDate: new Date('2020-09-12T06:10:23.932Z'), - sourceRefName: 'refs/heads/topic/super-awesome-feature', - targetRefName: 'refs/heads/main', - status: PullRequestStatus.Active, - isDraft: false, - }; + const inputPullRequest: GitPullRequest = { + pullRequestId: 7181, + repository: inputGitRepository, + title: 'My Awesome New Feature', + createdBy: inputIdentityRef, + creationDate: new Date('2020-09-12T06:10:23.932Z'), + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }; - const inputBaseUrl = - 'https://host.com/myOrg/_git/super-feature-repo/pullrequest'; + const inputBaseUrl = + 'https://host.com/myOrg/_git/super-feature-repo/pullrequest'; - const outputPullRequest: PullRequest = { - pullRequestId: 7181, - repoName: 'super-feature-repo', - title: 'My Awesome New Feature', - uniqueName: 'DOMAIN\\jdoe', - createdBy: 'Jane Doe', - creationDate: '2020-09-12T06:10:23.932Z', - sourceRefName: 'refs/heads/topic/super-awesome-feature', - targetRefName: 'refs/heads/main', - status: PullRequestStatus.Active, - isDraft: false, - link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', - }; + const outputPullRequest: PullRequest = { + pullRequestId: 7181, + repoName: 'super-feature-repo', + title: 'My Awesome New Feature', + uniqueName: 'DOMAIN\\jdoe', + createdBy: 'Jane Doe', + creationDate: '2020-09-12T06:10:23.932Z', + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', + }; - expect(mappedPullRequest(inputPullRequest, inputBaseUrl)).toEqual( - outputPullRequest, - ); + expect(mappedPullRequest(inputPullRequest, inputBaseUrl)).toEqual( + outputPullRequest, + ); + }); + }); + }); + + describe('mappedBuildRun', () => { + describe('mappedBuildRun happy path', () => { + it('should return RepoBuild from Build', () => { + const inputBuildDefinition: DefinitionReference = { + name: 'My Build Definition', + }; + + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: inputBuildDefinition, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'My Build Definition - Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; + + expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedBuildRun with no Build definition name', () => { + it('should return RepoBuild with only Build Number for title', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; + + expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedBuildRun with undefined status', () => { + it('should return BuildStatus of None for status', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: undefined, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.None, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; + + expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedBuildRun with undefined result', () => { + it('should return BuildResult of None for result', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.InProgress, + result: undefined, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + requestedFor: inputIdentityRef, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; + + expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedBuildRun with undefined link', () => { + it('should return empty string for link', () => { + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAIN\\jdoe', + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.InProgress, + result: undefined, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: undefined, + requestedFor: inputIdentityRef, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: '', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + uniqueName: 'DOMAIN\\jdoe', + }; + + expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild); + }); }); }); }); diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 23ea3311f0..6e45a5f4e5 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -14,22 +14,37 @@ * limitations under the License. */ +import { + Build, + BuildDefinitionReference, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { BuildResult, + BuildRun, BuildStatus, + DashboardPullRequest, + Policy, PullRequest, PullRequestOptions, RepoBuild, + Team, } from '@backstage/plugin-azure-devops-common'; import { GitPullRequest, GitPullRequestSearchCriteria, GitRepository, } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { + convertDashboardPullRequest, + convertPolicy, + getArtifactId, +} from '../utils'; -import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Logger } from 'winston'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import { TeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { WebApi } from 'azure-devops-node-api'; +import { WebApiTeam } from 'azure-devops-node-api/interfaces/CoreInterfaces'; export class AzureDevOpsApi { public constructor( @@ -138,6 +153,194 @@ export class AzureDevOpsApi { return pullRequests; } + + public async getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise { + this.logger?.debug( + `Getting dashboard pull requests for project '${projectName}'.`, + ); + + const client = await this.webApi.getGitApi(); + + const searchCriteria: GitPullRequestSearchCriteria = { + status: options.status, + }; + + const gitPullRequests: GitPullRequest[] = + await client.getPullRequestsByProject( + projectName, + searchCriteria, + undefined, + undefined, + options.top, + ); + + return Promise.all( + gitPullRequests.map(async gitPullRequest => { + const projectId = gitPullRequest.repository?.project?.id; + const prId = gitPullRequest.pullRequestId; + + let policies: Policy[] | undefined; + + if (projectId && prId) { + policies = await this.getPullRequestPolicies( + projectName, + projectId, + prId, + ); + } + + return convertDashboardPullRequest( + gitPullRequest, + this.webApi.serverUrl, + policies, + ); + }), + ); + } + + private async getPullRequestPolicies( + projectName: string, + projectId: string, + pullRequestId: number, + ): Promise { + this.logger?.debug( + `Getting pull request policies for pull request id '${pullRequestId}'.`, + ); + + const client = await this.webApi.getPolicyApi(); + + const artifactId = getArtifactId(projectId, pullRequestId); + + const policyEvaluationRecords: PolicyEvaluationRecord[] = + await client.getPolicyEvaluations(projectName, artifactId); + + return policyEvaluationRecords + .map(convertPolicy) + .filter((policy): policy is Policy => Boolean(policy)); + } + + public async getAllTeams(): Promise { + this.logger?.debug('Getting all teams.'); + + const client = await this.webApi.getCoreApi(); + const webApiTeams: WebApiTeam[] = await client.getAllTeams(); + + const teams: Team[] = await Promise.all( + webApiTeams.map(async team => ({ + id: team.id, + name: team.name, + memberIds: await this.getTeamMemberIds(team), + })), + ); + + return teams.sort((a, b) => + a.name && b.name ? a.name.localeCompare(b.name) : 0, + ); + } + + private async getTeamMemberIds( + team: WebApiTeam, + ): Promise { + this.logger?.debug(`Getting team member ids for team '${team.name}'.`); + + if (!team.projectId || !team.id) { + return undefined; + } + + const client = await this.webApi.getCoreApi(); + + const teamMembers: TeamMember[] = + await client.getTeamMembersWithExtendedProperties( + team.projectId, + team.id, + ); + + return teamMembers + .map(teamMember => teamMember.identity?.id) + .filter((id): id is string => Boolean(id)); + } + + public async getBuildDefinitions( + projectName: string, + definitionName: string, + ): Promise { + this.logger?.debug( + `Calling Azure DevOps REST API, getting Build Definitions for ${definitionName} in Project ${projectName}`, + ); + + const client = await this.webApi.getBuildApi(); + return client.getDefinitions(projectName, definitionName); + } + + public async getBuilds( + projectName: string, + top: number, + repoId?: string, + definitions?: number[], + ): Promise { + this.logger?.debug( + `Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`, + ); + + const client = await this.webApi.getBuildApi(); + return client.getBuilds( + projectName, + definitions, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + top, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + repoId, + repoId ? 'TfsGit' : undefined, + ); + } + + public async getBuildRuns( + projectName: string, + top: number, + repoName?: string, + definitionName?: string, + ) { + let repoId: string | undefined; + let definitions: number[] | undefined; + + if (repoName) { + const gitRepository = await this.getGitRepository(projectName, repoName); + repoId = gitRepository.id; + } + + if (definitionName) { + const buildDefinitions = await this.getBuildDefinitions( + projectName, + definitionName, + ); + definitions = buildDefinitions + .map(bd => bd.id) + .filter((bd): bd is number => Boolean(bd)); + } + + const builds = await this.getBuilds(projectName, top, repoId, definitions); + + const buildRuns: BuildRun[] = builds.map(mappedBuildRun); + + return buildRuns; + } } export function mappedRepoBuild(build: Build): RepoBuild { @@ -175,3 +378,20 @@ export function mappedPullRequest( link: `${linkBaseUrl}/${pullRequest.pullRequestId}`, }; } + +export function mappedBuildRun(build: Build): BuildRun { + return { + id: build.id, + title: [build.definition?.name, build.buildNumber] + .filter(Boolean) + .join(' - '), + link: build._links?.web.href ?? '', + status: build.status ?? BuildStatus.None, + result: build.result ?? BuildResult.None, + queueTime: build.queueTime?.toISOString(), + startTime: build.startTime?.toISOString(), + finishTime: build.finishTime?.toISOString(), + source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, + uniqueName: build.requestedFor?.uniqueName ?? 'N/A', + }; +} diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index ce5f712392..87135781e6 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -14,8 +14,13 @@ * limitations under the License. */ +import { + Build, + BuildDefinitionReference, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { BuildResult, + BuildRun, BuildStatus, PullRequest, PullRequestStatus, @@ -23,7 +28,6 @@ import { } from '@backstage/plugin-azure-devops-common'; import { AzureDevOpsApi } from '../api'; -import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { ConfigReader } from '@backstage/config'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { createRouter } from './router'; @@ -39,8 +43,12 @@ describe('createRouter', () => { azureDevOpsApi = { getGitRepository: jest.fn(), getBuildList: jest.fn(), + getBuildDefinitions: jest.fn(), getRepoBuilds: jest.fn(), + getDefinitionBuilds: jest.fn(), getPullRequests: jest.fn(), + getBuilds: jest.fn(), + getBuildRuns: jest.fn(), } as any; const router = await createRouter({ azureDevOpsApi, @@ -260,4 +268,142 @@ describe('createRouter', () => { expect(response.body).toEqual(pullRequests); }); }); + + describe('GET /build-definitions/:projectName/:definitionName', () => { + it('fetches a list of build definitions', async () => { + const inputDefinition: BuildDefinitionReference = { + id: 1, + name: 'myBuildDefinition', + }; + + const inputDefinitions: BuildDefinitionReference[] = [inputDefinition]; + + azureDevOpsApi.getBuildDefinitions.mockResolvedValueOnce( + inputDefinitions, + ); + + const response = await request(app).get( + '/build-definitions/myProject/myBuildDefinition', + ); + + expect(azureDevOpsApi.getBuildDefinitions).toHaveBeenCalledWith( + 'myProject', + 'myBuildDefinition', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(inputDefinitions); + }); + }); + + describe('GET /builds/:projectName', () => { + describe('GET /builds/:projectName with repoName', () => { + it('fetches a list of build runs using repoName', async () => { + const firstBuildRun: BuildRun = { + id: 1, + title: 'My Build Definition - Build 1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.PartiallySucceeded, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + }; + + const secondBuildRun: BuildRun = { + id: 2, + title: 'My Build Definition - Build 2', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (13c988d4)', + }; + + const thirdBuildRun: BuildRun = { + id: 3, + title: 'My Build Definition - Build 3', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (9bedf678)', + }; + + const buildRuns: BuildRun[] = [ + firstBuildRun, + secondBuildRun, + thirdBuildRun, + ]; + + azureDevOpsApi.getBuildRuns.mockResolvedValueOnce(buildRuns); + + const response = await request(app) + .get('/builds/myProject') + .query({ top: '50', repoName: 'myRepo' }); + + expect(azureDevOpsApi.getBuildRuns).toHaveBeenCalledWith( + 'myProject', + 50, + 'myRepo', + undefined, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(buildRuns); + }); + }); + + describe('GET /builds/:projectName with definitionName', () => { + it('fetches a list of build runs using definitionName', async () => { + const firstBuildRun: BuildRun = { + id: 1, + title: 'My Build Definition - Build 1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.PartiallySucceeded, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (f4f78b31)', + }; + + const secondBuildRun: BuildRun = { + id: 2, + title: 'My Build Definition - Build 2', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (13c988d4)', + }; + + const thirdBuildRun: BuildRun = { + id: 3, + title: 'My Build Definition - Build 3', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + source: 'refs/heads/develop (9bedf678)', + }; + + const buildRuns: BuildRun[] = [ + firstBuildRun, + secondBuildRun, + thirdBuildRun, + ]; + + azureDevOpsApi.getBuildRuns.mockResolvedValueOnce(buildRuns); + + const response = await request(app) + .get('/builds/myProject') + .query({ top: '50', definitionName: 'myDefinition' }); + + expect(azureDevOpsApi.getBuildRuns).toHaveBeenCalledWith( + 'myProject', + 50, + undefined, + 'myDefinition', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(buildRuns); + }); + }); + }); }); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index d61148b4ff..99c140a424 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -15,6 +15,7 @@ */ import { + DashboardPullRequest, PullRequestOptions, PullRequestStatus, } from '@backstage/plugin-azure-devops-common'; @@ -27,7 +28,7 @@ import Router from 'express-promise-router'; import { errorHandler } from '@backstage/backend-common'; import express from 'express'; -const DEFAULT_TOP: number = 10; +const DEFAULT_TOP = 10; export interface RouterOptions { azureDevOpsApi?: AzureDevOpsApi; @@ -80,33 +81,95 @@ export async function createRouter( router.get('/repo-builds/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const gitRepository = await azureDevOpsApi.getRepoBuilds( projectName, repoName, top, ); + res.status(200).json(gitRepository); }); router.get('/pull-requests/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const status = req.query.status ? Number(req.query.status) : PullRequestStatus.Active; + const pullRequestOptions: PullRequestOptions = { top: top, status: status, }; + const gitPullRequest = await azureDevOpsApi.getPullRequests( projectName, repoName, pullRequestOptions, ); + res.status(200).json(gitPullRequest); }); + router.get('/dashboard-pull-requests/:projectName', async (req, res) => { + const { projectName } = req.params; + + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + + const status = req.query.status + ? Number(req.query.status) + : PullRequestStatus.Active; + + const pullRequestOptions: PullRequestOptions = { + top: top, + status: status, + }; + + const pullRequests: DashboardPullRequest[] = + await azureDevOpsApi.getDashboardPullRequests( + projectName, + pullRequestOptions, + ); + + res.status(200).json(pullRequests); + }); + + router.get('/all-teams', async (_req, res) => { + const allTeams = await azureDevOpsApi.getAllTeams(); + res.status(200).json(allTeams); + }); + + router.get( + '/build-definitions/:projectName/:definitionName', + async (req, res) => { + const { projectName, definitionName } = req.params; + const buildDefinitionList = await azureDevOpsApi.getBuildDefinitions( + projectName, + definitionName, + ); + res.status(200).json(buildDefinitionList); + }, + ); + + router.get('/builds/:projectName', async (req, res) => { + const { projectName } = req.params; + const repoName = req.query.repoName?.toString(); + const definitionName = req.query.definitionName?.toString(); + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const builds = await azureDevOpsApi.getBuildRuns( + projectName, + top, + repoName, + definitionName, + ); + res.status(200).json(builds); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts new file mode 100644 index 0000000000..8aed436710 --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + DashboardPullRequest, + PullRequestStatus, + PullRequestVoteStatus, +} from '@backstage/plugin-azure-devops-common'; +import { + convertDashboardPullRequest, + getArtifactId, + getAvatarUrl, + getPullRequestLink, +} from './azure-devops-utils'; + +import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces'; + +describe('convertDashboardPullRequest', () => { + it('should return DashboardPullRequest', () => { + const baseUrl = 'https://dev.azure.com'; + + const pullRequest: GitPullRequest = { + pullRequestId: 1, + title: 'Pull Request 1', + description: 'Test description', + repository: { + id: 'repo1', + name: 'azure-devops', + url: 'https://dev.azure.com/backstage/backstage/_apis/git/repositories/azure-devops', + project: { + name: 'backstage', + }, + }, + createdBy: { + id: 'user1', + displayName: 'User 1', + uniqueName: 'user1@backstage.io', + _links: { + avatar: { + href: 'avatar-href', + }, + }, + imageUrl: 'avatar-url', + }, + reviewers: [ + { + id: 'user2', + displayName: 'User 2', + _links: { + avatar: { + href: 'avatar-href', + }, + }, + isRequired: true, + isContainer: false, + vote: 10, + }, + ], + creationDate: new Date('2021-10-15T09:30:00.0000000Z'), + status: PullRequestStatus.Active, + isDraft: false, + completionOptions: {}, + }; + + const expectedPullRequest: DashboardPullRequest = { + pullRequestId: 1, + title: 'Pull Request 1', + description: 'Test description', + repository: { + id: 'repo1', + name: 'azure-devops', + url: 'https://dev.azure.com/backstage/backstage/_git/azure-devops', + }, + createdBy: { + id: 'user1', + displayName: 'User 1', + uniqueName: 'user1@backstage.io', + imageUrl: 'avatar-href', + }, + hasAutoComplete: true, + policies: [], + reviewers: [ + { + id: 'user2', + displayName: 'User 2', + imageUrl: 'avatar-href', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.Approved, + }, + ], + creationDate: '2021-10-15T09:30:00.000Z', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://dev.azure.com/backstage/_git/azure-devops/pullrequest/1', + }; + + const result = convertDashboardPullRequest(pullRequest, baseUrl, []); + expect(result).toEqual(expectedPullRequest); + }); +}); + +describe('getPullRequestLink', () => { + it('should return pull request link', () => { + const baseUrl = 'dev.azure.com'; + const pullRequest = { + pullRequestId: 1, + repository: { + name: 'azure-devops', + project: { + name: 'backstage', + }, + }, + }; + const result = getPullRequestLink(baseUrl, pullRequest); + expect(result).toBe(`${baseUrl}/backstage/_git/azure-devops/pullrequest/1`); + }); +}); + +describe('getAvatarUrl', () => { + it('should return avatar href', () => { + const identity = { + _links: { + avatar: { + href: 'avatar-href', + }, + }, + imageUrl: 'avatar-url', + }; + const result = getAvatarUrl(identity); + expect(result).toBe('avatar-href'); + }); + + it('should return avatar image url', () => { + const identity = { + imageUrl: 'avatar-url', + }; + const result = getAvatarUrl(identity); + expect(result).toBe('avatar-url'); + }); +}); + +describe('getArtifactId', () => { + it('should return artifact id', () => { + const result = getArtifactId('project1', 1); + expect(result).toBe('vstfs:///CodeReview/CodeReviewId/project1/1'); + }); +}); diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts new file mode 100644 index 0000000000..7a0d0a279e --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -0,0 +1,265 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + CreatedBy, + DashboardPullRequest, + Policy, + PolicyEvaluationStatus, + PolicyType, + PolicyTypeId, + PullRequestVoteStatus, + Repository, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { + GitPullRequest, + GitRepository, + IdentityRefWithVote, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; + +import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; + +export function convertDashboardPullRequest( + pullRequest: GitPullRequest, + baseUrl: string, + policies: Policy[] | undefined, +): DashboardPullRequest { + return { + pullRequestId: pullRequest.pullRequestId, + title: pullRequest.title, + description: pullRequest.description, + repository: convertRepository(pullRequest.repository), + createdBy: convertCreatedBy(pullRequest.createdBy), + hasAutoComplete: hasAutoComplete(pullRequest), + policies, + reviewers: convertReviewers(pullRequest.reviewers), + creationDate: pullRequest.creationDate?.toISOString(), + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: getPullRequestLink(baseUrl, pullRequest), + }; +} + +export function getPullRequestLink( + baseUrl: string, + pullRequest: GitPullRequest, +): string | undefined { + const projectName = pullRequest.repository?.project?.name; + const repoName = pullRequest.repository?.name; + const pullRequestId = pullRequest.pullRequestId; + + if (!projectName || !repoName || !pullRequestId) { + return undefined; + } + + const encodedProjectName = encodeURIComponent(projectName); + const encodedRepoName = encodeURIComponent(repoName); + + return `${baseUrl}/${encodedProjectName}/_git/${encodedRepoName}/pullrequest/${pullRequestId}`; +} + +/** + * Tries to get the avatar from the new property if not then falls-back to deprecated `imageUrl`. + * https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests-by-project?view=azure-devops-rest-6.0#identityref + */ +export function getAvatarUrl(identity: IdentityRef): string | undefined { + return identity._links?.avatar?.href ?? identity.imageUrl; +} + +export function getArtifactId( + projectId: string, + pullRequestId: number, +): string { + return `vstfs:///CodeReview/CodeReviewId/${projectId}/${pullRequestId}`; +} + +export function convertPolicy( + policyEvaluationRecord: PolicyEvaluationRecord, +): Policy | undefined { + const policyConfig = policyEvaluationRecord.configuration; + const policyStatus = policyEvaluationRecord.status; + + if (!policyConfig) { + return undefined; + } + + if ( + !( + policyConfig.isEnabled && + !policyConfig.isDeleted && + (policyConfig.isBlocking || + policyConfig.type?.id === PolicyType.Status) && // Optional "Status" policies are actually required for automatic completion. + policyStatus !== PolicyEvaluationStatus.Approved + ) + ) { + return undefined; + } + + const policyTypeId = policyConfig.type?.id; + + if (!policyTypeId) { + return undefined; + } + + const policyType: PolicyType | undefined = ( + { + [PolicyTypeId.Build]: PolicyType.Build, + [PolicyTypeId.Status]: PolicyType.Status, + [PolicyTypeId.MinimumReviewers]: PolicyType.MinimumReviewers, + [PolicyTypeId.Comments]: PolicyType.Comments, + [PolicyTypeId.RequiredReviewers]: PolicyType.RequiredReviewers, + [PolicyTypeId.MergeStrategy]: PolicyType.MergeStrategy, + } as Record + )[policyTypeId]; + + if (!policyType) { + return undefined; + } + + const policyConfigSettings = policyConfig.settings; + let policyText = policyConfig.type?.displayName; + let policyLink: string | undefined; + + switch (policyType) { + case PolicyType.Build: { + const buildDisplayName = policyConfigSettings.displayName; + + if (buildDisplayName) { + policyText += `: ${buildDisplayName}`; + } + + const buildId = policyEvaluationRecord.context?.buildId; + const policyConfigUrl = policyConfig.url; + + if (buildId && policyConfigUrl) { + policyLink = policyConfigUrl.replace( + `_apis/policy/configurations/${policyConfig.id}`, + `_build/results?buildId=${buildId}`, + ); + } + + if (!policyStatus) { + break; + } + + const buildExpired = Boolean(policyConfigSettings.isExpired); + const buildPolicyStatus = + ( + { + [PolicyEvaluationStatus.Queued]: buildExpired + ? 'expired' + : 'queued', + [PolicyEvaluationStatus.Rejected]: 'failed', + } as Record + )[policyStatus] ?? PolicyEvaluationStatus[policyStatus].toLowerCase(); + + policyText += ` (${buildPolicyStatus})`; + + break; + } + case PolicyType.Status: { + const statusGenre = policyConfigSettings.statusGenre; + const statusName = policyConfigSettings.statusGenre; + + if (statusName) { + policyText += `: ${statusGenre}/${statusName}`; + } + + break; + } + case PolicyType.MinimumReviewers: { + const minimumApproverCount = policyConfigSettings.minimumApproverCount; + policyText += ` (${minimumApproverCount})`; + break; + } + case PolicyType.Comments: + break; + case PolicyType.RequiredReviewers: + break; + case PolicyType.MergeStrategy: + default: + return undefined; + } + + return { + id: policyConfig.id, + type: policyType, + status: policyStatus, + text: policyText, + link: policyLink, + }; +} + +function convertReviewer( + identityRef?: IdentityRefWithVote, +): Reviewer | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + uniqueName: identityRef.uniqueName, + imageUrl: getAvatarUrl(identityRef), + isRequired: identityRef.isRequired, + isContainer: identityRef.isContainer, + voteStatus: (identityRef.vote ?? 0) as PullRequestVoteStatus, + }; +} + +function convertReviewers( + identityRefs?: IdentityRefWithVote[], +): Reviewer[] | undefined { + if (!identityRefs) { + return undefined; + } + + return identityRefs + .map(convertReviewer) + .filter((reviewer): reviewer is Reviewer => Boolean(reviewer)); +} + +function convertRepository(repository?: GitRepository): Repository | undefined { + if (!repository) { + return undefined; + } + + return { + id: repository.id, + name: repository.name, + url: repository.url?.replace('_apis/git/repositories', '_git'), + }; +} + +function convertCreatedBy(identityRef?: IdentityRef): CreatedBy | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + uniqueName: identityRef.uniqueName, + imageUrl: getAvatarUrl(identityRef), + }; +} + +function hasAutoComplete(pullRequest: GitPullRequest): boolean { + return pullRequest.isDraft !== true && !!pullRequest.completionOptions; +} diff --git a/plugins/azure-devops-backend/src/utils/index.ts b/plugins/azure-devops-backend/src/utils/index.ts new file mode 100644 index 0000000000..415503091a --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './azure-devops-utils'; diff --git a/plugins/azure-devops-common/CHANGELOG.md b/plugins/azure-devops-common/CHANGELOG.md index 4a834fb068..84c2cae9a5 100644 --- a/plugins/azure-devops-common/CHANGELOG.md +++ b/plugins/azure-devops-common/CHANGELOG.md @@ -1,5 +1,74 @@ # @backstage/plugin-azure-devops-common +## 0.1.3 + +### Patch Changes + +- daf32e2c9b: Created some initial filters that can be used to create pull request columns: + + - All + - AssignedToUser + - AssignedToCurrentUser + - AssignedToTeam + - AssignedToTeams + - AssignedToCurrentUsersTeams + - CreatedByUser + - CreatedByCurrentUser + - CreatedByTeam + - CreatedByTeams + - CreatedByCurrentUsersTeams + + Example custom column creation: + + ```tsx + const COLUMN_CONFIGS: PullRequestColumnConfig[] = [ + { + title: 'Created by me', + filters: [{ type: FilterType.CreatedByCurrentUser }], + }, + { + title: 'Created by Backstage Core', + filters: [ + { + type: FilterType.CreatedByTeam, + teamName: 'Backstage Core', + }, + ], + }, + { + title: 'Assigned to my teams', + filters: [{ type: FilterType.AssignedToCurrentUsersTeams }], + }, + { + title: 'Other PRs', + filters: [{ type: FilterType.All }], + simplified: true, + }, + ]; + + + } + />; + ``` + +## 0.1.2 + +### Patch Changes + +- a77526afcd: Added getting builds by definition name + +## 0.1.1 + +### Patch Changes + +- 0749dd0307: feat: Created pull request card component and initial pull request dashboard page. + ## 0.1.0 ### Minor Changes diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index 71f0b8b807..85a9e54847 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -14,6 +14,29 @@ export enum BuildResult { Succeeded = 2, } +// Warning: (ae-missing-release-tag) "BuildRun" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BuildRun = { + id?: number; + title: string; + link?: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: string; + startTime?: string; + finishTime?: string; + source: string; + uniqueName?: string; +}; + +// Warning: (ae-missing-release-tag) "BuildRunOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BuildRunOptions = { + top?: number; +}; + // Warning: (ae-missing-release-tag) "BuildStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -27,6 +50,112 @@ export enum BuildStatus { Postponed = 8, } +// Warning: (ae-missing-release-tag) "CreatedBy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface CreatedBy { + // (undocumented) + displayName?: string; + // (undocumented) + id?: string; + // (undocumented) + imageUrl?: string; + // (undocumented) + teamIds?: string[]; + // (undocumented) + teamNames?: string[]; + // (undocumented) + uniqueName?: string; +} + +// Warning: (ae-missing-release-tag) "DashboardPullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface DashboardPullRequest { + // (undocumented) + createdBy?: CreatedBy; + // (undocumented) + creationDate?: string; + // (undocumented) + description?: string; + // (undocumented) + hasAutoComplete: boolean; + // (undocumented) + isDraft?: boolean; + // (undocumented) + link?: string; + // (undocumented) + policies?: Policy[]; + // (undocumented) + pullRequestId?: number; + // (undocumented) + repository?: Repository; + // (undocumented) + reviewers?: Reviewer[]; + // (undocumented) + status?: PullRequestStatus; + // (undocumented) + title?: string; +} + +// Warning: (ae-missing-release-tag) "Policy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Policy { + // (undocumented) + id?: number; + // (undocumented) + link?: string; + // (undocumented) + status?: PolicyEvaluationStatus; + // (undocumented) + text?: string; + // (undocumented) + type: PolicyType; +} + +// Warning: (ae-missing-release-tag) "PolicyEvaluationStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export enum PolicyEvaluationStatus { + Approved = 2, + Broken = 5, + NotApplicable = 4, + Queued = 0, + Rejected = 3, + Running = 1, +} + +// Warning: (ae-missing-release-tag) "PolicyType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PolicyType { + // (undocumented) + Build = 'Build', + // (undocumented) + Comments = 'Comments', + // (undocumented) + MergeStrategy = 'MergeStrategy', + // (undocumented) + MinimumReviewers = 'MinimumReviewers', + // (undocumented) + RequiredReviewers = 'RequiredReviewers', + // (undocumented) + Status = 'Status', +} + +// Warning: (ae-missing-release-tag) "PolicyTypeId" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PolicyTypeId { + Build = '0609b952-1397-4640-95ec-e00a01b2c241', + Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2', + MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab', + MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd', + RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e', + Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226', +} + // Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -63,6 +192,22 @@ export enum PullRequestStatus { NotSet = 0, } +// Warning: (ae-missing-release-tag) "PullRequestVoteStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PullRequestVoteStatus { + // (undocumented) + Approved = 10, + // (undocumented) + ApprovedWithSuggestions = 5, + // (undocumented) + NoVote = 0, + // (undocumented) + Rejected = -10, + // (undocumented) + WaitingForAuthor = -5, +} + // Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -86,5 +231,49 @@ export type RepoBuildOptions = { top?: number; }; +// Warning: (ae-missing-release-tag) "Repository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Repository { + // (undocumented) + id?: string; + // (undocumented) + name?: string; + // (undocumented) + url?: string; +} + +// Warning: (ae-missing-release-tag) "Reviewer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Reviewer { + // (undocumented) + displayName?: string; + // (undocumented) + id?: string; + // (undocumented) + imageUrl?: string; + // (undocumented) + isContainer?: boolean; + // (undocumented) + isRequired?: boolean; + // (undocumented) + uniqueName?: string; + // (undocumented) + voteStatus: PullRequestVoteStatus; +} + +// Warning: (ae-missing-release-tag) "Team" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Team { + // (undocumented) + id?: string; + // (undocumented) + memberIds?: string[]; + // (undocumented) + name?: string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/azure-devops-common/package.json b/plugins/azure-devops-common/package.json index fe9df644b1..f8a9622bff 100644 --- a/plugins/azure-devops-common/package.json +++ b/plugins/azure-devops-common/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops-common", - "version": "0.1.0", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "devDependencies": { - "@backstage/cli": "^0.9.0" + "@backstage/cli": "^0.10.2" }, "files": [ "dist" diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index a647f87300..0befcf0b60 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -126,3 +126,147 @@ export type PullRequestOptions = { top: number; status: PullRequestStatus; }; + +export interface DashboardPullRequest { + pullRequestId?: number; + title?: string; + description?: string; + repository?: Repository; + createdBy?: CreatedBy; + hasAutoComplete: boolean; + policies?: Policy[]; + reviewers?: Reviewer[]; + creationDate?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link?: string; +} + +export interface Reviewer { + id?: string; + displayName?: string; + uniqueName?: string; + imageUrl?: string; + isRequired?: boolean; + isContainer?: boolean; + voteStatus: PullRequestVoteStatus; +} + +export interface Policy { + id?: number; + type: PolicyType; + status?: PolicyEvaluationStatus; + text?: string; + link?: string; +} + +export interface CreatedBy { + id?: string; + displayName?: string; + uniqueName?: string; + imageUrl?: string; + teamIds?: string[]; + teamNames?: string[]; +} + +export interface Repository { + id?: string; + name?: string; + url?: string; +} + +export interface Team { + id?: string; + name?: string; + memberIds?: string[]; +} + +/** + * Status of a policy which is running against a specific pull request. + */ +export enum PolicyEvaluationStatus { + /** + * The policy is either queued to run, or is waiting for some event before progressing. + */ + Queued = 0, + /** + * The policy is currently running. + */ + Running = 1, + /** + * The policy has been fulfilled for this pull request. + */ + Approved = 2, + /** + * The policy has rejected this pull request. + */ + Rejected = 3, + /** + * The policy does not apply to this pull request. + */ + NotApplicable = 4, + /** + * The policy has encountered an unexpected error. + */ + Broken = 5, +} + +export enum PolicyType { + Build = 'Build', + Status = 'Status', + MinimumReviewers = 'MinimumReviewers', + Comments = 'Comments', + RequiredReviewers = 'RequiredReviewers', + MergeStrategy = 'MergeStrategy', +} + +export enum PolicyTypeId { + /** + * This policy will require a successful build has been performed before updating protected refs. + */ + Build = '0609b952-1397-4640-95ec-e00a01b2c241', + /** + * This policy will require a successful status to be posted before updating protected refs. + */ + Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226', + /** + * This policy will ensure that a minimum number of reviewers have approved a pull request before completion. + */ + MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd', + /** + * Check if the pull request has any active comments. + */ + Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2', + /** + * This policy will ensure that required reviewers are added for modified files matching specified patterns. + */ + RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e', + /** + * This policy ensures that pull requests use a consistent merge strategy. + */ + MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab', +} + +export enum PullRequestVoteStatus { + Approved = 10, + ApprovedWithSuggestions = 5, + NoVote = 0, + WaitingForAuthor = -5, + Rejected = -10, +} +export type BuildRun = { + id?: number; + title: string; + link?: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: string; + startTime?: string; + finishTime?: string; + source: string; + uniqueName?: string; +}; + +export type BuildRunOptions = { + top?: number; +}; diff --git a/plugins/azure-devops/CHANGELOG.md b/plugins/azure-devops/CHANGELOG.md index 0bcd9bc686..c79c3abdbc 100644 --- a/plugins/azure-devops/CHANGELOG.md +++ b/plugins/azure-devops/CHANGELOG.md @@ -1,5 +1,104 @@ # @backstage/plugin-azure-devops +## 0.1.7 + +### Patch Changes + +- daf32e2c9b: Created some initial filters that can be used to create pull request columns: + + - All + - AssignedToUser + - AssignedToCurrentUser + - AssignedToTeam + - AssignedToTeams + - AssignedToCurrentUsersTeams + - CreatedByUser + - CreatedByCurrentUser + - CreatedByTeam + - CreatedByTeams + - CreatedByCurrentUsersTeams + + Example custom column creation: + + ```tsx + const COLUMN_CONFIGS: PullRequestColumnConfig[] = [ + { + title: 'Created by me', + filters: [{ type: FilterType.CreatedByCurrentUser }], + }, + { + title: 'Created by Backstage Core', + filters: [ + { + type: FilterType.CreatedByTeam, + teamName: 'Backstage Core', + }, + ], + }, + { + title: 'Assigned to my teams', + filters: [{ type: FilterType.AssignedToCurrentUsersTeams }], + }, + { + title: 'Other PRs', + filters: [{ type: FilterType.All }], + simplified: true, + }, + ]; + + + } + />; + ``` + +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/plugin-azure-devops-common@0.1.3 + - @backstage/catalog-model@0.9.8 + - @backstage/plugin-catalog-react@0.6.7 + +## 0.1.6 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + - @backstage/plugin-azure-devops-common@0.1.2 + +## 0.1.5 + +### Patch Changes + +- 0749dd0307: feat: Created pull request card component and initial pull request dashboard page. +- 82cd709fdb: **Backend** + + - Created new `/dashboard-pull-requests/:projectName` endpoint + - Created new `/all-teams` endpoint + - Implemented pull request policy evaluation conversion + + **Frontend** + + - Refactored `PullRequestsPage` and added new properties for `projectName` and `pollingInterval` + - Fixed spacing issue between repo link and creation date in `PullRequestCard` + - Added missing condition to `PullRequestCardPolicy` for `RequiredReviewers` + - Updated `useDashboardPullRequests` hook to implement long polling for pull requests + +- Updated dependencies + - @backstage/plugin-azure-devops-common@0.1.1 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.1.4 ### Patch Changes diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index ab659534c1..11e238e6f5 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -6,13 +6,136 @@ /// import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; import { Entity } from '@backstage/catalog-model'; +import { SvgIconProps } from '@material-ui/core'; + +// Warning: (ae-missing-release-tag) "AllFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AllFilter = BaseFilter & { + type: FilterType.All; +}; + +// Warning: (ae-missing-release-tag) "AssignedToTeamFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AssignedToTeamFilter = BaseFilter & { + type: FilterType.AssignedToTeam; + teamId: string; +}; + +// Warning: (ae-missing-release-tag) "AssignedToTeamsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AssignedToTeamsFilter = BaseFilter & + ( + | { + type: FilterType.AssignedToTeams; + teamIds: string[]; + } + | { + type: FilterType.AssignedToCurrentUsersTeams; + teamIds?: string[]; + } + ); + +// Warning: (ae-missing-release-tag) "AssignedToUserFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AssignedToUserFilter = BaseFilter & + ( + | { + type: FilterType.AssignedToUser; + email: string; + } + | { + type: FilterType.AssignedToCurrentUser; + email?: string; + } + ); // Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const azureDevOpsPlugin: BackstagePlugin<{}, {}>; +// Warning: (ae-missing-release-tag) "AzurePullRequestsIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const AzurePullRequestsIcon: (props: SvgIconProps) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AzurePullRequestsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const AzurePullRequestsPage: ({ + projectName, + pollingInterval, + defaultColumnConfigs, +}: { + projectName?: string | undefined; + pollingInterval?: number | undefined; + defaultColumnConfigs?: PullRequestColumnConfig[] | undefined; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "BaseFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BaseFilter = { + type: FilterType; +}; + +// Warning: (ae-missing-release-tag) "CreatedByTeamFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CreatedByTeamFilter = BaseFilter & + ({ + type: FilterType.CreatedByTeam; + } & ( + | { + teamId: string; + } + | { + teamName: string; + } + )); + +// Warning: (ae-missing-release-tag) "CreatedByTeamsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CreatedByTeamsFilter = BaseFilter & + ( + | ({ + type: FilterType.CreatedByTeams; + } & ( + | { + teamIds: string[]; + } + | { + teamNames: string[]; + } + )) + | { + type: FilterType.CreatedByCurrentUsersTeams; + teamIds?: string[]; + } + ); + +// Warning: (ae-missing-release-tag) "CreatedByUserFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CreatedByUserFilter = BaseFilter & + ( + | { + type: FilterType.CreatedByUser; + email: string; + } + | { + type: FilterType.CreatedByCurrentUser; + email?: string; + } + ); + // Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -31,10 +154,67 @@ export const EntityAzurePullRequestsContent: ({ defaultLimit?: number | undefined; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "Filter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type Filter = + | AssignedToUserFilter + | CreatedByUserFilter + | AssignedToTeamFilter + | CreatedByTeamFilter + | AssignedToTeamsFilter + | CreatedByTeamsFilter + | AllFilter; + +// Warning: (ae-missing-release-tag) "FilterType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum FilterType { + // (undocumented) + All = 'All', + // (undocumented) + AssignedToCurrentUser = 'AssignedToCurrentUser', + // (undocumented) + AssignedToCurrentUsersTeams = 'AssignedToCurrentUsersTeams', + // (undocumented) + AssignedToTeam = 'AssignedToTeam', + // (undocumented) + AssignedToTeams = 'AssignedToTeams', + // (undocumented) + AssignedToUser = 'AssignedToUser', + // (undocumented) + CreatedByCurrentUser = 'CreatedByCurrentUser', + // (undocumented) + CreatedByCurrentUsersTeams = 'CreatedByCurrentUsersTeams', + // (undocumented) + CreatedByTeam = 'CreatedByTeam', + // (undocumented) + CreatedByTeams = 'CreatedByTeams', + // (undocumented) + CreatedByUser = 'CreatedByUser', +} + // Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const isAzureDevOpsAvailable: (entity: Entity) => boolean; +// Warning: (ae-missing-release-tag) "PullRequestColumnConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface PullRequestColumnConfig { + // (undocumented) + filters: Filter[]; + // (undocumented) + simplified?: boolean; + // (undocumented) + title: string; +} + +// Warning: (ae-missing-release-tag) "PullRequestFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index 2badcf63b5..5d048c6471 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops", - "version": "0.1.4", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,28 +27,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/catalog-model": "^0.9.8", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.4", - "@backstage/plugin-azure-devops-common": "^0.1.0", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-azure-devops-common": "^0.1.3", + "@backstage/plugin-catalog-react": "^0.6.7", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "humanize-duration": "^3.27.0", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index 90d291b25d..d6e926b9a5 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -15,18 +15,18 @@ */ import { + DashboardPullRequest, PullRequest, PullRequestOptions, RepoBuild, RepoBuildOptions, + Team, } from '@backstage/plugin-azure-devops-common'; import { createApiRef } from '@backstage/core-plugin-api'; export const azureDevOpsApiRef = createApiRef({ id: 'plugin.azure-devops.service', - description: - 'Used by the Azure DevOps plugin to make requests to accompanying backend', }); export interface AzureDevOpsApi { @@ -41,4 +41,10 @@ export interface AzureDevOpsApi { repoName: string, options?: PullRequestOptions, ): Promise<{ items: PullRequest[] }>; + + getDashboardPullRequests( + projectName: string, + ): Promise; + + getAllTeams(): Promise; } diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index cbcc496725..28d7e19cff 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -14,13 +14,15 @@ * limitations under the License. */ -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { + DashboardPullRequest, PullRequest, PullRequestOptions, RepoBuild, RepoBuildOptions, + Team, } from '@backstage/plugin-azure-devops-common'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { AzureDevOpsApi } from './AzureDevOpsApi'; import { ResponseError } from '@backstage/errors'; @@ -29,7 +31,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; - constructor(options: { + public constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; }) { @@ -74,6 +76,18 @@ export class AzureDevOpsClient implements AzureDevOpsApi { return { items }; } + public getDashboardPullRequests( + projectName: string, + ): Promise { + return this.get( + `dashboard-pull-requests/${projectName}?top=100`, + ); + } + + public getAllTeams(): Promise { + return this.get('all-teams'); + } + private async get(path: string): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`; const url = new URL(path, baseUrl); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx new file mode 100644 index 0000000000..158c4bca5b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx @@ -0,0 +1,112 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { PullRequestColumnConfig, PullRequestGroup } from './lib/types'; +import React, { useState } from 'react'; +import { getPullRequestGroupConfigs, getPullRequestGroups } from './lib/utils'; + +import { FilterType } from './lib/filters'; +import { PullRequestGrid } from './lib/PullRequestGrid'; +import { useDashboardPullRequests } from '../../hooks'; +import { useFilterProcessor } from './lib/hooks'; + +type PullRequestsPageContentProps = { + pullRequestGroups: PullRequestGroup[] | undefined; + loading: boolean; + error?: Error; +}; + +const PullRequestsPageContent = ({ + pullRequestGroups, + loading, + error, +}: PullRequestsPageContentProps) => { + if (loading && (!pullRequestGroups || pullRequestGroups.length <= 0)) { + return ; + } + + if (error) { + return ; + } + + return ; +}; + +const DEFAULT_COLUMN_CONFIGS: PullRequestColumnConfig[] = [ + { + title: 'Created by me', + filters: [{ type: FilterType.CreatedByCurrentUser }], + simplified: false, + }, + { + title: 'Other PRs', + filters: [{ type: FilterType.All }], + simplified: true, + }, +]; + +type PullRequestsPageProps = { + projectName?: string; + pollingInterval?: number; + defaultColumnConfigs?: PullRequestColumnConfig[]; +}; + +export const PullRequestsPage = ({ + projectName, + pollingInterval, + defaultColumnConfigs, +}: PullRequestsPageProps) => { + const { pullRequests, loading, error } = useDashboardPullRequests( + projectName, + pollingInterval, + ); + + const [columnConfigs] = useState( + defaultColumnConfigs ?? DEFAULT_COLUMN_CONFIGS, + ); + + const filterProcessor = useFilterProcessor(); + + const pullRequestGroupConfigs = getPullRequestGroupConfigs( + columnConfigs, + filterProcessor, + ); + + const pullRequestGroups = getPullRequestGroups( + pullRequests, + pullRequestGroupConfigs, + ); + + return ( + +
+ + + + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/index.ts new file mode 100644 index 0000000000..4061c72a8e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestsPage } from './PullRequestsPage'; +export type { PullRequestColumnConfig } from './lib/types'; +export { FilterType } from './lib/filters'; +export type { + BaseFilter, + Filter, + PullRequestFilter, + AssignedToUserFilter, + CreatedByUserFilter, + AssignedToTeamFilter, + CreatedByTeamFilter, + AssignedToTeamsFilter, + CreatedByTeamsFilter, + AllFilter, +} from './lib/filters'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx new file mode 100644 index 0000000000..ca43b8e27b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 DoneAllIcon from '@material-ui/icons/DoneAll'; +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + root: (props: { hasAutoComplete: boolean }) => ({ + color: props.hasAutoComplete + ? theme.palette.success.main + : theme.palette.grey[400], + }), +})); + +export const AutoCompleteIcon = (props: { hasAutoComplete: boolean }) => { + const classes = useStyles(props); + return ; +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts new file mode 100644 index 0000000000..d83b0ffad0 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AutoCompleteIcon } from './AutoCompleteIcon'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx new file mode 100644 index 0000000000..b948b616bd --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + DashboardPullRequest, + PolicyEvaluationStatus, + PolicyType, + PullRequestStatus, + PullRequestVoteStatus, +} from '@backstage/plugin-azure-devops-common'; + +import { MemoryRouter } from 'react-router'; +import { PullRequestCard } from './PullRequestCard'; +import React from 'react'; + +export default { + title: 'Plugins/Azure Devops/Pull Request Card', + component: PullRequestCard, +}; + +const pullRequest: DashboardPullRequest = { + pullRequestId: 1, + title: + "feat(EXUX-4091): ๐Ÿ›‚ Added the admin role authorization to the backend API's", + description: + 'This PR contains the following updates:\n\n| Package | Type | Update | Change |\n|---|---|---|---|\n| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint) | devDependencies | major | [`4.33.0` -> `5.0.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2feslint-plugin/4.33.0/5.0.0) |\n| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescrip', + link: undefined, + repository: { + id: undefined, + name: 'backstage', + url: undefined, + }, + createdBy: { + id: '', + displayName: 'Marley', + uniqueName: 'marley@test.com', + imageUrl: + 'https://dev.azure.com/exclaimerltd/_api/_common/identityImage?id=e6c0634b-68d2-6e6f-aa7d-adccada23216', + }, + reviewers: [ + { + id: undefined, + displayName: 'Marley', + imageUrl: '', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.Approved, + }, + { + id: undefined, + displayName: 'User 1', + imageUrl: '', + isRequired: false, + isContainer: false, + voteStatus: PullRequestVoteStatus.WaitingForAuthor, + }, + { + id: undefined, + displayName: 'User 2', + imageUrl: '', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.NoVote, + }, + ], + policies: [ + { + id: undefined, + type: PolicyType.Build, + status: PolicyEvaluationStatus.Running, + text: 'Build: UI (running)', + link: undefined, + }, + { + id: undefined, + type: PolicyType.MinimumReviewers, + text: 'Minimum number of reviewers (2)', + status: undefined, + link: undefined, + }, + { + id: undefined, + type: PolicyType.Comments, + text: 'Comment requirements', + status: undefined, + link: undefined, + }, + ], + hasAutoComplete: true, + creationDate: new Date(Date.now() - 10000000).toISOString(), + status: PullRequestStatus.Active, + isDraft: false, +}; + +export const Default = () => ( + + + +); + +export const Simplified = () => ( + + + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx new file mode 100644 index 0000000000..4b2823814e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Avatar, Link } from '@backstage/core-components'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; + +import { AutoCompleteIcon } from '../AutoCompleteIcon'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { DateTime } from 'luxon'; +import { PullRequestCardPolicies } from './PullRequestCardPolicies'; +import { PullRequestCardReviewers } from './PullRequestCardReviewers'; +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles( + theme => ({ + card: { + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[700] + : theme.palette.common.white, + }, + cardHeaderSimplified: { + paddingBottom: theme.spacing(2), + }, + cardHeaderAction: { + display: 'flex', + alignSelf: 'center', + margin: 0, + }, + content: { + display: 'flex', + flexDirection: 'row', + }, + policies: { + flex: 1, + }, + }), + { name: 'PullRequestCard' }, +); + +type PullRequestCardProps = { + pullRequest: DashboardPullRequest; + simplified?: boolean; +}; + +export const PullRequestCard = ({ + pullRequest, + simplified, +}: PullRequestCardProps) => { + const title = ( + + {pullRequest.title} + + ); + + const repoLink = ( + + {pullRequest.repository?.name} + + ); + + const creationDate = pullRequest.creationDate + ? DateTime.fromISO(pullRequest.creationDate).toRelative() + : undefined; + + const subheader = ( + + {repoLink} ยท {creationDate} + + ); + + const avatar = ( + + ); + + const classes = useStyles(); + + return ( + + + } + classes={{ + ...(simplified && { root: classes.cardHeaderSimplified }), + action: classes.cardHeaderAction, + }} + /> + + {!simplified && ( + + {pullRequest.policies && ( + + )} + + {pullRequest.reviewers && ( + + )} + + )} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx new file mode 100644 index 0000000000..ec6389ab1e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Policy } from '@backstage/plugin-azure-devops-common'; +import { PullRequestCardPolicy } from './PullRequestCardPolicy'; +import React from 'react'; + +type PullRequestCardProps = { + policies: Policy[]; + className: string; +}; + +export const PullRequestCardPolicies = ({ + policies, + className, +}: PullRequestCardProps) => ( +
+ {policies.map(policy => ( + + ))} +
+); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx new file mode 100644 index 0000000000..580f8c571f --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + Policy, + PolicyEvaluationStatus, + PolicyType, +} from '@backstage/plugin-azure-devops-common'; +import { styled, withStyles } from '@material-ui/core/styles'; + +import CancelIcon from '@material-ui/icons/Cancel'; +import GroupWorkIcon from '@material-ui/icons/GroupWork'; +import React from 'react'; +import WatchLaterIcon from '@material-ui/icons/WatchLater'; + +const PolicyRequiredIcon = withStyles( + theme => ({ + root: { + color: theme.palette.info.main, + }, + }), + { name: 'PolicyRequiredIcon' }, +)(WatchLaterIcon); + +const PolicyIssueIcon = withStyles( + theme => ({ + root: { + color: theme.palette.error.main, + }, + }), + { name: 'PolicyIssueIcon' }, +)(CancelIcon); + +const PolicyInProgressIcon = withStyles( + theme => ({ + root: { + color: theme.palette.info.main, + }, + }), + { name: 'PolicyInProgressIcon' }, +)(GroupWorkIcon); + +function getPolicyIcon(policy: Policy): JSX.Element | null { + switch (policy.type) { + case PolicyType.Build: + switch (policy.status) { + case PolicyEvaluationStatus.Running: + return ; + case PolicyEvaluationStatus.Rejected: + return ; + case PolicyEvaluationStatus.Queued: + return ; + default: + return null; + } + case PolicyType.MinimumReviewers: + case PolicyType.RequiredReviewers: + return ; + case PolicyType.Status: + case PolicyType.Comments: + return ; + default: + return null; + } +} + +const PullRequestCardPolicyContainer = styled('div')({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', +}); + +type PullRequestCardPolicyProps = { + policy: Policy; +}; + +export const PullRequestCardPolicy = ({ + policy, +}: PullRequestCardPolicyProps) => ( + + {getPolicyIcon(policy)} {policy.text} + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx new file mode 100644 index 0000000000..1d2049728b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Avatar } from '@backstage/core-components'; +import React from 'react'; +import { Reviewer } from '@backstage/plugin-azure-devops-common'; + +type PullRequestCardReviewerProps = { + reviewer: Reviewer; +}; + +export const PullRequestCardReviewer = ({ + reviewer, +}: PullRequestCardReviewerProps) => ( + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx new file mode 100644 index 0000000000..e3bf0c8a03 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestCardReviewer } from './PullRequestCardReviewer'; +import React from 'react'; +import { Reviewer } from '@backstage/plugin-azure-devops-common'; +import { reviewerFilter } from '../utils'; +import { styled } from '@material-ui/core/styles'; + +const PullRequestCardReviewersContainer = styled('div')({ + '& > *': { + marginTop: 4, + marginBottom: 4, + }, +}); + +type PullRequestCardReviewersProps = { + reviewers: Reviewer[]; +}; + +export const PullRequestCardReviewers = ({ + reviewers, +}: PullRequestCardReviewersProps) => ( + + {reviewers.filter(reviewerFilter).map(reviewer => ( + + ))} + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts new file mode 100644 index 0000000000..5d6a49d09e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestCard } from './PullRequestCard'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx new file mode 100644 index 0000000000..8b9c49beb4 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestGridColumn } from '../PullRequestGridColumn'; +import { PullRequestGroup } from '../types'; +import React from 'react'; +import { styled } from '@material-ui/core'; + +const GridDiv = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + '& > *': { + marginRight: theme.spacing(2), + }, + '& > :last-of-type': { + marginRight: 0, + }, +})); + +type PullRequestGridProps = { + pullRequestGroups: PullRequestGroup[]; +}; + +export const PullRequestGrid = ({ + pullRequestGroups, +}: PullRequestGridProps) => { + return ( + + {pullRequestGroups.map((pullRequestGroup, index) => ( + + ))} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts new file mode 100644 index 0000000000..902680f80d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestGrid } from './PullRequestGrid'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx new file mode 100644 index 0000000000..94a648188d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Paper, Typography, styled, withStyles } from '@material-ui/core'; + +import { PullRequestCard } from '../PullRequestCard'; +import { PullRequestGroup } from '../types'; +import React from 'react'; + +const ColumnPaper = withStyles(theme => ({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + padding: theme.spacing(2), + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[800] + : theme.palette.grey[300], + height: '100%', + }, +}))(Paper); + +const ColumnTitleDiv = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), +})); + +export const PullRequestCardContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > *': { + marginBottom: theme.spacing(2), + }, + '& > :last-of-type': { + marginBottom: 0, + }, +})); + +type PullRequestGridColumnProps = { + pullRequestGroup: PullRequestGroup; +}; + +export const PullRequestGridColumn = ({ + pullRequestGroup, +}: PullRequestGridColumnProps) => { + const columnTitle = ( + + {pullRequestGroup.title} + + ); + + const pullRequests = ( + + {pullRequestGroup.pullRequests.map(pullRequest => ( + + ))} + + ); + + return ( + + {columnTitle} + {pullRequests} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts new file mode 100644 index 0000000000..aa3cf82554 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestGridColumn } from './PullRequestGridColumn'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/allFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/allFilter.ts new file mode 100644 index 0000000000..90ec822ef6 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/allFilter.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; + +export type AllFilter = BaseFilter & { + type: FilterType.All; +}; + +export function createAllFilter(): PullRequestFilter { + return (_pullRequest: DashboardPullRequest): boolean => true; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamFilter.ts new file mode 100644 index 0000000000..8e816db2c3 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamFilter.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { stringArrayHas } from '../../../../utils'; + +export type AssignedToTeamFilter = BaseFilter & { + type: FilterType.AssignedToTeam; + teamId: string; +}; + +export function createAssignedToTeamFilter( + filter: AssignedToTeamFilter, +): PullRequestFilter { + return (pullRequest: DashboardPullRequest): boolean => { + const reviewerIds = pullRequest.reviewers?.map(reviewer => reviewer.id); + + if (!reviewerIds) { + return false; + } + + return stringArrayHas(reviewerIds, filter.teamId, true); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamsFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamsFilter.ts new file mode 100644 index 0000000000..ce5e5bcc11 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToTeamsFilter.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { createAssignedToTeamFilter } from './assignedToTeamFilter'; + +export type AssignedToTeamsFilter = BaseFilter & + ( + | { + type: FilterType.AssignedToTeams; + teamIds: string[]; + } + | { + type: FilterType.AssignedToCurrentUsersTeams; + teamIds?: string[]; + } + ); + +export function createAssignedToTeamsFilter( + filter: AssignedToTeamsFilter, +): PullRequestFilter { + const teamIds = filter.teamIds; + + return (pullRequest: DashboardPullRequest): boolean => { + if (!teamIds) { + return false; + } + + return teamIds.some(teamId => { + return createAssignedToTeamFilter({ + type: FilterType.AssignedToTeam, + teamId, + })(pullRequest); + }); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToUserFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToUserFilter.ts new file mode 100644 index 0000000000..95ee3d5a59 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/assignedToUserFilter.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { stringArrayHas } from '../../../../utils'; + +export type AssignedToUserFilter = BaseFilter & + ( + | { + type: FilterType.AssignedToUser; + email: string; + } + | { + type: FilterType.AssignedToCurrentUser; + email?: string; + } + ); + +export function createAssignedToUserFilter( + filter: AssignedToUserFilter, +): PullRequestFilter { + const email = filter.email; + + return (pullRequest: DashboardPullRequest): boolean => { + const uniqueNames = pullRequest.reviewers?.map( + reviewer => reviewer.uniqueName, + ); + + if (!email || !uniqueNames) { + return false; + } + + return stringArrayHas(uniqueNames, email, true); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.test.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.test.ts new file mode 100644 index 0000000000..b0ac4d402b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Filter, FilterType } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { createFilter } from './createFilter'; + +describe('createFilter', () => { + const pullRequest = { + createdBy: { + uniqueName: 'user1@backstage.com', + teamIds: ['team1Id', 'team2Id'], + }, + reviewers: [ + { uniqueName: 'user2@backstage.com' }, + { id: 'team2Id' }, + { id: 'team3Id' }, + ], + } as DashboardPullRequest; + + const testCases: Array<{ filter: Filter; result: boolean }> = [ + { + filter: { + type: FilterType.AssignedToUser, + email: 'user2@backstage.com', + }, + result: true, + }, + { + filter: { + type: FilterType.AssignedToUser, + email: 'random-user@backstage.com', + }, + result: false, + }, + { + filter: { + type: FilterType.CreatedByUser, + email: 'user1@backstage.com', + }, + result: true, + }, + { + filter: { + type: FilterType.CreatedByUser, + email: 'random-user@backstage.com', + }, + result: false, + }, + { + filter: { + type: FilterType.AssignedToTeam, + teamId: 'team2Id', + }, + result: true, + }, + { + filter: { + type: FilterType.AssignedToTeam, + teamId: 'randomTeamId', + }, + result: false, + }, + { + filter: { + type: FilterType.CreatedByTeam, + teamId: 'team1Id', + }, + result: true, + }, + { + filter: { + type: FilterType.CreatedByTeam, + teamId: 'randomTeamId', + }, + result: false, + }, + { + filter: { + type: FilterType.AssignedToTeams, + teamIds: ['team2Id', 'randomTeamId'], + }, + result: true, + }, + { + filter: { + type: FilterType.AssignedToTeams, + teamIds: ['team2Id', 'team3Id'], + }, + result: true, + }, + { + filter: { + type: FilterType.AssignedToTeams, + teamIds: ['randomTeam1Id', 'randomTeam2Id'], + }, + result: false, + }, + { + filter: { + type: FilterType.CreatedByTeams, + teamIds: ['team1Id', 'randomTeamId'], + }, + result: true, + }, + { + filter: { + type: FilterType.CreatedByTeams, + teamIds: ['team1Id', 'team2Id'], + }, + result: true, + }, + { + filter: { + type: FilterType.CreatedByTeams, + teamIds: ['randomTeam1Id', 'randomTeam2Id'], + }, + result: false, + }, + { + filter: { + type: FilterType.All, + }, + result: true, + }, + ]; + + testCases.forEach(({ filter, result }) => { + it(`should return ${String(result)} when pull request ${ + result ? 'is' : 'is not' + } ${filter.type}`, () => { + const pullRequestFilter = createFilter(filter); + expect(pullRequestFilter(pullRequest)).toBe(result); + }); + }); +}); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.ts new file mode 100644 index 0000000000..492d0539c5 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createFilter.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Filter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { createAllFilter } from './allFilter'; +import { createAssignedToTeamFilter } from './assignedToTeamFilter'; +import { createAssignedToTeamsFilter } from './assignedToTeamsFilter'; +import { createAssignedToUserFilter } from './assignedToUserFilter'; +import { createCreatedByTeamFilter } from './createdByTeamFilter'; +import { createCreatedByTeamsFilter } from './createdByTeamsFilter'; +import { createCreatedByUserFilter } from './createdByUserFilter'; + +export function createFilter(filters: Filter | Filter[]): PullRequestFilter { + const mapFilter = (filter: Filter): PullRequestFilter => { + switch (filter.type) { + case FilterType.AssignedToUser: + case FilterType.AssignedToCurrentUser: + return createAssignedToUserFilter(filter); + + case FilterType.CreatedByUser: + case FilterType.CreatedByCurrentUser: + return createCreatedByUserFilter(filter); + + case FilterType.AssignedToTeam: + return createAssignedToTeamFilter(filter); + + case FilterType.CreatedByTeam: + return createCreatedByTeamFilter(filter); + + case FilterType.AssignedToTeams: + case FilterType.AssignedToCurrentUsersTeams: + return createAssignedToTeamsFilter(filter); + + case FilterType.CreatedByTeams: + case FilterType.CreatedByCurrentUsersTeams: + return createCreatedByTeamsFilter(filter); + + case FilterType.All: + return createAllFilter(); + + default: + return _ => false; + } + }; + + if (Array.isArray(filters)) { + if (filters.length === 1) { + return mapFilter(filters[0]); + } + + return (pullRequest: DashboardPullRequest): boolean => + filters.every(filter => mapFilter(filter)(pullRequest)); + } + + return mapFilter(filters); +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamFilter.ts new file mode 100644 index 0000000000..70a388b540 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamFilter.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { stringArrayHas } from '../../../../utils'; + +export type CreatedByTeamFilter = BaseFilter & + ({ + type: FilterType.CreatedByTeam; + } & ({ teamId: string } | { teamName: string })); + +export function createCreatedByTeamFilter( + filter: CreatedByTeamFilter, +): PullRequestFilter { + return (pullRequest: DashboardPullRequest): boolean => { + const [createdByTeams, team] = + 'teamId' in filter + ? [pullRequest.createdBy?.teamIds, filter.teamId] + : [pullRequest.createdBy?.teamNames, filter.teamName]; + + if (!createdByTeams) { + return false; + } + + return stringArrayHas(createdByTeams, team, true); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamsFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamsFilter.ts new file mode 100644 index 0000000000..34ed81db5d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByTeamsFilter.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { createCreatedByTeamFilter } from './createdByTeamFilter'; + +export type CreatedByTeamsFilter = BaseFilter & + ( + | ({ + type: FilterType.CreatedByTeams; + } & ({ teamIds: string[] } | { teamNames: string[] })) + | { + type: FilterType.CreatedByCurrentUsersTeams; + teamIds?: string[]; + } + ); + +export function createCreatedByTeamsFilter( + filter: CreatedByTeamsFilter, +): PullRequestFilter { + return (pullRequest: DashboardPullRequest): boolean => { + if ('teamNames' in filter) { + const teamNames = filter.teamNames; + + if (!teamNames) { + return false; + } + + return teamNames.some(teamName => { + return createCreatedByTeamFilter({ + type: FilterType.CreatedByTeam, + teamName, + })(pullRequest); + }); + } + + const teamIds = filter.teamIds; + + if (!teamIds) { + return false; + } + + return teamIds.some(teamId => { + return createCreatedByTeamFilter({ + type: FilterType.CreatedByTeam, + teamId, + })(pullRequest); + }); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByUserFilter.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByUserFilter.ts new file mode 100644 index 0000000000..69d4f23db5 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/createdByUserFilter.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BaseFilter, FilterType, PullRequestFilter } from './types'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { equalsIgnoreCase } from '../../../../utils'; + +export type CreatedByUserFilter = BaseFilter & + ( + | { + type: FilterType.CreatedByUser; + email: string; + } + | { + type: FilterType.CreatedByCurrentUser; + email?: string; + } + ); + +export function createCreatedByUserFilter( + filter: CreatedByUserFilter, +): PullRequestFilter { + const email = filter.email; + + return (pullRequest: DashboardPullRequest): boolean => { + const uniqueName = pullRequest.createdBy?.uniqueName; + + if (!email || !uniqueName) { + return false; + } + + return equalsIgnoreCase(email, uniqueName); + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/index.ts new file mode 100644 index 0000000000..df165dc9ca --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createFilter } from './createFilter'; +export { FilterTypes, FilterType } from './types'; +export type { + BaseFilter, + Filter, + PullRequestFilter, + AssignedToUserFilter, + CreatedByUserFilter, + AssignedToTeamFilter, + CreatedByTeamFilter, + AssignedToTeamsFilter, + CreatedByTeamsFilter, + AllFilter, +} from './types'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/types.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/types.ts new file mode 100644 index 0000000000..bc4643d51b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/filters/types.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AllFilter } from './allFilter'; +import { AssignedToTeamFilter } from './assignedToTeamFilter'; +import { AssignedToTeamsFilter } from './assignedToTeamsFilter'; +import { AssignedToUserFilter } from './assignedToUserFilter'; +import { CreatedByTeamFilter } from './createdByTeamFilter'; +import { CreatedByTeamsFilter } from './createdByTeamsFilter'; +import { CreatedByUserFilter } from './createdByUserFilter'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; + +export enum FilterType { + All = 'All', + + // Assigned To + AssignedToUser = 'AssignedToUser', + AssignedToCurrentUser = 'AssignedToCurrentUser', + AssignedToTeam = 'AssignedToTeam', + AssignedToTeams = 'AssignedToTeams', + AssignedToCurrentUsersTeams = 'AssignedToCurrentUsersTeams', + + // Created By + CreatedByUser = 'CreatedByUser', + CreatedByCurrentUser = 'CreatedByCurrentUser', + CreatedByTeam = 'CreatedByTeam', + CreatedByTeams = 'CreatedByTeams', + CreatedByCurrentUsersTeams = 'CreatedByCurrentUsersTeams', +} + +export const FilterTypes = [ + FilterType.All, + + FilterType.AssignedToUser, + FilterType.AssignedToCurrentUser, + FilterType.AssignedToTeam, + FilterType.AssignedToTeams, + FilterType.AssignedToCurrentUsersTeams, + + FilterType.CreatedByUser, + FilterType.CreatedByCurrentUser, + FilterType.CreatedByTeam, + FilterType.CreatedByTeams, + FilterType.CreatedByCurrentUsersTeams, +] as const; + +export type BaseFilter = { + type: FilterType; +}; + +export type Filter = + | AssignedToUserFilter + | CreatedByUserFilter + | AssignedToTeamFilter + | CreatedByTeamFilter + | AssignedToTeamsFilter + | CreatedByTeamsFilter + | AllFilter; + +export type { + AssignedToUserFilter, + CreatedByUserFilter, + AssignedToTeamFilter, + CreatedByTeamFilter, + AssignedToTeamsFilter, + CreatedByTeamsFilter, + AllFilter, +}; + +export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/index.ts new file mode 100644 index 0000000000..2e87dfea0c --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './useFilterProcessor'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/useFilterProcessor.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/useFilterProcessor.ts new file mode 100644 index 0000000000..546b23430d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/hooks/useFilterProcessor.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Filter, FilterType } from '../filters'; + +import { useUserEmail } from '../../../../hooks'; + +export function useFilterProcessor(): (filters: Filter[]) => Filter[] { + const userEmail = useUserEmail(); + + return (filters: Filter[]): Filter[] => { + for (const filter of filters) { + switch (filter.type) { + case FilterType.AssignedToCurrentUser: + case FilterType.CreatedByCurrentUser: + filter.email = userEmail; + break; + + default: + break; + } + } + + return filters; + }; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts new file mode 100644 index 0000000000..74c8ebe605 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Filter, PullRequestFilter } from './filters'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; + +export interface PullRequestColumnConfig { + title: string; + filters: Filter[]; + simplified?: boolean; +} + +export interface PullRequestGroupConfig { + title: string; + filter: PullRequestFilter; + simplified?: boolean; +} + +export interface PullRequestGroup { + title: string; + pullRequests: DashboardPullRequest[]; + simplified?: boolean; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts new file mode 100644 index 0000000000..d128cbd1db --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + DashboardPullRequest, + PullRequestVoteStatus, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { arrayExtract, getPullRequestGroups, reviewerFilter } from './utils'; + +describe('reviewerFilter', () => { + it('should return false if reviewer has no vote and is not required', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.NoVote, + isRequired: false, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(false); + }); + + it('should return true if reviewer has no vote and is required', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.NoVote, + isRequired: true, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(true); + }); + + it('should return true if reviewer has a vote and is not a container', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.Approved, + isContainer: false, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(true); + }); + + it('should return true if reviewer has a vote and is a container', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.Approved, + isContainer: true, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(false); + }); +}); + +describe('arrayExtract', () => { + it('should extract numbers greater than 3', () => { + const numbers = [1, 2, 3, 4, 5, 6]; + const numberFilter = (num: number): boolean => num > 3; + const extractedNumbers = arrayExtract(numbers, numberFilter); + expect(numbers).toEqual([1, 2, 3]); + expect(extractedNumbers).toEqual([4, 5, 6]); + }); + + it('should extract even numbers', () => { + const numbers = [1, 2, 3, 4, 5, 6]; + const numberFilter = (num: number): boolean => num % 2 === 0; + const extractedNumbers = arrayExtract(numbers, numberFilter); + expect(numbers).toEqual([1, 3, 5]); + expect(extractedNumbers).toEqual([2, 4, 6]); + }); +}); + +describe('getPullRequestGroups', () => { + it('should create groups of pull requests based on the provided configs', () => { + const userEmail = 'user1@backstage.com'; + const userEmail2 = 'user2@backstage.com'; + + const pullRequests = [ + { + pullRequestId: 1, + createdBy: { uniqueName: userEmail }, + } as DashboardPullRequest, + { + pullRequestId: 2, + createdBy: { uniqueName: userEmail }, + } as DashboardPullRequest, + { + pullRequestId: 3, + createdBy: { uniqueName: userEmail2 }, + } as DashboardPullRequest, + { + pullRequestId: 4, + createdBy: { uniqueName: userEmail2 }, + } as DashboardPullRequest, + ]; + + const configs = [ + { + title: 'Created by me', + filter: (pullRequest: DashboardPullRequest): boolean => + pullRequest.createdBy?.uniqueName === userEmail, + }, + { title: 'Other PRs', filter: (_: unknown) => true, simplified: true }, + ]; + + const result = getPullRequestGroups(pullRequests, configs) ?? []; + + expect(result.length).toBe(2); + + const group1 = result[0]; + expect(group1.title).toBe('Created by me'); + expect(group1.simplified).toBeFalsy(); + expect(group1.pullRequests.length).toBe(2); + expect(group1.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 1 }), + ); + expect(group1.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 2 }), + ); + + const group2 = result[1]; + expect(group2.title).toBe('Other PRs'); + expect(group2.simplified).toBe(true); + expect(group2.pullRequests.length).toBe(2); + expect(group2.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 3 }), + ); + expect(group2.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 4 }), + ); + }); +}); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts new file mode 100644 index 0000000000..c4fcdc9f7e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + DashboardPullRequest, + PullRequestVoteStatus, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { Filter, createFilter } from './filters'; +import { + PullRequestColumnConfig, + PullRequestGroup, + PullRequestGroupConfig, +} from './types'; + +/** + * Filters a reviewer based on vote status and if the reviewer is required. + * @param reviewer a reviewer to filter. + * @returns whether or not to filter the `reviewer`. + */ +export function reviewerFilter(reviewer: Reviewer): boolean { + return reviewer.voteStatus === PullRequestVoteStatus.NoVote + ? !!reviewer.isRequired + : !reviewer.isContainer; +} + +/** + * Removes values from the provided array and returns them. + * @param arr the array to extract values from. + * @param filter a filter used to extract values from the provided array. + * @returns the values that were extracted from the array. + * + * @example + * ```ts + * const numbers = [1, 2, 3, 4, 5, 6]; + * const numberFilter = (num: number): boolean => num > 3; + * const extractedNumbers = arrayExtract(numbers, numberFilter); + * console.log(numbers); // [1, 2, 3] + * console.log(extractedNumbers); // [4, 5, 6] + * ``` + * + * @example + * ```ts + * const numbers = [1, 2, 3, 4, 5, 6]; + * const numberFilter = (num: number): boolean => num % 2 === 0; + * const extractedNumbers = arrayExtract(numbers, numberFilter); + * console.log(numbers); // [1, 3, 5] + * console.log(extractedNumbers); // [2, 4, 6] + * ``` + */ +export function arrayExtract(arr: T[], filter: (value: T) => unknown): T[] { + const extractedValues: T[] = []; + + for (let i = 0; i - extractedValues.length < arr.length; i++) { + const offsetIndex = i - extractedValues.length; + + const value = arr[offsetIndex]; + + if (filter(value)) { + arr.splice(offsetIndex, 1); + extractedValues.push(value); + } + } + + return extractedValues; +} + +/** + * Creates groups of pull requests based on a list of `PullRequestGroupConfig`. + * @param pullRequests all pull requests to be split up into groups. + * @param configs the config used for splitting up the pull request groups. + * @returns a list of pull request groups. + */ +export function getPullRequestGroups( + pullRequests: DashboardPullRequest[] | undefined, + configs: PullRequestGroupConfig[], +): PullRequestGroup[] | undefined { + if (!pullRequests) { + return undefined; + } + + const remainingPullRequests: DashboardPullRequest[] = [...pullRequests]; + const pullRequestGroups: PullRequestGroup[] = []; + + configs.forEach(({ title, filter: configFilter, simplified }) => { + const groupPullRequests = arrayExtract(remainingPullRequests, configFilter); + + pullRequestGroups.push({ + title, + pullRequests: groupPullRequests, + simplified, + }); + }); + + return pullRequestGroups; +} + +export function getPullRequestGroupConfigs( + columnConfigs: PullRequestColumnConfig[], + filterProcessor: (filters: Filter[]) => Filter[], +): PullRequestGroupConfig[] { + return columnConfigs.map(columnConfig => { + const filters = filterProcessor(columnConfig.filters); + return { + title: columnConfig.title, + filter: createFilter(filters), + simplified: columnConfig.simplified, + }; + }); +} diff --git a/plugins/azure-devops/src/hooks/index.ts b/plugins/azure-devops/src/hooks/index.ts new file mode 100644 index 0000000000..44b94f3f4b --- /dev/null +++ b/plugins/azure-devops/src/hooks/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './useAllTeams'; +export * from './useDashboardPullRequests'; +export * from './useProjectRepoFromEntity'; +export * from './usePullRequests'; +export * from './useRepoBuilds'; +export * from './useUserEmail'; diff --git a/plugins/azure-devops/src/hooks/useAllTeams.ts b/plugins/azure-devops/src/hooks/useAllTeams.ts new file mode 100644 index 0000000000..d32aa5e136 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useAllTeams.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Team } from '@backstage/plugin-azure-devops-common'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; + +export function useAllTeams(): { + teams?: Team[]; + loading: boolean; + error?: Error; +} { + const api = useApi(azureDevOpsApiRef); + + const { + value: teams, + loading, + error, + } = useAsync(() => { + return api.getAllTeams(); + }, [api]); + + return { + teams, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts new file mode 100644 index 0000000000..1b2333ba1c --- /dev/null +++ b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-plugin-api'; +import { useAsyncRetry, useInterval } from 'react-use'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { azureDevOpsApiRef } from '../api'; +import { useCallback } from 'react'; + +const POLLING_INTERVAL = 10000; + +export function useDashboardPullRequests( + project?: string, + pollingInterval: number = POLLING_INTERVAL, +): { + pullRequests?: DashboardPullRequest[]; + loading: boolean; + error?: Error; +} { + const api = useApi(azureDevOpsApiRef); + const errorApi = useApi(errorApiRef); + + const getDashboardPullRequests = useCallback(async (): Promise< + DashboardPullRequest[] + > => { + if (!project) { + return Promise.reject(new Error('Missing project name')); + } + + try { + return await api.getDashboardPullRequests(project); + } catch (error) { + if (error instanceof Error) { + errorApi.post(error); + } + + return Promise.reject(error); + } + }, [project, api, errorApi]); + + const { + value: pullRequests, + loading, + error, + retry, + } = useAsyncRetry( + () => getDashboardPullRequests(), + [getDashboardPullRequests], + ); + + useInterval(() => retry(), pollingInterval); + + return { + pullRequests, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useUserEmail.ts b/plugins/azure-devops/src/hooks/useUserEmail.ts new file mode 100644 index 0000000000..4655815297 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useUserEmail.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { identityApiRef, useApi } from '@backstage/core-plugin-api'; + +export function useUserEmail(): string | undefined { + const identityApi = useApi(identityApiRef); + return identityApi.getProfile().email; +} diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 4da533b534..8289a93a5c 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -13,9 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { azureDevOpsPlugin, EntityAzurePipelinesContent, EntityAzurePullRequestsContent, isAzureDevOpsAvailable, + AzurePullRequestsPage, } from './plugin'; + +export { AzurePullRequestsIcon } from './components/AzurePullRequestsIcon'; + +export { FilterType } from './components/PullRequestsPage'; +export type { + PullRequestColumnConfig, + BaseFilter, + Filter, + PullRequestFilter, + AssignedToUserFilter, + CreatedByUserFilter, + AssignedToTeamFilter, + CreatedByTeamFilter, + AssignedToTeamsFilter, + CreatedByTeamsFilter, + AllFilter, +} from './components/PullRequestsPage'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index 7a5f80d0d3..003c7b7eee 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -16,6 +16,7 @@ import { azurePipelinesEntityContentRouteRef, + azurePullRequestDashboardRouteRef, azurePullRequestsEntityContentRouteRef, } from './routes'; import { @@ -46,6 +47,15 @@ export const azureDevOpsPlugin = createPlugin({ ], }); +export const AzurePullRequestsPage = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'AzurePullRequestsPage', + component: () => + import('./components/PullRequestsPage').then(m => m.PullRequestsPage), + mountPoint: azurePullRequestDashboardRouteRef, + }), +); + export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( createRoutableExtension({ name: 'EntityAzurePipelinesContent', diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts index 7a4adea33e..0d20c12beb 100644 --- a/plugins/azure-devops/src/routes.ts +++ b/plugins/azure-devops/src/routes.ts @@ -16,6 +16,10 @@ import { createRouteRef } from '@backstage/core-plugin-api'; +export const azurePullRequestDashboardRouteRef = createRouteRef({ + id: 'azure-pull-request-dashboard', +}); + export const azurePipelinesEntityContentRouteRef = createRouteRef({ id: 'azure-pipelines-entity-content', }); diff --git a/plugins/azure-devops/src/utils/arrayHas.ts b/plugins/azure-devops/src/utils/arrayHas.ts new file mode 100644 index 0000000000..8dac8b8ec9 --- /dev/null +++ b/plugins/azure-devops/src/utils/arrayHas.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 function arrayHas(arr: T[], value: T): boolean { + return new Set(arr).has(value); +} + +export function stringArrayHas( + arr: Array, + value: string | undefined, + ignoreCase: boolean = false, +): boolean { + if (ignoreCase) { + return arrayHas( + arr.map(a => a?.toLocaleLowerCase('en-US')), + value?.toLocaleLowerCase('en-US'), + ); + } + + return arrayHas(arr, value); +} diff --git a/plugins/azure-devops/src/utils/equalsIgnoreCase.ts b/plugins/azure-devops/src/utils/equalsIgnoreCase.ts new file mode 100644 index 0000000000..12b058e6ae --- /dev/null +++ b/plugins/azure-devops/src/utils/equalsIgnoreCase.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 function equalsIgnoreCase(str1: string, str2: string): boolean { + return str1.toLocaleLowerCase('en-US') === str2.toLocaleLowerCase('en-US'); +} diff --git a/plugins/azure-devops/src/utils/index.ts b/plugins/azure-devops/src/utils/index.ts new file mode 100644 index 0000000000..f2214e8106 --- /dev/null +++ b/plugins/azure-devops/src/utils/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './arrayHas'; +export * from './equalsIgnoreCase'; +export * from './getDurationFromDates'; diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index 736d079969..e1cdfa1490 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-badges-backend +## 0.1.13 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/backend-common@0.9.12 + ## 0.1.12 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 6f44e96b37..c481866a0a 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges-backend", "description": "A Backstage backend plugin that generates README badges for your entities", - "version": "0.1.12", + "version": "0.1.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index 449352f87b..049709e8be 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-badges +## 0.2.16 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.15 ### Patch Changes diff --git a/plugins/badges/package.json b/plugins/badges/package.json index be38144ada..9d35a3b9e2 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges", "description": "A Backstage plugin that generates README badges for your entities", - "version": "0.2.15", + "version": "0.2.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,24 +28,25 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.5", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index 499c144fca..884daa08ea 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const badgesApiRef = createApiRef({ id: 'plugin.badges.client', - description: 'Used to make requests to the badges backend', }); export type BadgeStyle = diff --git a/plugins/bazaar-backend/CHANGELOG.md b/plugins/bazaar-backend/CHANGELOG.md index 812ae8f5e3..2646731535 100644 --- a/plugins/bazaar-backend/CHANGELOG.md +++ b/plugins/bazaar-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-bazaar-backend +## 0.1.4 + +### Patch Changes + +- 210fcf63ee: Handle migration error when old data is present in the database +- Updated dependencies + - @backstage/backend-common@0.9.13 + ## 0.1.3 ### Patch Changes diff --git a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js index d4cb87bd82..ea3a89ea72 100644 --- a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js +++ b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js @@ -29,6 +29,7 @@ exports.up = async function up(knex) { .comment('Optional end date of the project (ISO 8601 format)'); table .text('responsible') + .defaultTo('') .notNullable() .comment('Contact person of the project'); }); diff --git a/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js b/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js new file mode 100644 index 0000000000..03ed0a8d05 --- /dev/null +++ b/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js @@ -0,0 +1,171 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('metadata'); + await knex.schema.createTable('metadata', table => { + table.increments('id').comment('Automatically generated unique ID'); + table.text('entity_ref').unique().comment('The ref of the entity'); + table.text('name').notNullable().comment('The name of the entity'); + table + .text('community') + .comment('Link to where the community can discuss ideas'); + table + .text('description') + .notNullable() + .comment('The description of the Bazaar project'); + table + .text('status') + .defaultTo('proposed') + .notNullable() + .comment('The status of the Bazaar project'); + table + .text('updated_at') + .notNullable() + .comment('Timestamp on ISO 8601 format when entity was last updated'); + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); + + await knex.schema.dropTable('members'); + await knex.schema.createTable('members', table => { + table + .integer('item_id') + .references('metadata.id') + .onDelete('CASCADE') + .comment('Id of the associated item'); + table + .text('entity_ref') + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + table.text('user_id').notNullable().comment('The user id of the member'); + table + .dateTime('join_date') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this member joined'); + table.text('picture').comment('Link to profile picture'); + }); + } else { + await knex.schema.alterTable('metadata', table => { + table.renameColumn('announcement', 'description'); + table.increments('id').comment('Automatically generated unique ID'); + table.string('entity_ref').nullable().alter(); + }); + + await knex.schema.alterTable('members', table => { + table + .integer('item_id') + .references('metadata.id') + .onDelete('CASCADE') + .comment('Id of the associated item'); + table.dropColumn('entity_ref'); + }); + } +}; + +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('metadata'); + await knex.schema.createTable('metadata', table => { + table + .text('entity_ref') + .notNullable() + .unique() + .comment('The ref of the entity'); + table.text('name').notNullable().comment('The name of the entity'); + table + .text('community') + .comment('Link to where the community can discuss ideas'); + table + .text('announcement') + .notNullable() + .comment('The announcement of the Bazaar project'); + table + .text('status') + .defaultTo('proposed') + .notNullable() + .comment('The status of the Bazaar project'); + table + .text('updated_at') + .notNullable() + .comment('Timestamp on ISO 8601 format when entity was last updated'); + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); + + await knex.schema.dropTable('members'); + await knex.schema.createTable('members', table => { + table + .text('entity_ref') + .notNullable() + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + table.text('user_id').notNullable().comment('The user id of the member'); + table + .dateTime('join_date') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this member joined'); + table.text('picture').comment('Link to profile picture'); + }); + } else { + await knex.schema.alterTable('metadata', table => { + table.renameColumn('description', 'announcement'); + table.string('entity_ref').notNullable().alter(); + table.dropColumn('id'); + }); + + await knex.schema.alterTable('members', table => { + table.dropColumn('item_id'); + table + .text('entity_ref') + .notNullable() + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + }); + } +}; diff --git a/plugins/bazaar-backend/package.json b/plugins/bazaar-backend/package.json index 9c6426fcba..aefe3bfdc6 100644 --- a/plugins/bazaar-backend/package.json +++ b/plugins/bazaar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar-backend", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", - "@backstage/backend-test-utils": "^0.1.9", + "@backstage/backend-common": "^0.9.13", + "@backstage/backend-test-utils": "^0.1.10", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1" + "@backstage/cli": "^0.10.1" }, "files": [ "dist", diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts index 3afde44d5e..3099e5bbb2 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts @@ -22,13 +22,14 @@ const bazaarProject: any = { entityRef: 'ref1', community: '', status: 'proposed', - announcement: 'a', + description: 'a', membersCount: 0, startDate: '2021-11-07T13:27:00.000Z', endDate: null, size: 'small', responsible: 'r', }; + describe('DatabaseHandler', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], @@ -50,7 +51,7 @@ describe('DatabaseHandler', () => { await knex('metadata').insert({ entity_ref: bazaarProject.entityRef, name: bazaarProject.name, - announcement: bazaarProject.announcement, + description: bazaarProject.description, community: bazaarProject.community, status: bazaarProject.status, updated_at: new Date().toISOString(), @@ -60,10 +61,10 @@ describe('DatabaseHandler', () => { responsible: bazaarProject.responsible, }); - const res = await dbHandler.getMetadata('ref1'); + const res = await dbHandler.getMetadataByRef('ref1'); expect(res).toHaveLength(1); - expect(res[0].announcement).toEqual('a'); + expect(res[0].description).toEqual('a'); expect(res[0].community).toEqual(''); expect(res[0].status).toEqual('proposed'); expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z'); diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.ts index 60f579af2d..7da06a54b8 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.ts @@ -44,10 +44,10 @@ export class DatabaseHandler { } private columns = [ - 'members.entity_ref', + 'metadata.id', 'metadata.entity_ref', 'metadata.name', - 'metadata.announcement', + 'metadata.description', 'metadata.status', 'metadata.updated_at', 'metadata.community', @@ -57,40 +57,52 @@ export class DatabaseHandler { 'metadata.responsible', ]; - async getMembers(entityRef: string) { + async getMembers(id: string) { return await this.database .select('*') .from('members') - .where({ entity_ref: entityRef }); + .where({ item_id: id }); } - async addMember(userId: string, entityRef: string, picture?: string) { + async addMember(id: number, userId: string, picture?: string) { await this.database .insert({ - entity_ref: entityRef, + item_id: id, user_id: userId, picture: picture, }) .into('members'); } - async deleteMember(userId: string, entityRef: string) { + async deleteMember(id: number, userId: string) { return await this.database('members') - .where({ entity_ref: decodeURIComponent(entityRef) }) + .where({ item_id: id }) .andWhere('user_id', userId) .del(); } - async getMetadata(entityRef: string) { + async getMetadataById(id: number) { const coalesce = this.database.raw( - 'coalesce(count(members.entity_ref), 0) as members_count', + 'coalesce(count(members.item_id), 0) as members_count', + ); + + return await this.database('metadata') + .select([...this.columns, coalesce]) + .where({ 'metadata.id': id }) + .groupBy(this.columns) + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); + } + + async getMetadataByRef(entityRef: string) { + const coalesce = this.database.raw( + 'coalesce(count(members.item_id), 0) as members_count', ); return await this.database('metadata') .select([...this.columns, coalesce]) .where({ 'metadata.entity_ref': entityRef }) .groupBy(this.columns) - .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); } async insertMetadata(bazaarProject: any) { @@ -98,7 +110,7 @@ export class DatabaseHandler { name, entityRef, community, - announcement, + description, status, size, startDate, @@ -108,11 +120,11 @@ export class DatabaseHandler { await this.database .insert({ - name: name, + name, entity_ref: entityRef, - community: community, - announcement: announcement, - status: status, + community, + description, + status, updated_at: new Date().toISOString(), size, start_date: startDate, @@ -124,9 +136,11 @@ export class DatabaseHandler { async updateMetadata(bazaarProject: any) { const { + name, + id, entityRef, community, - announcement, + description, status, size, startDate, @@ -134,34 +148,32 @@ export class DatabaseHandler { responsible, } = bazaarProject; - return await this.database('metadata') - .where({ entity_ref: entityRef }) - .update({ - announcement: announcement, - community: community, - status: status, - updated_at: new Date().toISOString(), - size, - start_date: startDate, - end_date: endDate, - responsible, - }); + return await this.database('metadata').where({ id: id }).update({ + name, + entity_ref: entityRef, + description, + community, + status, + updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, + }); } - async deleteMetadata(entityRef: string) { - return await this.database('metadata') - .where({ entity_ref: entityRef }) - .del(); + async deleteMetadata(id: number) { + return await this.database('metadata').where({ id: id }).del(); } - async getEntities() { + async getProjects() { const coalesce = this.database.raw( - 'coalesce(count(members.entity_ref), 0) as members_count', + 'coalesce(count(members.item_id), 0) as members_count', ); return await this.database('metadata') .select([...this.columns, coalesce]) .groupBy(this.columns) - .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); } } diff --git a/plugins/bazaar-backend/src/service/router.ts b/plugins/bazaar-backend/src/service/router.ts index f0bc02c65b..730d958fcd 100644 --- a/plugins/bazaar-backend/src/service/router.ts +++ b/plugins/bazaar-backend/src/service/router.ts @@ -40,28 +40,27 @@ export async function createRouter( const router = Router(); router.use(express.json()); - router.get('/members/:ref', async (request, response) => { - const entity_ref = decodeURIComponent(request.params.ref); - const data = await dbHandler.getMembers(entity_ref); + router.get('/projects/:id/members', async (request, response) => { + const members = await dbHandler.getMembers(request.params.id); - if (data?.length) { - response.json({ status: 'ok', data: data }); + if (members?.length) { + response.json({ status: 'ok', data: members }); } else { response.json({ status: 'ok', data: [] }); } }); - router.put('/member', async (request, response) => { - const { user_id, entity_ref, picture } = request.body; - await dbHandler.addMember(user_id, entity_ref, picture); + router.put('/projects/:id/member/:userId', async (request, response) => { + const { id, userId } = request.params; + await dbHandler.addMember(parseInt(id, 10), userId, request.body?.picture); response.json({ status: 'ok' }); }); - router.delete('/member/:ref/:id', async (request, response) => { - const { ref, id } = request.params; + router.delete('/projects/:id/member/:userId', async (request, response) => { + const { id, userId } = request.params; - const count = await dbHandler.deleteMember(id, ref); + const count = await dbHandler.deleteMember(parseInt(id, 10), userId); if (count) { response.json({ status: 'ok' }); @@ -70,36 +69,48 @@ export async function createRouter( } }); - router.get('/metadata/:ref', async (request, response) => { + router.get('/projects/id/:id', async (request, response) => { + const id = decodeURIComponent(request.params.id); + + const data = await dbHandler.getMetadataById(parseInt(id, 10)); + + response.json({ status: 'ok', data: data }); + }); + + router.get('/projects/ref/:ref', async (request, response) => { const ref = decodeURIComponent(request.params.ref); - const data = await dbHandler.getMetadata(ref); + const data = await dbHandler.getMetadataByRef(ref); response.json({ status: 'ok', data: data }); }); - router.get('/entities', async (_, response) => { - const data = await dbHandler.getEntities(); + router.get('/projects', async (_, response) => { + const data = await dbHandler.getProjects(); response.json({ status: 'ok', data: data }); }); - router.put('/metadata', async (request, response) => { + router.put('/projects', async (request, response) => { const bazaarProject = request.body; const count = await dbHandler.updateMetadata(bazaarProject); if (count) { response.json({ status: 'ok' }); - } else { - await dbHandler.insertMetadata(bazaarProject); - response.json({ status: 'ok' }); } }); - router.delete('/metadata/:ref', async (request, response) => { - const ref = decodeURIComponent(request.params.ref); + router.post('/projects', async (request, response) => { + const bazaarProject = request.body; - const count = await dbHandler.deleteMetadata(ref); + await dbHandler.insertMetadata(bazaarProject); + response.json({ status: 'ok' }); + }); + + router.delete('/projects/:id', async (request, response) => { + const id = decodeURIComponent(request.params.id); + + const count = await dbHandler.deleteMetadata(parseInt(id, 10)); if (count) { response.json({ status: 'ok' }); diff --git a/plugins/bazaar/CHANGELOG.md b/plugins/bazaar/CHANGELOG.md index 8988bf5d62..664b69a4a4 100644 --- a/plugins/bazaar/CHANGELOG.md +++ b/plugins/bazaar/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-bazaar +## 0.1.6 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog@0.7.4 + - @backstage/plugin-catalog-react@0.6.5 + - @backstage/cli@0.10.1 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/cli@0.10.0 + - @backstage/core-plugin-api@0.2.2 + ## 0.1.4 ### Patch Changes diff --git a/plugins/bazaar/README.md b/plugins/bazaar/README.md index f07e751984..dea25727b0 100644 --- a/plugins/bazaar/README.md +++ b/plugins/bazaar/README.md @@ -69,61 +69,42 @@ const overviewContent = ( ### Layout -The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. The list of project is paginated. +The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. It is also possible to sort in alphabetical order or on the number of members. Here you can also search or add project to the Bazaar. -![home](media/bazaar_pr_fullscreen.png) +![home](media/layout.png) ### Workflow -To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. +To add a project to the bazaar, simply click on the `add-project` button and fill in the form. The following fields are mandatory: -- announcement - present your idea and what skills you are looking for +- name - name of the project on URL safe format +- description - present your idea and what skills you are looking for - status - whether or not the project has started - size - small, medium or large - responsible - main contact person of the project The other fields are: +- project - link Bazaar project to existing entity in the catalog +- community link - link to where the project members can communicate, e.g. Teams or Discord link - start date - end date -- community link - link where the project members can chat, e.g. Teams or Discord link -When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it. +When clicking on a Bazaar project a card containing the Bazaar information will show up. If the Bazaar project is linked to an entity, the card is also visible on that entity's EntityPage. From that card it is possible to either link or unlink an entity to a project, edit or delete the project and join the project if it seems interesting to you. Once you have joined a project, you will get access to the community link if it exists. -### Database - -The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project. - -**metadata**: - -- name - name of the entity -- entity_ref - namespace/kind/name of the entity -- announcement - announcement of the project and its current need of skills/team member -- status - status of the project, 'proposed' or 'ongoing' -- updated_at - date when the Bazaar information was last modified (ISO 8601 format) -- size - small, medium or large -- start_date - date when the project is estimated to start (ISO 8601 format) -- end_date - date when the project is estimated to end (ISO 8601 format) -- responsible - main contact person of the project - -**members**: - -- entity_ref - namespace/kind/name of the entity -- user_name -- join_date - date when the user joined the project (ISO 8601 format) +![home](media/demo.gif) ## Future work and ideas - Workflow - - Make it possible to add a Bazaar project without linking it to a Backstage entity, this would make it easier to just add an idea to the Bazaar. + - Make it possible for multiple Bazaar project to link to the same catalog entity - Bazaar landing page - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities etc. - - Make it possible to sort the project based on e.g. the number of members - Bazaar tab on the EntityPage @@ -133,6 +114,3 @@ The metadata related to the Bazaar is stored in a database. Right now there are - Dialogues - Extend the dialogue for adding a project with more fields, e.g. the possibility to add images - -- Testing - - Add tests to all the components diff --git a/plugins/bazaar/media/bazaar_pr_fullscreen.png b/plugins/bazaar/media/bazaar_pr_fullscreen.png deleted file mode 100644 index 6c5cf1277b..0000000000 Binary files a/plugins/bazaar/media/bazaar_pr_fullscreen.png and /dev/null differ diff --git a/plugins/bazaar/media/demo.gif b/plugins/bazaar/media/demo.gif new file mode 100644 index 0000000000..6369ce7551 Binary files /dev/null and b/plugins/bazaar/media/demo.gif differ diff --git a/plugins/bazaar/media/layout.png b/plugins/bazaar/media/layout.png new file mode 100644 index 0000000000..cc02a374a3 Binary files /dev/null and b/plugins/bazaar/media/layout.png differ diff --git a/plugins/bazaar/package.json b/plugins/bazaar/package.json index bb45a7b974..6ccda73f93 100644 --- a/plugins/bazaar/package.json +++ b/plugins/bazaar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar", - "version": "0.1.4", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,11 +22,11 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/cli": "^0.9.0", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/cli": "^0.10.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog": "^0.7.4", + "@backstage/plugin-catalog-react": "^0.6.5", "@date-io/luxon": "1.x", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -34,15 +34,17 @@ "@material-ui/pickers": "^3.3.10", "@testing-library/jest-dom": "^5.10.1", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "material-ui-search-bar": "^1.0.0", "react-hook-form": "^7.13.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/dev-utils": "^0.2.13", + "@backstage/cli": "^0.10.2", + "@backstage/dev-utils": "^0.2.14", "@testing-library/jest-dom": "^5.10.1", "cross-fetch": "^3.0.6" }, diff --git a/plugins/bazaar/plugin-bazaar.api.md b/plugins/bazaar/plugin-bazaar.api.md new file mode 100644 index 0000000000..33a674ffc4 --- /dev/null +++ b/plugins/bazaar/plugin-bazaar.api.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-bazaar" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import * as _backstage_core_plugin_api from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const BazaarPage: () => JSX.Element; + +// @public (undocumented) +export const bazaarPlugin: _backstage_core_plugin_api.BackstagePlugin< + { + root: _backstage_core_plugin_api.RouteRef; + }, + {} +>; + +// @public (undocumented) +export const EntityBazaarInfoCard: () => JSX.Element; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/bazaar/src/api.ts b/plugins/bazaar/src/api.ts index db20e5e0cb..9f5010ec43 100644 --- a/plugins/bazaar/src/api.ts +++ b/plugins/bazaar/src/api.ts @@ -14,33 +14,34 @@ * limitations under the License. */ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { createApiRef, DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; -import { BazaarProject } from './types'; export const bazaarApiRef = createApiRef({ id: 'bazaar', - description: 'Used to make requests towards the bazaar backend', }); export interface BazaarApi { - updateMetadata(bazaarProject: BazaarProject): Promise; + updateProject(bazaarProject: any): Promise; - getMetadata(entity: Entity): Promise; + addProject(bazaarProject: any): Promise; - getMembers(entity: Entity): Promise; + getProjectById(id: number): Promise; - deleteMember(entity: Entity): Promise; + getProjectByRef(entityRef: string): Promise; - addMember(entity: Entity): Promise; + getMembers(id: number): Promise; - getEntities(): Promise; + deleteMember(id: number, userId: string): Promise; - deleteEntity(bazaarProject: BazaarProject): Promise; + addMember(id: number, userId: string): Promise; + + getProjects(): Promise; + + deleteProject(id: number): Promise; } export class BazaarClient implements BazaarApi { @@ -55,10 +56,10 @@ export class BazaarClient implements BazaarApi { this.discoveryApi = options.discoveryApi; } - async updateMetadata(bazaarProject: BazaarProject): Promise { + async updateProject(bazaarProject: any): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch(`${baseUrl}/metadata`, { + return await fetch(`${baseUrl}/projects`, { method: 'PUT', headers: { Accept: 'application/json', @@ -68,11 +69,34 @@ export class BazaarClient implements BazaarApi { }).then(resp => resp.json()); } - async getMetadata(entity: Entity): Promise { + async addProject(bazaarProject: any): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); + + return await fetch(`${baseUrl}/projects`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(bazaarProject), + }).then(resp => resp.json()); + } + + async getProjectById(id: number): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); + + const response = await fetch(`${baseUrl}/projects/id/${id}`, { + method: 'GET', + }); + + return response.ok ? response : null; + } + + async getProjectByRef(entityRef: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); const response = await fetch( - `${baseUrl}/metadata/${encodeURIComponent(stringifyEntityRef(entity))}`, + `${baseUrl}/projects/ref/${encodeURIComponent(entityRef)}`, { method: 'GET', }, @@ -81,60 +105,49 @@ export class BazaarClient implements BazaarApi { return response.ok ? response : null; } - async getMembers(entity: Entity): Promise { + async getMembers(id: number): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch( - `${baseUrl}/members/${encodeURIComponent(stringifyEntityRef(entity))}`, - { - method: 'GET', - }, - ).then(resp => resp.json()); + return await fetch(`${baseUrl}/projects/${id}/members`, { + method: 'GET', + }).then(resp => resp.json()); } - async addMember(entity: Entity): Promise { + async addMember(id: number, userId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - await fetch(`${baseUrl}/member`, { + await fetch(`${baseUrl}/projects/${id}/member/${userId}`, { method: 'PUT', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ - entity_ref: stringifyEntityRef(entity), - user_id: this.identityApi.getUserId(), picture: this.identityApi.getProfile()?.picture, }), }); } - async deleteMember(entity: Entity): Promise { + async deleteMember(id: number, userId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - await fetch( - `${baseUrl}/member/${encodeURIComponent( - stringifyEntityRef(entity), - )}/${this.identityApi.getUserId()}`, - { - method: 'DELETE', - }, - ); + await fetch(`${baseUrl}/projects/${id}/member/${userId}`, { + method: 'DELETE', + }); } - async getEntities(): Promise { + async getProjects(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch(`${baseUrl}/entities`, { + return await fetch(`${baseUrl}/projects`, { method: 'GET', }).then(resp => resp.json()); } - async deleteEntity(bazaarProject: BazaarProject): Promise { + async deleteProject(id: number): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - const entityRef = bazaarProject.entityRef as string; - await fetch(`${baseUrl}/metadata/${encodeURIComponent(entityRef)}`, { + await fetch(`${baseUrl}/projects/${id}`, { method: 'DELETE', }); } diff --git a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx index 310ffe051d..4333ff11fd 100644 --- a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx +++ b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { UseFormReset, UseFormGetValues } from 'react-hook-form'; import { useApi } from '@backstage/core-plugin-api'; @@ -39,18 +39,13 @@ export const AddProjectDialog = ({ fetchCatalogEntities, }: Props) => { const bazaarApi = useApi(bazaarApiRef); - const [selectedEntity, setSelectedEntity] = useState( - catalogEntities ? catalogEntities[0] : null, - ); - - useEffect(() => { - setSelectedEntity(catalogEntities ? catalogEntities[0] : null); - }, [catalogEntities]); + const [selectedEntity, setSelectedEntity] = useState(null); const defaultValues = { + name: '', title: 'Add project', community: '', - announcement: '', + description: '', status: 'proposed' as Status, size: 'medium' as Size, responsible: '', @@ -58,59 +53,48 @@ export const AddProjectDialog = ({ endDate: null, }; - const handleListItemClick = (entity: Entity) => { + const handleEntityClick = (entity: Entity) => { setSelectedEntity(entity); }; - const handleCloseDialog = () => { - setSelectedEntity(catalogEntities ? catalogEntities[0] : null); - handleClose(); - }; - - const handleSave: any = async ( + const handleSubmit: any = async ( getValues: UseFormGetValues, reset: UseFormReset, ) => { const formValues = getValues(); + const response = await bazaarApi.addProject({ + ...formValues, + entityRef: selectedEntity ? stringifyEntityRef(selectedEntity) : null, + startDate: formValues.startDate ?? null, + endDate: formValues.endDate ?? null, + } as BazaarProject); - if (selectedEntity) { - await bazaarApi.updateMetadata({ - name: selectedEntity.metadata.name, - entityRef: stringifyEntityRef(selectedEntity), - announcement: formValues.announcement, - status: formValues.status, - community: formValues.community, - membersCount: 0, - size: formValues.size, - startDate: formValues.startDate ?? null, - endDate: formValues.endDate ?? null, - responsible: formValues.responsible, - } as BazaarProject); - + if (response.status === 'ok') { fetchBazaarProjects(); fetchCatalogEntities(); - - handleClose(); - reset(defaultValues); } + + handleClose(); + reset(defaultValues); }; return ( } - handleClose={handleCloseDialog} + handleClose={handleClose} /> ); }; diff --git a/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx b/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx deleted file mode 100644 index 1b6c1331c3..0000000000 --- a/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { Snackbar, IconButton } from '@material-ui/core'; -import CloseIcon from '@material-ui/icons/Close'; -import { Alert } from '@material-ui/lab'; - -type Props = { - open: boolean; - message: JSX.Element; - handleClose: () => void; -}; - -export const AlertBanner = ({ open, message, handleClose }: Props) => { - return ( - - - - - } - > - {message} - - - ); -}; diff --git a/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx b/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx new file mode 100644 index 0000000000..5145dc54b8 --- /dev/null +++ b/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, + makeStyles, + Card, + CardContent, + Typography, + Link, + GridSize, +} from '@material-ui/core'; +import { Avatar } from '@backstage/core-components'; +import { AboutField } from '@backstage/plugin-catalog'; +import { StatusTag } from '../StatusTag'; +import { Member, BazaarProject } from '../../types'; + +const useStyles = makeStyles({ + break: { + wordBreak: 'break-word', + }, +}); + +type Props = { + bazaarProject: BazaarProject; + members: Member[]; + descriptionSize: GridSize; + membersSize: GridSize; +}; + +export const CardContentFields = ({ + bazaarProject, + members, + descriptionSize, + membersSize, +}: Props) => { + const classes = useStyles(); + + return ( +
+ + + + + + {bazaarProject.description + .split('\n') + .map((str: string, i: number) => ( + + {str} + + ))} + + + + + + {members.length ? ( + members.slice(0, 7).map((member: Member) => { + return ( +
+ + + {member?.userId} + +
+ ); + }) + ) : ( +
+ )} + + + + + + + + + + + + {bazaarProject.size} + + + + + + + {bazaarProject.startDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject.endDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject.responsible || ''} + + + + + + +
+ ); +}; diff --git a/plugins/bazaar/src/components/CardContentFields/index.ts b/plugins/bazaar/src/components/CardContentFields/index.ts new file mode 100644 index 0000000000..e6f990322b --- /dev/null +++ b/plugins/bazaar/src/components/CardContentFields/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CardContentFields } from './CardContentFields'; diff --git a/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 0000000000..1e00ac5e30 --- /dev/null +++ b/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import { + CustomDialogTitle, + DialogActions, + DialogContent, +} from '../CustomDialogTitle'; + +type Props = { + open: boolean; + handleClose: () => void; + message: (string | JSX.Element)[]; + type: 'delete' | 'unlink'; + handleSubmit: () => void; +}; + +export const ConfirmationDialog = ({ + open, + handleClose, + message, + type, + handleSubmit, +}: Props) => { + return ( + + + {type.charAt(0).toLocaleUpperCase('en-US') + type.slice(1)} project + + + {message} + + + + + + ); +}; diff --git a/plugins/bazaar/src/components/ConfirmationDialog/index.ts b/plugins/bazaar/src/components/ConfirmationDialog/index.ts new file mode 100644 index 0000000000..a3252eb6a9 --- /dev/null +++ b/plugins/bazaar/src/components/ConfirmationDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ConfirmationDialog } from './ConfirmationDialog'; diff --git a/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx b/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx new file mode 100644 index 0000000000..2cf8b13874 --- /dev/null +++ b/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 IconButton from '@material-ui/core/IconButton'; +import CloseIcon from '@material-ui/icons/Close'; +import Typography from '@material-ui/core/Typography'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import { + Theme, + WithStyles, + withStyles, + createStyles, +} from '@material-ui/core/styles'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogActions from '@material-ui/core/DialogActions'; + +/* + DialogTitleProps, DialogTitle, DialogContent and DialogActions + are copied from the git-release plugin +*/ + +export interface DialogTitleProps extends WithStyles { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }); + +export const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +export const DialogActions = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1), + }, +}))(MuiDialogActions); + +export const CustomDialogTitle = withStyles(styles)( + (props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + + {children} + {onClose ? ( + + + + ) : null} + + ); + }, +); diff --git a/plugins/bazaar/src/components/CustomDialogTitle/index.ts b/plugins/bazaar/src/components/CustomDialogTitle/index.ts new file mode 100644 index 0000000000..8b615ac211 --- /dev/null +++ b/plugins/bazaar/src/components/CustomDialogTitle/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + CustomDialogTitle, + DialogContent, + DialogActions, +} from './CustomDialogTitle'; diff --git a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx index db564a7b68..600247a53f 100644 --- a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx +++ b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx @@ -45,7 +45,6 @@ export const DateSelector = ({ name, control, setValue }: Props) => { - createStyles({ - root: { - margin: 0, - padding: theme.spacing(2), - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }); - -/* - DialogTitleProps, DialogTitle, DialogContent and DialogActions - are copied from the git-release plugin -*/ -export interface DialogTitleProps extends WithStyles { - id: string; - children: React.ReactNode; - onClose: () => void; -} - -const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { - const { children, classes, onClose, ...other } = props; - return ( - - {children} - {onClose ? ( - - - - ) : null} - - ); -}); - -const DialogContent = withStyles((theme: Theme) => ({ - root: { - padding: theme.spacing(2), - }, -}))(MuiDialogContent); - -const DialogActions = withStyles((theme: Theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, -}))(MuiDialogActions); - -type Props = { - bazaarProject: BazaarProject; - openDelete: boolean; - handleClose: () => void; - setIsBazaar: Dispatch>; -}; - -export const DeleteProjectDialog = ({ - bazaarProject, - openDelete, - handleClose, - setIsBazaar, -}: Props) => { - const handleCloseAndClear = () => { - handleClose(); - }; - - const bazaarApi = useApi(bazaarApiRef); - - const handleSubmit = async () => { - await bazaarApi.deleteEntity(bazaarProject); - setIsBazaar(false); - handleCloseAndClear(); - }; - - return ( - - - Delete project - - - - Are you sure you want to delete this project from the Bazaar? - - - - - - - ); -}; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx index b37e734453..eb72ee67dc 100644 --- a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx +++ b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx @@ -17,49 +17,48 @@ import React from 'react'; import { Control, UseFormSetValue } from 'react-hook-form'; import { FormValues } from '../../types'; -import { Typography } from '@material-ui/core'; import { DateSelector } from '../DateSelector/DateSelector'; +import { Typography, makeStyles } from '@material-ui/core'; type Props = { control: Control; setValue: UseFormSetValue; }; +const useStyles = makeStyles({ + container: { + marginTop: '0.25rem', + textAlign: 'center', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + startDate: { + float: 'left', + }, + endDate: { + float: 'right', + }, + dash: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '1.5rem', + margin: '0 1rem', + }, +}); + export const DoubleDateSelector = ({ control, setValue }: Props) => { + const classes = useStyles(); + return ( -
-
+
+
- - - - -
+ - +
diff --git a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx index 41c9e474d4..0b2a8ef391 100644 --- a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx +++ b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx @@ -15,81 +15,119 @@ */ import React, { useState, useEffect } from 'react'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; -import { BazaarProject, FormValues, Size, Status } from '../../types'; +import { BazaarProject, FormValues } from '../../types'; import { bazaarApiRef } from '../../api'; import { UseFormGetValues } from 'react-hook-form'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { Button, makeStyles } from '@material-ui/core'; type Props = { - entity: Entity; bazaarProject: BazaarProject; + openEdit: boolean; + handleEditClose: () => void; + handleCardClose?: () => void; fetchBazaarProject: () => Promise; - open: boolean; - handleClose: () => void; - isAddForm: boolean; }; +const useStyles = makeStyles({ + button: { + marginLeft: '0', + marginRight: 'auto', + }, +}); + export const EditProjectDialog = ({ - entity, bazaarProject, + openEdit, + handleEditClose, + handleCardClose, fetchBazaarProject, - open, - handleClose, }: Props) => { + const classes = useStyles(); + const bazaarApi = useApi(bazaarApiRef); + const [openDelete, setOpenDelete] = useState(false); const [defaultValues, setDefaultValues] = useState({ - announcement: bazaarProject.announcement, - community: bazaarProject.community, - status: bazaarProject.status, - size: bazaarProject.size, - startDate: bazaarProject?.startDate ?? null, - endDate: bazaarProject?.endDate ?? null, - responsible: bazaarProject.responsible, + ...bazaarProject, + startDate: bazaarProject.startDate ?? null, + endDate: bazaarProject.endDate ?? null, }); - const bazaarApi = useApi(bazaarApiRef); + const handleDeleteClose = () => { + setOpenDelete(false); + handleEditClose(); + + if (handleCardClose) handleCardClose(); + }; + + const handleDeleteSubmit = async () => { + await bazaarApi.deleteProject(bazaarProject.id); + + handleDeleteClose(); + fetchBazaarProject(); + }; useEffect(() => { setDefaultValues({ - announcement: bazaarProject.announcement, - community: bazaarProject.community, - status: bazaarProject.status, - size: bazaarProject.size, - startDate: bazaarProject?.startDate ?? null, - endDate: bazaarProject?.endDate ?? null, - responsible: bazaarProject.responsible, + ...bazaarProject, + startDate: bazaarProject.startDate ?? null, + endDate: bazaarProject.endDate ?? null, }); }, [bazaarProject]); - const handleSave: any = async (getValues: UseFormGetValues) => { + const handleEditSubmit: any = async ( + getValues: UseFormGetValues, + ) => { const formValues = getValues(); - const updateResponse = await bazaarApi.updateMetadata({ - name: entity.metadata.name, - entityRef: stringifyEntityRef(entity), - announcement: formValues.announcement, - status: formValues.status as Status, - community: formValues.community, + const updateResponse = await bazaarApi.updateProject({ + ...formValues, + id: bazaarProject.id, + entityRef: bazaarProject.entityRef, membersCount: bazaarProject.membersCount, - size: formValues.size as Size, startDate: formValues?.startDate ?? null, endDate: formValues?.endDate ?? null, - responsible: formValues.responsible, }); if (updateResponse.status === 'ok') fetchBazaarProject(); - handleClose(); + handleEditClose(); }; return ( - +
+ {bazaarProject.name}, + ' from the Bazaar?', + ]} + type="delete" + handleSubmit={handleDeleteSubmit} + /> + + { + setOpenDelete(true); + }} + > + Delete project + + } + /> +
); }; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx index acae8f5342..685d400d3e 100644 --- a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx +++ b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx @@ -15,347 +15,48 @@ */ import React, { useState, useEffect } from 'react'; -import { - Grid, - makeStyles, - Card, - CardContent, - CardHeader, - Typography, - Divider, - IconButton, - Popover, - MenuList, - MenuItem, - ListItemText, - Link, -} from '@material-ui/core'; -import { - Progress, - HeaderIconLinkRow, - IconLinkVerticalProps, - Avatar, -} from '@backstage/core-components'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { AboutField } from '@backstage/plugin-catalog'; -import { StatusTag } from '../StatusTag'; -import EditIcon from '@material-ui/icons/Edit'; -import ChatIcon from '@material-ui/icons/Chat'; -import PersonAddIcon from '@material-ui/icons/PersonAdd'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; -import DeleteIcon from '@material-ui/icons/Delete'; -import { EditProjectDialog } from '../EditProjectDialog'; -import { DeleteProjectDialog } from '../DeleteProjectDialog'; -import ExitToAppIcon from '@material-ui/icons/ExitToApp'; -import { useApi, identityApiRef } from '@backstage/core-plugin-api'; -import { Member, BazaarProject } from '../../types'; -import { bazaarApiRef } from '../../api'; -import { Alert } from '@material-ui/lab'; import { useAsyncFn } from 'react-use'; - -const useStyles = makeStyles({ - description: { - wordBreak: 'break-word', - }, - icon: { - marginRight: '1.75rem', - }, - link: { - color: '#9cc9ff', - '&:hover': { - textDecoration: 'underline', - }, - }, - memberLink: { - display: 'block', - marginBottom: '0.3rem', - }, -}); - -const sortMembers = (m1: Member, m2: Member) => { - return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime(); -}; +import { useApi } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { bazaarApiRef } from '../../api'; +import { EntityBazaarInfoContent } from '../EntityBazaarInfoContent'; +import { Card } from '@material-ui/core'; +import { parseBazaarResponse } from '../../util/parseMethods'; export const EntityBazaarInfoCard = () => { const { entity } = useEntity(); - const classes = useStyles(); const bazaarApi = useApi(bazaarApiRef); - const identity = useApi(identityApiRef); - const [anchorEl, setAnchorEl] = useState(); - const [open, setOpen] = useState(false); - const [popoverOpen, setPopoverOpen] = useState(false); - const [openDelete, setOpenDelete] = useState(false); - const [isMember, setIsMember] = useState(false); - const [isBazaar, setIsBazaar] = useState(false); - const [members, fetchMembers] = useAsyncFn(async () => { - const response = await bazaarApi.getMembers(entity); - - const dbMembers = response.data.map((obj: any) => { - const member: Member = { - userId: obj.user_id, - entityRef: obj.entity_ref, - joinDate: obj.join_date, - picture: obj.picture, - }; - - return member; - }); - - dbMembers.sort(sortMembers); - - return dbMembers; - }); const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => { - const response = await bazaarApi.getMetadata(entity); + const response = await bazaarApi.getProjectByRef( + stringifyEntityRef(entity), + ); - if (response) { - const metadata = await response.json().then((resp: any) => resp.data[0]); - - if (metadata) { - return { - entityRef: metadata.entity_ref, - name: metadata.name, - community: metadata.community, - announcement: metadata.announcement, - status: metadata.status, - updatedAt: metadata.updated_at, - membersCount: metadata.members_count, - size: metadata.size, - startDate: metadata.start_date, - endDate: metadata.end_date, - responsible: metadata.responsible, - } as BazaarProject; - } - } - return null; + return await parseBazaarResponse(response); }); + const [isBazaar, setIsBazaar] = useState(bazaarProject.value ?? false); + useEffect(() => { - fetchMembers(); fetchBazaarProject(); - }, [fetchMembers, fetchBazaarProject]); + }, [fetchBazaarProject]); useEffect(() => { - const isBazaarMember = - members?.value - ?.map((member: Member) => member.userId) - .indexOf(identity.getUserId()) >= 0; - const isBazaarProject = bazaarProject.value !== null; + const isBazaarProject = bazaarProject.value !== undefined; - setIsMember(isBazaarMember); setIsBazaar(isBazaarProject); - }, [bazaarProject, members, identity]); + }, [bazaarProject.value]); - const onOpen = (event: React.SyntheticEvent) => { - setAnchorEl(event.currentTarget); - setPopoverOpen(true); - }; - - const closeEdit = () => { - setOpen(false); - }; - - const closeDelete = () => { - setOpenDelete(false); - }; - - const popoverCloseHandler = () => { - setPopoverOpen(false); - }; - - const handleMembersClick = async () => { - if (!isMember) { - await bazaarApi.addMember(entity); - } else { - await bazaarApi.deleteMember(entity); - } - - fetchMembers(); - fetchBazaarProject(); - }; - - const links: IconLinkVerticalProps[] = [ - { - label: isMember ? 'Leave' : 'Join', - icon: isMember ? : , - href: '', - onClick: async () => { - handleMembersClick(); - }, - }, - { - label: 'Community', - icon: , - href: bazaarProject?.value?.community, - disabled: !bazaarProject?.value?.community || !isMember, - }, - ]; - - if (!isBazaar) { - return null; - } else if (bazaarProject.loading || members.loading) { - return ; - } else if (bazaarProject.error) { - return {bazaarProject?.error?.message}; - } else if (members.error) { - return {members?.error?.message}; - } - return ( - - {bazaarProject?.value && ( - + - )} - - {bazaarProject?.value && ( - - )} - - - - } - subheader={} - /> - - - - - { - setOpen(true); - setPopoverOpen(false); - }} - > - - - - - { - setOpenDelete(true); - setPopoverOpen(false); - }} - > - - - - - - - - - {bazaarProject?.value?.announcement - ? bazaarProject?.value?.announcement - .split('\n') - .map((str: string, i: number) => ( - - {str} - - )) - : 'No announcement'} - - - - - {' '} - - {members?.value?.length ? ( - members.value.slice(0, 7).map((member: Member) => { - return ( -
- - - {member?.userId} - -
- ); - }) - ) : ( -
- )} - - - - - - - - - - - - - {bazaarProject?.value?.size} - - - - - - - - {bazaarProject?.value?.startDate?.substring(0, 10) || ''} - - - - - - - - {bazaarProject?.value?.endDate?.substring(0, 10) || ''} - - - - - - - - {bazaarProject?.value?.responsible || ''} - - - - - - - ); + + ); + } + return null; }; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx b/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx new file mode 100644 index 0000000000..f2c005abd1 --- /dev/null +++ b/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx @@ -0,0 +1,200 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CardHeader, Divider, IconButton } from '@material-ui/core'; +import { + HeaderIconLinkRow, + IconLinkVerticalProps, +} from '@backstage/core-components'; +import EditIcon from '@material-ui/icons/Edit'; +import ChatIcon from '@material-ui/icons/Chat'; +import PersonAddIcon from '@material-ui/icons/PersonAdd'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import LinkOffIcon from '@material-ui/icons/LinkOff'; +import { EditProjectDialog } from '../EditProjectDialog'; +import { useApi, identityApiRef } from '@backstage/core-plugin-api'; +import { BazaarProject, Member } from '../../types'; +import { bazaarApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { useAsyncFn } from 'react-use'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; +import { parseEntityRef } from '@backstage/catalog-model'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { CardContentFields } from '../CardContentFields'; +import { fetchProjectMembers } from '../../util/fetchMethods'; + +type Props = { + bazaarProject: BazaarProject | null | undefined; + fetchBazaarProject: () => Promise; +}; + +export const EntityBazaarInfoContent = ({ + bazaarProject, + fetchBazaarProject, +}: Props) => { + const bazaarApi = useApi(bazaarApiRef); + const identity = useApi(identityApiRef); + const [openEdit, setOpenEdit] = useState(false); + const [isMember, setIsMember] = useState(false); + const [openUnlink, setOpenUnlink] = useState(false); + const [members, fetchMembers] = useAsyncFn(async () => { + return bazaarProject + ? await fetchProjectMembers(bazaarApi, bazaarProject) + : []; + }); + + const [userId, fetchUserId] = useAsyncFn(async () => { + return await ( + await identity.getProfileInfo() + ).displayName; + }); + + useEffect(() => { + fetchMembers(); + fetchUserId(); + }, [fetchMembers, fetchUserId]); + + useEffect(() => { + if (members.value && userId.value) { + setIsMember( + members.value + ?.map((member: Member) => member.userId) + .indexOf(userId.value) >= 0, + ); + } + }, [bazaarProject, members, identity, userId.value]); + + const handleMembersClick = async () => { + if (userId.value) { + if (!isMember) { + await bazaarApi.addMember(bazaarProject?.id!, userId.value); + } else { + await bazaarApi.deleteMember(bazaarProject!.id, userId.value); + } + setIsMember(!isMember); + fetchMembers(); + } + }; + + const links: IconLinkVerticalProps[] = [ + { + label: 'Entity page', + icon: , + disabled: true, + }, + { + label: 'Unlink project', + icon: , + disabled: false, + onClick: () => { + setOpenUnlink(true); + }, + }, + { + label: isMember ? 'Leave' : 'Join', + icon: isMember ? : , + href: '', + onClick: async () => { + handleMembersClick(); + }, + }, + { + label: 'Community', + icon: , + href: bazaarProject?.community, + disabled: bazaarProject?.community === '' || !isMember, + }, + ]; + + const handleEditClose = () => { + setOpenEdit(false); + }; + + const handleUnlinkClose = () => { + setOpenUnlink(false); + }; + + const handleUnlinkSubmit = async () => { + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject, + entityRef: null, + }); + + if (updateResponse.status === 'ok') { + handleUnlinkClose(); + fetchBazaarProject(); + } + }; + + if (members.error) { + return {members?.error?.message}; + } + + if (bazaarProject) { + return ( +
+ + + {openUnlink && ( + {parseEntityRef(bazaarProject.entityRef!).name}, + ' from ', + {bazaarProject.name}, + ' ?', + ]} + type="unlink" + handleSubmit={handleUnlinkSubmit} + /> + )} + + + { + setOpenEdit(true); + }} + > + + +
+ } + subheader={} + /> + + + +
+ ); + } + return null; +}; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts b/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts new file mode 100644 index 0000000000..6be5ad7dbf --- /dev/null +++ b/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { EntityBazaarInfoContent } from './EntityBazaarInfoContent'; diff --git a/plugins/bazaar/src/components/HomePage/HomePage.tsx b/plugins/bazaar/src/components/HomePage/HomePage.tsx index 32a16291fc..b34b7ac949 100644 --- a/plugins/bazaar/src/components/HomePage/HomePage.tsx +++ b/plugins/bazaar/src/components/HomePage/HomePage.tsx @@ -36,11 +36,7 @@ export const HomePage = () => { return (
-
+
); diff --git a/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx b/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx new file mode 100644 index 0000000000..daca0aec1d --- /dev/null +++ b/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx @@ -0,0 +1,269 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Card, CardHeader, Divider, IconButton } from '@material-ui/core'; +import { + HeaderIconLinkRow, + IconLinkVerticalProps, +} from '@backstage/core-components'; +import EditIcon from '@material-ui/icons/Edit'; +import ChatIcon from '@material-ui/icons/Chat'; +import PersonAddIcon from '@material-ui/icons/PersonAdd'; +import InsertLinkIcon from '@material-ui/icons/InsertLink'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import CloseIcon from '@material-ui/icons/Close'; +import LinkOffIcon from '@material-ui/icons/LinkOff'; +import { EditProjectDialog } from '../EditProjectDialog'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; +import { + useApi, + identityApiRef, + useRouteRef, +} from '@backstage/core-plugin-api'; +import { Member, BazaarProject } from '../../types'; +import { bazaarApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { useAsyncFn } from 'react-use'; +import { + catalogApiRef, + catalogRouteRef, +} from '@backstage/plugin-catalog-react'; + +import { + parseEntityName, + stringifyEntityRef, + Entity, + parseEntityRef, +} from '@backstage/catalog-model'; + +import { ConfirmationDialog } from '../ConfirmationDialog/ConfirmationDialog'; +import { CardContentFields } from '../CardContentFields/CardContentFields'; +import { LinkProjectDialog } from '../LinkProjectDialog'; +import { + fetchCatalogItems, + fetchProjectMembers, +} from '../../util/fetchMethods'; +import { parseBazaarResponse } from '../../util/parseMethods'; + +type Props = { + initProject: BazaarProject; + handleClose: () => void; + initEntity: Entity; +}; + +export const HomePageBazaarInfoCard = ({ + initProject, + handleClose, + initEntity, +}: Props) => { + const catalogLink = useRouteRef(catalogRouteRef); + const bazaarApi = useApi(bazaarApiRef); + const identity = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + const [openEdit, setOpenEdit] = useState(false); + const [openProjectSelector, setOpenProjectSelector] = useState(false); + const [openUnlink, setOpenUnlink] = useState(false); + const [isMember, setIsMember] = useState(false); + + const [catalogEntities, fetchCatalogEntities] = useAsyncFn(async () => { + const entities = await fetchCatalogItems(catalogApi); + const bazaarProjects = await bazaarApi.getProjects(); + const bazaarLinkedRefs: string[] = bazaarProjects.data + .filter((entity: any) => entity.entity_ref !== null) + .map((entity: any) => entity.entity_ref); + + return entities.filter( + (entity: Entity) => + !bazaarLinkedRefs.includes(stringifyEntityRef(entity)), + ); + }); + + const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => { + const response = await bazaarApi.getProjectById(initProject.id); + return await parseBazaarResponse(response); + }); + + const [members, fetchMembers] = useAsyncFn(async () => { + return fetchProjectMembers(bazaarApi, bazaarProject.value ?? initProject); + }); + + const [userId, fetchUserId] = useAsyncFn(async () => { + return await ( + await identity.getProfileInfo() + ).displayName; + }); + + useEffect(() => { + fetchMembers(); + fetchBazaarProject(); + fetchCatalogEntities(); + fetchUserId(); + }, [fetchMembers, fetchBazaarProject, fetchCatalogEntities, fetchUserId]); + + useEffect(() => { + if (members.value && userId.value) { + setIsMember( + members.value + ?.map((member: Member) => member.userId) + .indexOf(userId.value) >= 0, + ); + } + }, [bazaarProject.value, members, identity, userId.value]); + + const handleMembersClick = async () => { + if (userId.value) { + if (!isMember) { + await bazaarApi.addMember(bazaarProject.value!.id, userId.value); + } else { + await bazaarApi.deleteMember(bazaarProject.value!.id, userId.value); + } + setIsMember(!isMember); + fetchMembers(); + } + }; + + const getEntityPageLink = () => { + if (bazaarProject?.value?.entityRef) { + const { name, kind, namespace } = parseEntityName( + bazaarProject.value.entityRef, + ); + return `${catalogLink()}/${namespace}/${kind}/${name}`; + } + return ''; + }; + + const handleLink = () => { + if (bazaarProject.value?.entityRef) { + setOpenUnlink(true); + } else { + fetchCatalogEntities(); + setOpenProjectSelector(true); + } + }; + + const links: IconLinkVerticalProps[] = [ + { + label: 'Entity page', + icon: , + href: bazaarProject.value?.entityRef ? getEntityPageLink() : '', + disabled: bazaarProject.value?.entityRef === null, + }, + { + label: bazaarProject.value?.entityRef ? 'Unlink project' : 'Link project', + icon: bazaarProject.value?.entityRef ? ( + + ) : ( + + ), + onClick: handleLink, + }, + { + label: isMember ? 'Leave' : 'Join', + icon: isMember ? : , + href: '', + onClick: async () => { + handleMembersClick(); + }, + }, + { + label: 'Community', + icon: , + href: bazaarProject.value?.community, + disabled: !bazaarProject.value?.community || !isMember, + }, + ]; + + const handleUnlinkSubmit = async () => { + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject.value, + entityRef: null, + }); + + if (updateResponse.status === 'ok') { + setOpenUnlink(false); + fetchBazaarProject(); + } + }; + + if (bazaarProject.error) { + return {bazaarProject?.error?.message}; + } else if (members.error) { + return {members?.error?.message}; + } + + return ( +
+ setOpenProjectSelector(false)} + catalogEntities={catalogEntities.value || []} + bazaarProject={bazaarProject.value || initProject} + fetchBazaarProject={fetchBazaarProject} + initEntity={initEntity} + /> + + {openUnlink && ( + setOpenUnlink(false)} + message={[ + 'Are you sure you want to unlink ', + {parseEntityRef(bazaarProject.value?.entityRef!).name}, + ' from ', + {bazaarProject.value?.name}, + ' ?', + ]} + type="unlink" + handleSubmit={handleUnlinkSubmit} + /> + )} + + + setOpenEdit(false)} + handleCardClose={handleClose} + fetchBazaarProject={fetchBazaarProject} + /> + + + setOpenEdit(true)}> + + + + + +
+ } + subheader={} + /> + + + +
+
+ ); +}; diff --git a/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts b/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts new file mode 100644 index 0000000000..0edfba0652 --- /dev/null +++ b/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { HomePageBazaarInfoCard } from './HomePageBazaarInfoCard'; diff --git a/plugins/bazaar/src/components/InputField/InputField.tsx b/plugins/bazaar/src/components/InputField/InputField.tsx index 9932e5b1fc..76eb28da6c 100644 --- a/plugins/bazaar/src/components/InputField/InputField.tsx +++ b/plugins/bazaar/src/components/InputField/InputField.tsx @@ -25,7 +25,7 @@ type Rules = { }; type Props = { - inputType: 'announcement' | 'community' | 'responsible'; + inputType: 'description' | 'community' | 'responsible' | 'name'; error?: FieldError | undefined; control: Control; helperText?: string; diff --git a/plugins/bazaar/src/components/InputSelector/InputSelector.tsx b/plugins/bazaar/src/components/InputSelector/InputSelector.tsx index 25ed459963..b7e9ecd96a 100644 --- a/plugins/bazaar/src/components/InputSelector/InputSelector.tsx +++ b/plugins/bazaar/src/components/InputSelector/InputSelector.tsx @@ -60,12 +60,7 @@ export const InputSelector = ({ name, options, control, error }: Props) => { > {options.map(option => { return ( - + {option} ); diff --git a/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx b/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx new file mode 100644 index 0000000000..92a7344dac --- /dev/null +++ b/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + Dialog, + DialogActions, + Button, + DialogContent, + makeStyles, +} from '@material-ui/core'; +import { ProjectSelector } from '../ProjectSelector'; +import { CustomDialogTitle } from '../CustomDialogTitle'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + +import { bazaarApiRef } from '../../api'; +import { useApi } from '@backstage/core-plugin-api'; + +import { BazaarProject } from '../../types'; + +type Props = { + openProjectSelector: boolean; + handleProjectSelectorClose: () => void; + catalogEntities: Entity[]; + bazaarProject: BazaarProject; + fetchBazaarProject: () => Promise; + initEntity: Entity; +}; + +const useStyles = makeStyles({ + content: { padding: '0 1rem' }, +}); + +export const LinkProjectDialog = ({ + openProjectSelector, + handleProjectSelectorClose, + catalogEntities, + bazaarProject, + fetchBazaarProject, + initEntity, +}: Props) => { + const classes = useStyles(); + const bazaarApi = useApi(bazaarApiRef); + const [selectedEntity, setSelectedEntity] = useState(initEntity); + const handleEntityClick = (entity: Entity) => { + setSelectedEntity(entity); + }; + + const handleSubmit = async () => { + handleProjectSelectorClose(); + + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject, + entityRef: stringifyEntityRef(selectedEntity!), + }); + if (updateResponse.status === 'ok') fetchBazaarProject(); + }; + + return ( + + + Select entity + + + + + + + + + + ); +}; diff --git a/plugins/bazaar/src/components/LinkProjectDialog/index.ts b/plugins/bazaar/src/components/LinkProjectDialog/index.ts new file mode 100644 index 0000000000..68d8e7c89e --- /dev/null +++ b/plugins/bazaar/src/components/LinkProjectDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { LinkProjectDialog } from './LinkProjectDialog'; diff --git a/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx b/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx index b3c8b3e2e7..a558d347a5 100644 --- a/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx +++ b/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx @@ -14,85 +14,94 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { ItemCardHeader } from '@backstage/core-components'; import { Card, CardActionArea, CardContent, + Dialog, makeStyles, Typography, } from '@material-ui/core'; import { StatusTag } from '../StatusTag/StatusTag'; -import { Link as RouterLink } from 'react-router-dom'; -import { catalogRouteRef } from '@backstage/plugin-catalog-react'; -import { useRouteRef } from '@backstage/core-plugin-api'; import { BazaarProject } from '../../types'; -import { parseEntityName } from '@backstage/catalog-model'; import { DateTime } from 'luxon'; +import { HomePageBazaarInfoCard } from '../HomePageBazaarInfoCard'; +import { Entity } from '@backstage/catalog-model'; const useStyles = makeStyles({ statusTag: { display: 'inline-block', whiteSpace: 'nowrap', - marginBottom: '0.5rem', - }, - announcement: { - display: '-webkit-box', - WebkitLineClamp: 5, - WebkitBoxOrient: 'vertical', marginBottom: '0.8rem', + }, + description: { + display: '-webkit-box', + WebkitLineClamp: 7, + WebkitBoxOrient: 'vertical', overflow: 'hidden', + backgroundColor: '', }, memberCount: { float: 'right', }, + content: { height: '13rem', marginBottom: '-0.5rem' }, }); type Props = { - bazaarProject: BazaarProject; + project: BazaarProject; + fetchBazaarProjects: () => Promise; + catalogEntities: Entity[]; }; -export const ProjectCard = ({ bazaarProject }: Props) => { +export const ProjectCard = ({ + project, + fetchBazaarProjects, + catalogEntities, +}: Props) => { const classes = useStyles(); - const { entityRef, name, status, updatedAt, announcement, membersCount } = - bazaarProject; - const catalogLink = useRouteRef(catalogRouteRef); - const { namespace, kind } = parseEntityName(entityRef); + const [openCard, setOpenCard] = useState(false); + const { id, name, status, updatedAt, description, membersCount } = project; + + const handleClose = () => { + setOpenCard(false); + fetchBazaarProjects(); + }; return ( - - - + + - - - - {membersCount === 1 - ? `${membersCount} member` - : `${membersCount} members`} - -
- - {announcement} +
+ + + setOpenCard(true)}> + + + + + {Number(membersCount) === Number(1) + ? `${membersCount} member` + : `${membersCount} members`} -
- - - + + {description} + + + + +
); }; diff --git a/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx b/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx index a65122b26e..08c43d1c2e 100644 --- a/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx +++ b/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx @@ -15,17 +15,7 @@ */ import React from 'react'; -import { - createStyles, - Theme, - withStyles, - WithStyles, -} from '@material-ui/core/styles'; -import MuiDialogTitle from '@material-ui/core/DialogTitle'; -import MuiDialogContent from '@material-ui/core/DialogContent'; -import MuiDialogActions from '@material-ui/core/DialogActions'; -import CloseIcon from '@material-ui/icons/Close'; -import { Button, Dialog, Typography, IconButton } from '@material-ui/core'; +import { Button, Dialog } from '@material-ui/core'; import { useForm, SubmitHandler, @@ -36,61 +26,11 @@ import { InputField } from '../InputField/InputField'; import { InputSelector } from '../InputSelector/InputSelector'; import { FormValues } from '../../types'; import { DoubleDateSelector } from '../DoubleDateSelector/DoubleDateSelector'; - -const styles = (theme: Theme) => - createStyles({ - root: { - margin: 0, - padding: theme.spacing(2), - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }); - -/* - DialogTitleProps, DialogTitle, DialogContent and DialogActions - are copied from the git-release plugin -*/ -export interface DialogTitleProps extends WithStyles { - id: string; - children: React.ReactNode; - onClose: () => void; -} - -const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { - const { children, classes, onClose, ...other } = props; - return ( - - {children} - {onClose ? ( - - - - ) : null} - - ); -}); - -const DialogContent = withStyles((theme: Theme) => ({ - root: { - padding: theme.spacing(2), - }, -}))(MuiDialogContent); - -const DialogActions = withStyles((theme: Theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, -}))(MuiDialogActions); +import { + CustomDialogTitle, + DialogActions, + DialogContent, +} from '../CustomDialogTitle'; type Props = { handleSave: ( @@ -102,6 +42,7 @@ type Props = { defaultValues: FormValues; open: boolean; projectSelector?: JSX.Element; + deleteButton?: JSX.Element; handleClose: () => void; }; @@ -112,6 +53,7 @@ export const ProjectDialog = ({ defaultValues, open, projectSelector, + deleteButton, handleClose, }: Props) => { const { @@ -123,16 +65,16 @@ export const ProjectDialog = ({ setValue, } = useForm({ mode: 'onChange', - defaultValues: defaultValues, + defaultValues, }); - const handleCloseAndClear = () => { - handleClose(); - reset(defaultValues); + const handleSaveForm = () => { + handleSave(getValues, reset); }; - const handleSaveProject = () => { - handleSave(getValues, reset); + const handleCloseDialog = () => { + handleClose(); + reset(defaultValues); }; return ( @@ -140,25 +82,36 @@ export const ProjectDialog = ({ - + {title} - - - {isAddForm && projectSelector} + + + - + {isAddForm && projectSelector} + + + {!isAddForm && deleteButton}
- } - handleClose={handleCloseNoProjects} - /> - +
+ + + { + setSearchValue(newSortMethod); + }} + onCancelSearch={() => { + setSearchValue(''); + }} + /> { setOpenAdd(false); }} @@ -176,10 +191,11 @@ export const SortView = () => { fetchCatalogEntities={fetchCatalogEntities} /> - +
diff --git a/plugins/bazaar/src/types.ts b/plugins/bazaar/src/types.ts index 7d810c4eab..30c07286ac 100644 --- a/plugins/bazaar/src/types.ts +++ b/plugins/bazaar/src/types.ts @@ -17,7 +17,7 @@ import { EntityRef } from '@backstage/catalog-model'; export type Member = { - entityRef: EntityRef; + itemId: number; userId: string; joinDate?: string; picture?: string; @@ -29,10 +29,11 @@ export type Size = 'small' | 'medium' | 'large'; export type BazaarProject = { name: string; - entityRef: EntityRef; + id: number; + entityRef?: EntityRef; community: string; status: Status; - announcement: string; + description: string; updatedAt?: string; membersCount: number; size: Size; @@ -42,7 +43,8 @@ export type BazaarProject = { }; export type FormValues = { - announcement: string; + name: string; + description: string; community: string; status: string; size: Size; diff --git a/plugins/bazaar/src/util/fetchMethods.ts b/plugins/bazaar/src/util/fetchMethods.ts new file mode 100644 index 0000000000..015abae210 --- /dev/null +++ b/plugins/bazaar/src/util/fetchMethods.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { sortMembers } from './sortMethods'; +import { parseMember } from './parseMethods'; +import { BazaarProject, Member } from '../types'; +import { BazaarApi } from '../api'; +import { Entity } from '@backstage/catalog-model'; + +export const fetchProjectMembers = async ( + bazaarApi: BazaarApi, + project: BazaarProject, +): Promise => { + const response = await bazaarApi.getMembers(project.id); + + if (response.data.length > 0) { + const dbMembers = response.data.map((member: any) => { + return parseMember(member); + }); + + dbMembers.sort(sortMembers); + return dbMembers; + } + return []; +}; + +export const fetchCatalogItems = async (catalogApi: any): Promise => { + const entities = await catalogApi.getEntities({ + filter: { + kind: ['Component', 'Resource'], + }, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }); + + return entities.items; +}; diff --git a/plugins/bazaar/src/util/parseMethods.ts b/plugins/bazaar/src/util/parseMethods.ts new file mode 100644 index 0000000000..1b4ebfd308 --- /dev/null +++ b/plugins/bazaar/src/util/parseMethods.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BazaarProject, Member } from '../types'; + +export const parseBazaarProject = (metadata: any): BazaarProject => { + return { + id: metadata.id, + entityRef: metadata.entity_ref, + name: metadata.name, + community: metadata.community, + description: metadata.description, + status: metadata.status, + updatedAt: metadata.updated_at, + membersCount: metadata.members_count, + size: metadata.size, + startDate: metadata.start_date, + endDate: metadata.end_date, + responsible: metadata.responsible, + } as BazaarProject; +}; + +export const parseMember = (member: any): Member => { + return { + itemId: member.item_id, + userId: member.user_id, + joinDate: member.join_date, + picture: member.picture, + } as Member; +}; + +export const parseBazaarResponse = async (response: any) => { + if (response) { + const metadata = await response.json().then((resp: any) => resp.data[0]); + + if (metadata) { + return parseBazaarProject(metadata); + } + } + return null; +}; diff --git a/plugins/bazaar/src/util/sortMethods.ts b/plugins/bazaar/src/util/sortMethods.ts new file mode 100644 index 0000000000..d75639edca --- /dev/null +++ b/plugins/bazaar/src/util/sortMethods.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BazaarProject, Member } from '../types'; + +export const sortMembers = (m1: Member, m2: Member) => { + return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime(); +}; + +export const sortByDate = (a: BazaarProject, b: BazaarProject): number => { + const dateA = new Date(a.updatedAt!).getTime(); + const dateB = new Date(b.updatedAt!).getTime(); + return dateB - dateA; +}; + +export const sortByName = (a: BazaarProject, b: BazaarProject) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } + return 0; +}; + +export const sortByMembers = (a: BazaarProject, b: BazaarProject) => { + return b.membersCount - a.membersCount; +}; diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index 874cbe2c6c..43c2ba1ac6 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-bitrise +## 0.1.19 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.1.18 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.1.17 ### Patch Changes diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 3af16837de..c4a02c7403 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-bitrise", "description": "A Backstage plugin that integrates towards Bitrise", - "version": "0.1.17", + "version": "0.1.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -33,16 +33,17 @@ "lodash": "^4.17.21", "luxon": "^2.0.2", "qs": "^6.9.6", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4", "recharts": "^1.8.5" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/bitrise/src/plugin.ts b/plugins/bitrise/src/plugin.ts index a6f0706999..92beda287c 100644 --- a/plugins/bitrise/src/plugin.ts +++ b/plugins/bitrise/src/plugin.ts @@ -26,8 +26,6 @@ import { export const bitriseApiRef = createApiRef({ id: 'plugin.bitrise.service', - description: - 'Used by the BitriseCI plugin to retrieve information about builds.', }); export const bitrisePlugin = createPlugin({ diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md index 9e769af873..b8e48924dc 100644 --- a/plugins/catalog-backend-module-ldap/CHANGELOG.md +++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-ldap +## 0.3.8 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.19.0 + ## 0.3.7 ### Patch Changes diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index 36cacb6660..2817a0e209 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-ldap", "description": "A Backstage catalog backend modules that helps integrate towards LDAP", - "version": "0.3.7", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", - "@backstage/plugin-catalog-backend": "^0.18.0", + "@backstage/plugin-catalog-backend": "^0.19.0", "@backstage/types": "^0.1.1", "@types/ldapjs": "^2.2.0", "ldapjs": "^2.2.0", @@ -40,7 +40,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/lodash": "^4.14.151" }, "files": [ diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md index c2225b8945..3db08d02fc 100644 --- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md +++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-msgraph +## 0.2.11 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/plugin-catalog-backend@0.19.0 + ## 0.2.10 ### Patch Changes diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index 391a7125de..8e9b99931c 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-msgraph", "description": "A Backstage catalog backend modules that helps integrate towards Microsoft Graph", - "version": "0.2.10", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "@azure/msal-node": "^1.1.0", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/plugin-catalog-backend": "^0.18.0", + "@backstage/plugin-catalog-backend": "^0.19.0", "@microsoft/microsoft-graph-types": "^2.6.0", "@types/node-fetch": "^2.5.12", "lodash": "^4.17.21", @@ -42,8 +42,8 @@ "qs": "^6.9.4" }, "devDependencies": { - "@backstage/backend-common": "^0.9.11", - "@backstage/cli": "^0.9.1", + "@backstage/backend-common": "^0.9.12", + "@backstage/cli": "^0.10.0", "@backstage/test-utils": "^0.1.23", "@types/lodash": "^4.14.151", "msw": "^0.35.0" diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index ce19314a50..ce502f8f46 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,60 @@ # @backstage/plugin-catalog-backend +## 0.19.2 + +### Patch Changes + +- 3368f27aef: Fixed the handling of optional locations so that the catalog no longer logs `NotFoundError`s for missing optional locations. +- Updated dependencies + - @backstage/backend-common@0.9.14 + - @backstage/catalog-model@0.9.8 + +## 0.19.1 + +### Patch Changes + +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- 6bccc7d794: The `pagedRequest` method in the GitLab ingestion client is now public for re-use and may be used to make other calls to the GitLab API. Developers can now pass in a type into the GitLab `paginated` and `pagedRequest` functions as generics instead of forcing `any` (defaults to `any` to maintain compatibility). The `GitLabClient` now provides a `isSelfManaged` convenience method. +- 0e4daaa753: Reject catalog entities that have duplicate fields that vary only in casing. +- 98a9c35f0c: Honor database migration configuration +- Updated dependencies + - @backstage/backend-common@0.9.13 + +## 0.19.0 + +### Minor Changes + +- 905dd952ac: **BREAKING** `DefaultCatalogCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: + + ```diff + // packages/backend/src/plugins/search.ts + + ... + export default async function createPlugin({ + ... + + tokenManager, + }: PluginEnvironment) { + ... + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + + tokenManager, + }), + }); + + ... + } + ``` + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + ## 0.18.0 ### Minor Changes diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 43eb8d61b7..f773905ca0 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -752,13 +752,7 @@ export type DbPageInfo = // // @public (undocumented) export class DefaultCatalogCollator implements DocumentCollator { - constructor({ - discovery, - locationTemplate, - filter, - catalogClient, - tokenManager, - }: { + constructor(options: { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; locationTemplate?: string; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 0628969ea3..d49723b7d2 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend", "description": "The Backstage backend plugin that provides the Backstage catalog", - "version": "0.18.0", + "version": "0.19.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/catalog-client": "^0.5.2", - "@backstage/catalog-model": "^0.9.7", + "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/search-common": "^0.2.1", "@backstage/types": "^0.1.1", "@octokit/graphql": "^4.5.8", @@ -62,9 +62,9 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.9", - "@backstage/cli": "^0.9.1", - "@backstage/test-utils": "^0.1.23", + "@backstage/backend-test-utils": "^0.1.10", + "@backstage/cli": "^0.10.2", + "@backstage/test-utils": "^0.1.24", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts index ab053e8748..463d982ca2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts @@ -165,6 +165,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { type: 'url', target: 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -174,6 +175,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { type: 'url', target: 'https://dev.azure.com/shopify/engineering/_git/ios-app?path=/src/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -211,6 +213,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { type: 'url', target: 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -249,6 +252,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { type: 'url', target: 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/src/main/catalog.yaml', + presence: 'optional', }, optional: true, }); diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts index 58ee2155e9..aeac64f54d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts @@ -95,6 +95,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { { type: 'url', target: `${baseUrl}/${org}/${project}/_git/${file.repository.name}?path=${file.path}`, + presence: 'optional', }, // Not all locations may actually exist, since the user defined them as a wildcard pattern. // Thus, we emit them as optional and let the downstream processor find them while not outputting diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketDiscoveryProcessor.test.ts index 2a63605b0d..3f8d0ba0fb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BitbucketDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketDiscoveryProcessor.test.ts @@ -208,6 +208,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.mycompany.com/projects/backstage/repos/backstage/browse/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -217,6 +218,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.mycompany.com/projects/demo/repos/demo/browse/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -242,6 +244,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.mycompany.com/projects/backstage/repos/techdocs-cli/browse/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -263,6 +266,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.mycompany.com/projects/backstage/repos/test/browse/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -291,6 +295,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.mycompany.com/projects/backstage/repos/techdocs-cli/browse/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -334,6 +339,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -343,6 +349,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-two/src/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -370,6 +377,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/my/nested/path/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -379,6 +387,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-two/src/master/my/nested/path/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -406,6 +415,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -415,6 +425,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-two/src/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -441,6 +452,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -468,6 +480,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-three/src/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -493,6 +506,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -528,6 +542,7 @@ describe('BitbucketDiscoveryProcessor', () => { type: 'url', target: 'https://bitbucket.org/myworkspace/repository-one/src/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts index a0e44d7648..311de05673 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts @@ -201,6 +201,7 @@ describe('GitlabDiscoveryProcessor', () => { location: { type: 'url', target: 'https://gitlab.fake/1/-/blob/main/catalog-info.yaml', + presence: 'optional', }, optional: true, }, @@ -209,6 +210,7 @@ describe('GitlabDiscoveryProcessor', () => { location: { type: 'url', target: 'https://gitlab.fake/2/-/blob/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }, @@ -246,6 +248,7 @@ describe('GitlabDiscoveryProcessor', () => { location: { type: 'url', target: 'https://gitlab.fake/1/-/blob/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts index 445f02829e..70ccd44793 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts @@ -113,6 +113,7 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor { // The alternative is using the `buildRawUrl` function, which does not support subgroups, so providing a raw // URL here won't work either. target: `${project.web_url}/-/blob/${project_branch}/${catalogPath}`, + presence: 'optional', }, true, ), diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts index 12d9e66659..59c25bb051 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts @@ -157,6 +157,7 @@ describe('GithubDiscoveryProcessor', () => { type: 'url', target: 'https://github.com/backstage/backstage/blob/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -165,6 +166,7 @@ describe('GithubDiscoveryProcessor', () => { location: { type: 'url', target: 'https://github.com/backstage/demo/blob/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -197,6 +199,7 @@ describe('GithubDiscoveryProcessor', () => { type: 'url', target: 'https://github.com/backstage/tech-docs/blob/main/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -251,6 +254,7 @@ describe('GithubDiscoveryProcessor', () => { type: 'url', target: 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + presence: 'optional', }, optional: true, }); @@ -306,6 +310,7 @@ describe('GithubDiscoveryProcessor', () => { type: 'url', target: 'https://github.com/backstage/techdocs-cli/blob/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -315,6 +320,7 @@ describe('GithubDiscoveryProcessor', () => { type: 'url', target: 'https://github.com/backstage/techdocs-container/blob/master/catalog.yaml', + presence: 'optional', }, optional: true, }); @@ -370,6 +376,7 @@ describe('GithubDiscoveryProcessor', () => { location: { type: 'url', target: 'https://github.com/backstage/test/blob/master/catalog.yaml', + presence: 'optional', }, optional: true, }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts index b104d87542..9bd6cc775e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts @@ -125,6 +125,7 @@ export class GithubDiscoveryProcessor implements CatalogProcessor { { type: 'url', target: `${repository.url}${path}`, + presence: 'optional', }, // Not all locations may actually exist, since the user defined them as a wildcard pattern. // Thus, we emit them as optional and let the downstream processor find them while not outputting diff --git a/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.test.ts b/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.test.ts index bc6c5a540f..397d259bae 100644 --- a/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.test.ts @@ -29,6 +29,7 @@ describe('BitbucketRepositoryParser', () => { { type: 'url', target: `${browseUrl}${path}`, + presence: 'optional', }, true, ), diff --git a/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.ts b/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.ts index 27f07d58a2..34a9284b3e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.ts +++ b/plugins/catalog-backend/src/ingestion/processors/bitbucket/BitbucketRepositoryParser.ts @@ -30,6 +30,7 @@ export const defaultRepositoryParser: BitbucketRepositoryParser = { type: 'url', target: target, + presence: 'optional', }, // Not all locations may actually exist, since the user defined them as a wildcard pattern. // Thus, we emit them as optional and let the downstream processor find them while not outputting diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.test.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.test.ts new file mode 100644 index 0000000000..b249137740 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.test.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { setupRequestMockHandlers } from '@backstage/test-utils'; +import { readGitLabIntegrationConfig } from '@backstage/integration'; +import { getVoidLogger } from '@backstage/backend-common'; +import { rest } from 'msw'; +import { setupServer, SetupServerApi } from 'msw/node'; + +import { GitLabClient, paginated } from './client'; + +const server = setupServer(); +setupRequestMockHandlers(server); + +const MOCK_CONFIG = readGitLabIntegrationConfig( + new ConfigReader({ + host: 'example.com', + token: 'test-token', + apiBaseUrl: 'https://example.com/api/v4', + }), +); +const FAKE_PAGED_ENDPOINT = `/some-endpoint`; +const FAKE_PAGED_URL = `${MOCK_CONFIG.apiBaseUrl}${FAKE_PAGED_ENDPOINT}`; + +function setupFakeFourPageURL(srv: SetupServerApi, url: string) { + srv.use( + rest.get(url, (req, res, ctx) => { + const page = req.url.searchParams.get('page'); + const currentPage = page ? Number(page) : 1; + const fakePageCount = 4; + + return res( + // set next page number header if page requested is less than count + ctx.set( + 'x-next-page', + currentPage < fakePageCount ? String(currentPage + 1) : '', + ), + ctx.json([{ someContentOfPage: currentPage }]), + ); + }), + ); +} + +function setupFakeGroupProjectsEndpoint( + srv: SetupServerApi, + apiBaseUrl: string, + groupID: string, +) { + srv.use( + rest.get(`${apiBaseUrl}/groups/${groupID}/projects`, (_, res, ctx) => { + return res( + ctx.set('x-next-page', ''), + ctx.json([ + { + id: 1, + description: 'Project One Description', + name: 'Project One', + path: 'project-one', + }, + ]), + ); + }), + ); +} + +function setupFakeInstanceProjectsEndpoint( + srv: SetupServerApi, + apiBaseUrl: string, +) { + srv.use( + rest.get(`${apiBaseUrl}/projects`, (_, res, ctx) => { + return res( + ctx.set('x-next-page', ''), + ctx.json([ + { + id: 1, + description: 'Project One Description', + name: 'Project One', + path: 'project-one', + }, + { + id: 2, + description: 'Project Two Description', + name: 'Project Two', + path: 'project-two', + }, + ]), + ); + }), + ); +} + +describe('GitLabClient', () => { + describe('isSelfManaged', () => { + it('returns true if self managed instance', () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader({ + host: 'example.com', + token: 'test-token', + apiBaseUrl: 'https://example.com/api/v4', + }), + ), + logger: getVoidLogger(), + }); + expect(client.isSelfManaged()).toBeTruthy(); + }); + it('returns false if gitlab.com', () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader({ + host: 'gitlab.com', + token: 'test-token', + }), + ), + logger: getVoidLogger(), + }); + expect(client.isSelfManaged()).toBeFalsy(); + }); + }); + + describe('pagedRequest', () => { + beforeEach(() => { + // setup fake paginated endpoint with 4 pages each returning one item + setupFakeFourPageURL(server, FAKE_PAGED_URL); + }); + + it('should provide immediate items within the page', async () => { + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const { items } = await client.pagedRequest(FAKE_PAGED_ENDPOINT); + // fake page contains exactly one item + expect(items).toHaveLength(1); + }); + + it('should request items for a given page number', async () => { + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const requestedPage = 2; + const { items, nextPage } = await client.pagedRequest( + FAKE_PAGED_ENDPOINT, + { + page: requestedPage, + }, + ); + // should contain an item from a given page + expect(items[0].someContentOfPage).toEqual(requestedPage); + // should set the nextPage property to the next page + expect(nextPage).toEqual(3); + }); + + it('should not have a next page if at the end', async () => { + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const { items, nextPage } = await client.pagedRequest( + FAKE_PAGED_ENDPOINT, + { + page: 4, + }, + ); + // should contain item of last page + expect(items).toHaveLength(1); + expect(nextPage).toBeNull(); + }); + + it('should throw if response is not okay', async () => { + const endpoint = '/unhealthy-endpoint'; + const url = `${MOCK_CONFIG.apiBaseUrl}${endpoint}`; + server.use( + rest.get(url, (_, res, ctx) => { + return res(ctx.status(400), ctx.json({ error: 'some error' })); + }), + ); + + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + // non-200 status code should throw + await expect(() => client.pagedRequest(endpoint)).rejects.toThrowError(); + }); + }); + + describe('listProjects', () => { + it('should get projects for a given group', async () => { + setupFakeGroupProjectsEndpoint( + server, + MOCK_CONFIG.apiBaseUrl, + 'test-group', + ); + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const groupProjectsGen = paginated( + options => client.listProjects(options), + { group: 'test-group' }, + ); + const allItems = []; + for await (const item of groupProjectsGen) { + allItems.push(item); + } + expect(allItems).toHaveLength(1); + }); + + it('should get all projects for an instance', async () => { + setupFakeInstanceProjectsEndpoint(server, MOCK_CONFIG.apiBaseUrl); + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const instanceProjects = paginated( + options => client.listProjects(options), + {}, + ); + const allProjects = []; + for await (const project of instanceProjects) { + allProjects.push(project); + } + expect(allProjects).toHaveLength(2); + }); + }); +}); + +describe('paginated', () => { + it('should iterate through the pages until exhausted', async () => { + setupFakeFourPageURL(server, FAKE_PAGED_URL); + const client = new GitLabClient({ + config: MOCK_CONFIG, + logger: getVoidLogger(), + }); + + const paginatedItems = paginated( + options => client.pagedRequest(FAKE_PAGED_ENDPOINT, options), + {}, + ); + const allItems = []; + for await (const item of paginatedItems) { + allItems.push(item); + } + + expect(allItems).toHaveLength(4); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts index d1765521a3..4d42f5e068 100644 --- a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts @@ -21,6 +21,18 @@ import { } from '@backstage/integration'; import { Logger } from 'winston'; +export type ListOptions = { + [key: string]: string | number | boolean | undefined; + group?: string; + per_page?: number | undefined; + page?: number | undefined; +}; + +export type PagedResponse = { + items: T[]; + nextPage?: number; +}; + export class GitLabClient { private readonly config: GitLabIntegrationConfig; private readonly logger: Logger; @@ -30,12 +42,17 @@ export class GitLabClient { this.logger = options.logger; } + /** + * Indicates whether the client is for a SaaS or self managed GitLab instance. + */ + isSelfManaged(): boolean { + return this.config.host !== 'gitlab.com'; + } + async listProjects(options?: ListOptions): Promise> { if (options?.group) { return this.pagedRequest( - `${this.config.apiBaseUrl}/groups/${encodeURIComponent( - options?.group, - )}/projects`, + `/groups/${encodeURIComponent(options?.group)}/projects`, { ...options, include_subgroups: true, @@ -43,14 +60,26 @@ export class GitLabClient { ); } - return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options); + return this.pagedRequest(`/projects`, options); } - private async pagedRequest( + /** + * Performs a request against a given paginated GitLab endpoint. + * + * This method may be used to perform authenticated REST calls against any + * paginated GitLab endpoint which uses X-NEXT-PAGE headers. The return value + * can be be used with the {@link paginated} async-generator function to yield + * each item from the paged request. + * + * @see {@link paginated} + * @param endpoint - The request endpoint, e.g. /projects. + * @param options - Request queryString options which may also include page variables. + */ + async pagedRequest( endpoint: string, options?: ListOptions, - ): Promise> { - const request = new URL(endpoint); + ): Promise> { + const request = new URL(`${this.config.apiBaseUrl}${endpoint}`); for (const key in options) { if (options[key]) { request.searchParams.append(key, options[key]!.toString()); @@ -80,20 +109,20 @@ export class GitLabClient { } } -export type ListOptions = { - [key: string]: string | number | boolean | undefined; - group?: string; - per_page?: number | undefined; - page?: number | undefined; -}; - -export type PagedResponse = { - items: T[]; - nextPage?: number; -}; - -export async function* paginated( - request: (options: ListOptions) => Promise>, +/** + * Advances through each page and provides each item from a paginated request. + * + * The async generator function yields each item from repeated calls to the + * provided request function. The generator walks through each available page by + * setting the page key in the options passed into the request function and + * making repeated calls until there are no more pages. + * + * @see {@link pagedRequest} + * @param request - Function which returns a PagedResponse to walk through. + * @param options - Initial ListOptions for the request function. + */ +export async function* paginated( + request: (options: ListOptions) => Promise>, options: ListOptions, ) { let res; diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts index b42c8839cf..756b6a3639 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts @@ -284,7 +284,7 @@ export class DefaultCatalogProcessingOrchestrator entity: LocationEntity, context: Context, ): Promise { - const { type = context.location.type } = entity.spec; + const { type = context.location.type, presence = 'required' } = entity.spec; const targets = new Array(); if (entity.spec.target) { targets.push(entity.spec.target); @@ -318,9 +318,9 @@ export class DefaultCatalogProcessingOrchestrator { type, target, - presence: 'required', + presence, }, - false, + presence === 'optional', context.collector.onEmit, this.options.parser, context.cache.forProcessor(processor, target), diff --git a/plugins/catalog-backend/src/processing/ProcessorOutputCollector.ts b/plugins/catalog-backend/src/processing/ProcessorOutputCollector.ts index 436038db66..b0c46b4b9f 100644 --- a/plugins/catalog-backend/src/processing/ProcessorOutputCollector.ts +++ b/plugins/catalog-backend/src/processing/ProcessorOutputCollector.ts @@ -105,8 +105,9 @@ export class ProcessorOutputCollector { this.deferredEntities.push({ entity, locationKey: location }); } else if (i.type === 'location') { + const presence = i.optional ? 'optional' : 'required'; const entity = locationSpecToLocationEntity( - i.location, + { presence, ...i.location }, this.parentEntity, ); const locationKey = getEntityLocationRef(entity); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 4c6342c6c6..ebad95e72f 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -56,19 +56,16 @@ export class DefaultCatalogCollator implements DocumentCollator { }); } - constructor({ - discovery, - locationTemplate, - filter, - catalogClient, - tokenManager, - }: { + constructor(options: { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; locationTemplate?: string; filter?: CatalogEntitiesRequest['filter']; catalogClient?: CatalogApi; }) { + const { discovery, locationTemplate, filter, catalogClient, tokenManager } = + options; + this.discovery = discovery; this.locationTemplate = locationTemplate || '/catalog/:namespace/:kind/:name'; diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index c9ebc8f723..1618ef8d7b 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -336,7 +336,10 @@ export class NextCatalogBuilder { const parser = this.parser || defaultEntityDataParser; const dbClient = await database.getClient(); - await applyDatabaseMigrations(dbClient); + if (!database.migrations?.skip) { + logger.info('Performing database migration'); + await applyDatabaseMigrations(dbClient); + } const db = new CommonDatabase(dbClient, logger); diff --git a/plugins/catalog-backend/src/stitching/Stitcher.ts b/plugins/catalog-backend/src/stitching/Stitcher.ts index 507f3aa53e..b4a693a4fa 100644 --- a/plugins/catalog-backend/src/stitching/Stitcher.ts +++ b/plugins/catalog-backend/src/stitching/Stitcher.ts @@ -209,6 +209,11 @@ export class Stitcher { entity.metadata.etag = hash; } + // This may throw if the entity is invalid, so we call it before + // the final_entites write, even though we may end up not needing + // to write the search index. + const searchEntries = buildEntitySearch(entityId, entity); + const rowsChanged = await this.database( 'final_entities', ) @@ -235,7 +240,6 @@ export class Stitcher { // B writes the entity -> // B writes search -> // A writes search - const searchEntries = buildEntitySearch(entityId, entity); await this.database('search') .where({ entity_id: entityId }) .delete(); diff --git a/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts b/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts index 9043dab9ba..58551a461f 100644 --- a/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts +++ b/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts @@ -163,5 +163,51 @@ describe('buildEntitySearch', () => { { entity_id: 'eid', key: 'relations.t2', value: 'k:ns/b' }, ]); }); + + it('rejects duplicate keys', () => { + expect(() => + buildEntitySearch('eid', { + relations: [], + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'n', + Namespace: 'ns', + }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'metadata.namespace'`, + ); + + expect(() => + buildEntitySearch('eid', { + relations: [ + { type: 'dup', target: { kind: 'k', namespace: 'ns', name: 'a' } }, + { type: 'DUP', target: { kind: 'k', namespace: 'ns', name: 'b' } }, + ], + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'relations.dup'`, + ); + + expect(() => + buildEntitySearch('eid', { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + spec: { + owner: 'o', + OWNER: 'o', + lifecycle: 'production', + lifeCycle: 'production', + }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'spec.owner', 'spec.lifecycle'`, + ); + }); }); }); diff --git a/plugins/catalog-backend/src/stitching/buildEntitySearch.ts b/plugins/catalog-backend/src/stitching/buildEntitySearch.ts index ed84dfa018..b6c3eead2a 100644 --- a/plugins/catalog-backend/src/stitching/buildEntitySearch.ts +++ b/plugins/catalog-backend/src/stitching/buildEntitySearch.ts @@ -19,6 +19,7 @@ import { ENTITY_DEFAULT_NAMESPACE, stringifyEntityRef, } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; import { DbSearchRow } from '../database/tables'; // These are excluded in the generic loop, either because they do not make sense @@ -184,5 +185,23 @@ export function buildEntitySearch( }); } + // This validates that there are no keys that vary only in casing, such + // as `spec.foo` and `spec.Foo`. + const keys = new Set(raw.map(r => r.key)); + const lowerKeys = new Set(raw.map(r => r.key.toLocaleLowerCase('en-US'))); + if (keys.size !== lowerKeys.size) { + const difference = []; + for (const key of keys) { + const lower = key.toLocaleLowerCase('en-US'); + if (!lowerKeys.delete(lower)) { + difference.push(lower); + } + } + const badKeys = `'${difference.join("', '")}'`; + throw new InputError( + `Entity has duplicate keys that vary only in casing, ${badKeys}`, + ); + } + return mapToRows(raw, entityId); } diff --git a/plugins/catalog-backend/src/util/conversion.ts b/plugins/catalog-backend/src/util/conversion.ts index a834f81255..559a472c25 100644 --- a/plugins/catalog-backend/src/util/conversion.ts +++ b/plugins/catalog-backend/src/util/conversion.ts @@ -81,6 +81,7 @@ export function locationSpecToLocationEntity( spec: { type: location.type, target: location.target, + presence: location.presence, }, }; diff --git a/plugins/catalog-common/.eslintrc.js b/plugins/catalog-common/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-common/README.md b/plugins/catalog-common/README.md new file mode 100644 index 0000000000..2566a737d8 --- /dev/null +++ b/plugins/catalog-common/README.md @@ -0,0 +1,9 @@ +# Catalog Common + +Shared isomorphic code for the catalog plugin. + +## Links + +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog-common/api-report.md b/plugins/catalog-common/api-report.md new file mode 100644 index 0000000000..8e03fc16b5 --- /dev/null +++ b/plugins/catalog-common/api-report.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/plugin-catalog-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Permission } from '@backstage/plugin-permission-common'; + +// @public +export const catalogEntityDeletePermission: Permission; + +// @public +export const catalogEntityReadPermission: Permission; + +// @public +export const catalogEntityRefreshPermission: Permission; + +// @public +export const catalogLocationCreatePermission: Permission; + +// @public +export const catalogLocationDeletePermission: Permission; + +// @public +export const catalogLocationReadPermission: Permission; + +// @public (undocumented) +export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; + +// @public (undocumented) +export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; +``` diff --git a/packages/test-utils-core/package.json b/plugins/catalog-common/package.json similarity index 61% rename from packages/test-utils-core/package.json rename to plugins/catalog-common/package.json index ebe6decddb..439a76fe58 100644 --- a/packages/test-utils-core/package.json +++ b/plugins/catalog-common/package.json @@ -1,37 +1,39 @@ { - "name": "@backstage/test-utils-core", - "description": "Utilities to test Backstage core", - "version": "0.1.4", + "name": "@backstage/plugin-catalog-common", + "description": "Common functionalities for the catalog plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", "private": false, "publishConfig": { "access": "public", - "main": "dist/index.esm.js", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, "homepage": "https://backstage.io", "repository": { "type": "git", "url": "https://github.com/backstage/backstage", - "directory": "packages/test-utils-core" + "directory": "plugins/catalog-common" }, "keywords": [ "backstage" ], - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", "scripts": { - "build": "backstage-cli build --outputs types,esm", + "build": "backstage-cli build", "lint": "backstage-cli lint", "test": "backstage-cli test --passWithNoTests", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", "clean": "backstage-cli clean" }, - "dependencies": {}, + "dependencies": { + "@backstage/plugin-permission-common": "^0.2.0" + }, "devDependencies": { - "@types/jest": "^26.0.7", - "@types/node": "^14.14.32" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/plugins/catalog-common/src/index.ts b/plugins/catalog-common/src/index.ts new file mode 100644 index 0000000000..a38a5ed93b --- /dev/null +++ b/plugins/catalog-common/src/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides shared objects useful for interacting with the catalog and its + * entities, such as catalog permissions. + * + * @packageDocumentation + */ + +export { + RESOURCE_TYPE_CATALOG_ENTITY, + RESOURCE_TYPE_CATALOG_LOCATION, + catalogEntityReadPermission, + catalogEntityDeletePermission, + catalogEntityRefreshPermission, + catalogLocationReadPermission, + catalogLocationCreatePermission, + catalogLocationDeletePermission, +} from './permissions'; diff --git a/plugins/catalog-common/src/permissions.ts b/plugins/catalog-common/src/permissions.ts new file mode 100644 index 0000000000..eb7b01c31e --- /dev/null +++ b/plugins/catalog-common/src/permissions.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Permission } from '@backstage/plugin-permission-common'; + +/** + * {@link https://backstage.io/docs/features/software-catalog/software-catalog-overview} + * @public + */ +export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; + +/** + * {@link https://backstage.io/docs/features/software-catalog/descriptor-format#kind-location} + * @public + */ +export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; + +/** + * This permission is used to authorize actions that involve reading one or more + * entities from the catalog. + * + * If this permission is not authorized, it will appear that the entity does not + * exist in the catalog โ€” both in the frontend and in API responses. + * @public + */ +export const catalogEntityReadPermission: Permission = { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate actions that involve removing one or + * more entities from the catalog. + * @public + */ +export const catalogEntityDeletePermission: Permission = { + name: 'catalog.entity.delete', + attributes: { + action: 'delete', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate refreshing one or more entities from the + * catalog. + * @public + */ +export const catalogEntityRefreshPermission: Permission = { + name: 'catalog.entity.refresh', + attributes: { + action: 'update', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate actions that involve reading one or more + * locations from the catalog. + * + * If this permission is not authorized, it will appear that the location does + * not exist in the catalog โ€” both in the frontend and in API responses. + * @public + */ +export const catalogLocationReadPermission: Permission = { + name: 'catalog.location.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; + +/** + * This permission is used to designate actions that involve creating catalog + * locations. + * @public + */ +export const catalogLocationCreatePermission: Permission = { + name: 'catalog.location.create', + attributes: { + action: 'create', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; + +/** + * This permission is used to designate actions that involve deleting locations + * from the catalog. + * @public + */ +export const catalogLocationDeletePermission: Permission = { + name: 'catalog.location.delete', + attributes: { + action: 'delete', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; diff --git a/packages/test-utils-core/src/index.ts b/plugins/catalog-common/src/setupTests.ts similarity index 81% rename from packages/test-utils-core/src/index.ts rename to plugins/catalog-common/src/setupTests.ts index 8f0a5b31e1..fb7d1a181a 100644 --- a/packages/test-utils-core/src/index.ts +++ b/plugins/catalog-common/src/setupTests.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,8 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - export {}; -throw new Error( - 'This module has been removed. Use @backstage/dev-utils instead', -); diff --git a/plugins/catalog-graph/CHANGELOG.md b/plugins/catalog-graph/CHANGELOG.md index c107a4e758..50842898df 100644 --- a/plugins/catalog-graph/CHANGELOG.md +++ b/plugins/catalog-graph/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-graph +## 0.2.3 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.2 ### Patch Changes diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json index 16c942de8b..59afadc62e 100644 --- a/plugins/catalog-graph/package.json +++ b/plugins/catalog-graph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graph", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -23,28 +23,29 @@ "dependencies": { "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "classnames": "^2.3.1", "lodash": "^4.17.15", "p-limit": "^3.1.0", "qs": "^6.9.4", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index e123f3b17f..fe6cc4cc00 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-catalog-graphql +## 0.2.14 + +### Patch Changes + +- 6b500622d5: Move to using node-fetch internally instead of cross-fetch + ## 0.2.13 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index a7070a1634..9ed6918110 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-graphql", "description": "An experimental Backstage catalog GraphQL module", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -36,14 +36,14 @@ "@backstage/types": "^0.1.1", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", - "cross-fetch": "^3.0.6", "graphql": "^15.3.0", "graphql-tag": "^2.11.0", "graphql-type-json": "^0.3.2", + "node-fetch": "^2.6.1", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@backstage/test-utils": "^0.1.22", "@graphql-codegen/cli": "^1.21.3", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-graphql/src/service/client.ts b/plugins/catalog-graphql/src/service/client.ts index 4328a5bd5a..8d517fcbd6 100644 --- a/plugins/catalog-graphql/src/service/client.ts +++ b/plugins/catalog-graphql/src/service/client.ts @@ -15,7 +15,7 @@ */ import { Entity, EntityMeta } from '@backstage/catalog-model'; -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import { JsonObject } from '@backstage/types'; export interface ReaderEntityMeta extends EntityMeta { @@ -26,11 +26,14 @@ export interface ReaderEntityMeta extends EntityMeta { annotations: Record; labels: Record; } + export interface ReaderEntity extends Entity { metadata: JsonObject & ReaderEntityMeta; } + export class CatalogClient { constructor(private baseUrl: string) {} + async list(): Promise { const res = await fetch(`${this.baseUrl}/catalog/entities`); if (!res.ok) { @@ -39,7 +42,7 @@ export class CatalogClient { throw new Error(await res.text()); } - const entities: ReaderEntity[] = await res.json(); + const entities = (await res.json()) as ReaderEntity[]; return entities; } } diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 6397718fd7..0fae63ff9d 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-import +## 0.7.5 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/integration-react@0.1.15 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.7.4 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index d46495a58b..e017c45d09 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-import", "description": "A Backstage plugin the helps you import entities into your catalog", - "version": "0.7.4", + "version": "0.7.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,32 +33,33 @@ "dependencies": { "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.8", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog-react": "^0.6.5", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^18.5.3", - "@types/react": "*", "git-url-parse": "^11.6.0", "js-base64": "^3.6.0", "lodash": "^4.17.21", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-hook-form": "^7.12.2", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4", "yaml": "^1.10.0" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 89c27f1875..5fae076479 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -20,7 +20,6 @@ import { PartialEntity } from '../types'; export const catalogImportApiRef = createApiRef({ id: 'plugin.catalog-import.service', - description: 'Used by the catalog import plugin to make requests', }); // result of the analyze state diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 276b6d351c..6154eec757 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -79,6 +79,9 @@ describe('CatalogImportClient', () => { signOut: () => { return Promise.resolve(); }, + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; const scmIntegrationsApi = ScmIntegrations.fromConfig( diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index 784cbfbe9e..d6132b249e 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -37,6 +37,9 @@ describe('', () => { signOut: () => { return Promise.resolve(); }, + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; let apis: TestApiRegistry; diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx index 1778af73dc..498e786eb3 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -43,6 +43,9 @@ describe('', () => { signOut: () => { return Promise.resolve(); }, + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; let apis: TestApiRegistry; diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index e6d00b2f8f..0f7e08ead2 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-catalog-react +## 0.6.7 + +### Patch Changes + +- 6156fb8730: `useEntityTypeFilter`: Skip updating selected types if a kind filter change did not change them. +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/catalog-model@0.9.8 + +## 0.6.6 + +### Patch Changes + +- 4c0f0b2003: Removed dependency on `@backstage/core-app-api`. + +## 0.6.5 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- 69034b4419: Fix typo in catalog-react package.json +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/core-app-api@0.2.0 + - @backstage/version-bridge@0.1.1 + ## 0.6.4 ### Patch Changes diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index c4aa254226..d5cf06107c 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsync'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { CatalogApi } from '@backstage/catalog-client'; +import { CatalogListResponse } from '@backstage/catalog-client'; import { ComponentEntity } from '@backstage/catalog-model'; import { ComponentProps } from 'react'; import { Context } from 'react'; @@ -271,14 +272,13 @@ export const EntityRefLink: React_2.ForwardRefExoticComponent< | 'children' | 'key' | 'id' - | 'className' | 'classes' - | 'innerRef' | 'defaultChecked' | 'defaultValue' | 'suppressContentEditableWarning' | 'suppressHydrationWarning' | 'accessKey' + | 'className' | 'contentEditable' | 'contextMenu' | 'draggable' @@ -519,6 +519,7 @@ export const EntityRefLink: React_2.ForwardRefExoticComponent< | 'onTransitionEndCapture' | 'component' | 'variant' + | 'innerRef' | 'download' | 'href' | 'hrefLang' @@ -847,6 +848,12 @@ export function useEntityOwnership(): { // @public export function useEntityTypeFilter(): EntityTypeReturn; +// @public +export function useOwnedEntities(allowedKinds?: string[]): { + loading: boolean; + ownedEntities: CatalogListResponse | undefined; +}; + // Warning: (ae-missing-release-tag) "useOwnUser" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 2ae853650a..0bdffc0724 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-react", "description": "A frontend library that helps other Backstage plugins interact with the catalog", - "version": "0.6.4", + "version": "0.6.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -15,7 +15,7 @@ "repository": { "type": "git", "url": "https://github.com/backstage/backstage", - "directory": "plugins/plugin-catalog-common-react" + "directory": "plugins/catalog-react" }, "keywords": [ "backstage" @@ -30,29 +30,31 @@ }, "dependencies": { "@backstage/catalog-client": "^0.5.2", - "@backstage/catalog-model": "^0.9.7", - "@backstage/core-app-api": "^0.1.21", - "@backstage/core-components": "^0.7.4", - "@backstage/core-plugin-api": "^0.2.0", + "@backstage/catalog-model": "^0.9.8", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", - "@backstage/version-bridge": "^0.1.0", + "@backstage/version-bridge": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "jwt-decode": "^3.1.0", "lodash": "^4.17.21", "qs": "^6.9.4", - "react": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.0", - "@backstage/test-utils": "^0.1.22", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index bbbca3a501..a6684732ce 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -42,3 +42,4 @@ export { useEntityOwnership, loadIdentityOwnerRefs, } from './useEntityOwnership'; +export { useOwnedEntities } from './useOwnedEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx index 5a8ed6775a..bf5777becc 100644 --- a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx +++ b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx @@ -16,6 +16,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useAsync } from 'react-use'; +import isEqual from 'lodash/isEqual'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '../api'; import { useEntityListProvider } from './useEntityListProvider'; @@ -107,7 +108,9 @@ export function useEntityTypeFilter(): EntityTypeReturn { const stillValidTypes = selectedTypes.filter(value => newTypes.includes(value), ); - setSelectedTypes(stillValidTypes); + if (!isEqual(selectedTypes, stillValidTypes)) { + setSelectedTypes(stillValidTypes); + } }, [loading, kind, selectedTypes, setSelectedTypes, entities]); useEffect(() => { diff --git a/plugins/catalog-react/src/hooks/useOwnedEntities.ts b/plugins/catalog-react/src/hooks/useOwnedEntities.ts new file mode 100644 index 0000000000..f9191364cb --- /dev/null +++ b/plugins/catalog-react/src/hooks/useOwnedEntities.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { catalogApiRef } from './../api'; +import { + loadCatalogOwnerRefs, + loadIdentityOwnerRefs, +} from './useEntityOwnership'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { CatalogListResponse } from '@backstage/catalog-client'; +import { useAsync } from 'react-use'; +import { useMemo } from 'react'; + +/** + * Takes the relevant parts of the Backstage identity, and translates them into + * a list of entities which are owned by the user. Takes an optional parameter + * to filter the entities based on allowedKinds + * + * @public + * + * @param allowedKinds - Array of allowed kinds to filter the entities + * @returns CatalogListResponse + */ +export function useOwnedEntities(allowedKinds?: string[]): { + loading: boolean; + ownedEntities: CatalogListResponse | undefined; +} { + const identityApi = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + + const { loading, value: refs } = useAsync(async () => { + const identityRefs = await loadIdentityOwnerRefs(identityApi); + const catalogRefs = await loadCatalogOwnerRefs(catalogApi, identityRefs); + const catalogs = await catalogApi.getEntities( + allowedKinds + ? { + filter: { + kind: allowedKinds, + [`relations.${RELATION_OWNED_BY}`]: + [...identityRefs, ...catalogRefs] || [], + }, + } + : { + filter: { + [`relations.${RELATION_OWNED_BY}`]: + [...identityRefs, ...catalogRefs] || [], + }, + }, + ); + return catalogs; + }, []); + + const ownedEntities = useMemo(() => { + return refs; + }, [refs]); + + return useMemo(() => ({ loading, ownedEntities }), [loading, ownedEntities]); +} diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 7fda738d32..e0a6dd857a 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog +## 0.7.4 + +### Patch Changes + +- 9603827bb5: Addressed some peer dependency warnings +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/integration-react@0.1.15 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.7.3 ### Patch Changes diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 2be7fc8575..80271c89af 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -66,7 +66,7 @@ export type BackstageOverrides = Overrides & { // Warning: (ae-missing-release-tag) "CatalogClientWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public +// @public @deprecated export class CatalogClientWrapper implements CatalogApi { constructor(options: { client: CatalogClient; identityApi: IdentityApi }); // (undocumented) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index e0218090bd..00504d7777 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog", "description": "The Backstage plugin for browsing the Backstage catalog", - "version": "0.7.3", + "version": "0.7.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,28 +33,30 @@ "dependencies": { "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.3", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", + "history": "^5.0.0", "lodash": "^4.17.21", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/catalog/src/CatalogClientWrapper.test.ts b/plugins/catalog/src/CatalogClientWrapper.test.ts index 0e9da8d8c8..6917b5abed 100644 --- a/plugins/catalog/src/CatalogClientWrapper.test.ts +++ b/plugins/catalog/src/CatalogClientWrapper.test.ts @@ -40,6 +40,9 @@ const identityApi: IdentityApi = { async signOut() { return Promise.resolve(); }, + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; const guestIdentityApi: IdentityApi = { getUserId() { @@ -54,6 +57,9 @@ const guestIdentityApi: IdentityApi = { async signOut() { return Promise.resolve(); }, + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; describe('CatalogClientWrapper', () => { diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts index 0463cbf30e..c5f072e96c 100644 --- a/plugins/catalog/src/CatalogClientWrapper.ts +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -30,6 +30,13 @@ import { IdentityApi } from '@backstage/core-plugin-api'; /** * CatalogClient wrapper that injects identity token for all requests + * + * @deprecated The default catalog client now uses the `fetchApiRef` + * implementation, which in turn by default issues tokens just the same as this + * class used to assist in doing. If you use a custom `fetchApiRef` + * implementation that does NOT issue tokens, or use a custom `catalogApiRef` + * implementation which does not use the default `fetchApiRef`, you can wrap + * your catalog API in this class to get back the old behavior. */ export class CatalogClientWrapper implements CatalogApi { private readonly identityApi: IdentityApi; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 3f3fc804e1..a45afe6e02 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -31,9 +31,8 @@ import { renderInTestApp, TestApiRegistry, } from '@backstage/test-utils'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { Route, Routes } from 'react-router'; import { EntityLayout } from './EntityLayout'; diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx index 9681420e1c..ad111380f8 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx @@ -16,8 +16,7 @@ import React from 'react'; import { Tabbed } from './Tabbed'; import { renderInTestApp } from '@backstage/test-utils'; -import { fireEvent } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; +import { act, fireEvent } from '@testing-library/react'; import { Routes, Route } from 'react-router'; describe('Tabbed layout', () => { diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 2a0eacf074..e8b00d8513 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -19,13 +19,13 @@ import { useEntity, useEntityFromUrl, } from '@backstage/plugin-catalog-react'; -import { Link, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; import React, { ComponentType, ReactNode } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; import { CatalogPage } from './CatalogPage'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { Content } from '@backstage/core-components'; +import { Content, Link } from '@backstage/core-components'; const DefaultEntityPage = () => ( @@ -38,9 +38,7 @@ const DefaultEntityPage = () => ( To override this component with your custom implementation, read docs on{' '} - - backstage.io/docs - + backstage.io/docs } diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 20f937b2e7..66fdf4638f 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -22,7 +22,6 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { CatalogClientWrapper } from './CatalogClientWrapper'; import { createComponentRouteRef, viewTechDocRouteRef } from './routes'; import { createApiFactory, @@ -30,7 +29,7 @@ import { createPlugin, createRoutableExtension, discoveryApiRef, - identityApiRef, + fetchApiRef, storageApiRef, } from '@backstage/core-plugin-api'; @@ -39,14 +38,13 @@ export const catalogPlugin = createPlugin({ apis: [ createApiFactory({ api: catalogApiRef, - deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, - factory: ({ discoveryApi, identityApi }) => - new CatalogClientWrapper({ - client: new CatalogClient({ discoveryApi }), - identityApi, - }), + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, fetchApi }) => + new CatalogClient({ discoveryApi, fetchApi }), }), - createApiFactory({ api: starredEntitiesApiRef, deps: { storageApi: storageApiRef }, diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 18e227627b..8137294300 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-circleci +## 0.2.31 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- 56d04330c4: Switch to using `LogViewer` component from `@backstage/core-components` to display action output. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.2.30 + +### Patch Changes + +- 5a46712a7d: Fixed a bug in the `CircleCI` plugin where restarting builds was hard-coded to GitHub rather than introspecting the entity source location. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.2.29 ### Patch Changes diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 6f3eaa39e3..f449e5556c 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-circleci", "description": "A Backstage plugin that integrates towards Circle CI", - "version": "0.2.29", + "version": "0.2.31", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,10 +33,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -44,25 +44,24 @@ "humanize-duration": "^3.27.0", "lodash": "^4.17.21", "luxon": "^2.0.2", - "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": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", "@types/humanize-duration": "^3.25.1", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "@types/react-lazylog": "^4.5.0", "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, diff --git a/plugins/circleci/src/api/CircleCIApi.ts b/plugins/circleci/src/api/CircleCIApi.ts index 498cbb588a..78b8b378d6 100644 --- a/plugins/circleci/src/api/CircleCIApi.ts +++ b/plugins/circleci/src/api/CircleCIApi.ts @@ -33,7 +33,6 @@ 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'; diff --git a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index a4c2af241f..4a4a8a45f2 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Progress } from '@backstage/core-components'; +import { LogViewer } from '@backstage/core-components'; import { Accordion, AccordionDetails, @@ -24,10 +24,9 @@ import { import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { BuildStepAction } from 'circleci-api'; -import React, { Suspense, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { durationHumanized } from '../../../../util'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); const useStyles = makeStyles({ accordionDetails: { padding: 0, @@ -85,11 +84,9 @@ export const ActionOutput = ({ {messages.length === 0 ? ( 'Nothing here...' ) : ( - }> -
- -
-
+
+ +
)} diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index 53cbc754cf..adc8a569bb 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { Avatar, - Link, Typography, Box, IconButton, @@ -37,6 +36,7 @@ import { StatusRunning, Table, TableColumn, + Link, } from '@backstage/core-components'; import { useRouteRef } from '@backstage/core-plugin-api'; @@ -121,9 +121,7 @@ const SourceInfo = ({ build }: { build: CITableBuildInfo }) => { {source?.branchName} {source?.commit?.url !== undefined ? ( - - {source?.commit.shortHash} - + {source?.commit.shortHash} ) : ( source?.commit.shortHash )} @@ -169,7 +167,7 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, render: (row: Partial) => ( - + diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index a9869644a3..521a55e922 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -162,7 +162,7 @@ export function useBuilds() { vcs: { owner: owner, repo: repo, - type: GitType.GITHUB, + type: mapVcsType(vcs), }, }); } catch (e) { diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index 735f5d2167..04963a6fbb 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cloudbuild +## 0.2.29 + +### Patch Changes + +- c77def982f: Remove unnecessary dependency. +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.28 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index f66dcbed00..44dafc8245 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-cloudbuild", "description": "A Backstage plugin that integrates towards Google Cloud Build", - "version": "0.2.28", + "version": "0.2.29", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,27 +32,27 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "luxon": "^2.0.2", "qs": "^6.9.4", - "react": "^16.13.1", - "react-dom": "^16.13.1", - "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cloudbuild/src/api/CloudbuildApi.ts b/plugins/cloudbuild/src/api/CloudbuildApi.ts index 2b09d24e64..92ea8511fe 100644 --- a/plugins/cloudbuild/src/api/CloudbuildApi.ts +++ b/plugins/cloudbuild/src/api/CloudbuildApi.ts @@ -22,7 +22,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const cloudbuildApiRef = createApiRef({ id: 'plugin.cloudbuild.service', - description: 'Used by the Cloudbuild plugin to make requests', }); export type CloudbuildApi = { diff --git a/plugins/cloudbuild/src/components/Cards/Cards.tsx b/plugins/cloudbuild/src/components/Cards/Cards.tsx index a1c9b3c076..dad1f7d38f 100644 --- a/plugins/cloudbuild/src/components/Cards/Cards.tsx +++ b/plugins/cloudbuild/src/components/Cards/Cards.tsx @@ -19,12 +19,13 @@ import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; -import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; +import { Theme, makeStyles, LinearProgress } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; import { CLOUDBUILD_ANNOTATION } from '../useProjectName'; import { InfoCard, + Link, StructuredMetadataTable, WarningPanel, } from '@backstage/core-components'; @@ -62,7 +63,7 @@ const WidgetContent = ({ ), message: lastRun.message, url: ( - + See more on Google{' '} diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md index f1175172f5..387e86bc46 100644 --- a/plugins/code-coverage-backend/CHANGELOG.md +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-code-coverage-backend +## 0.1.16 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + ## 0.1.15 ### Patch Changes diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 1bc637146b..83a0acf9c2 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-code-coverage-backend", "description": "A Backstage backend plugin that helps you keep track of your code coverage", - "version": "0.1.15", + "version": "0.1.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,12 +20,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.5", - "@backstage/integration": "^0.6.8", + "@backstage/integration": "^0.6.10", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/express-xml-bodyparser": "^0.3.2", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/code-coverage/CHANGELOG.md b/plugins/code-coverage/CHANGELOG.md index b5b413b9d2..777dc6d5df 100644 --- a/plugins/code-coverage/CHANGELOG.md +++ b/plugins/code-coverage/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-code-coverage +## 0.1.19 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.1.18 ### Patch Changes diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index 3a45960684..c5cb3e3acb 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-code-coverage", "description": "A Backstage plugin that helps you keep track of your code coverage", - "version": "0.1.18", + "version": "0.1.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -23,29 +23,30 @@ "dependencies": { "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.11.0", "highlight.js": "^10.6.0", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", "recharts": "^1.8.5" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/code-coverage/src/api.ts b/plugins/code-coverage/src/api.ts index a4a7bba37c..61f920106b 100644 --- a/plugins/code-coverage/src/api.ts +++ b/plugins/code-coverage/src/api.ts @@ -34,7 +34,6 @@ export type CodeCoverageApi = { export const codeCoverageApiRef = createApiRef({ id: 'plugin.code-coverage.service', - description: 'Used by the code coverage plugin to make requests', }); export class CodeCoverageRestApi implements CodeCoverageApi { diff --git a/plugins/config-schema/CHANGELOG.md b/plugins/config-schema/CHANGELOG.md index fc4f8697b8..d4999dc573 100644 --- a/plugins/config-schema/CHANGELOG.md +++ b/plugins/config-schema/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-config-schema +## 0.1.15 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.1.14 ### Patch Changes diff --git a/plugins/config-schema/api-report.md b/plugins/config-schema/api-report.md index efeb1646eb..f3b620738f 100644 --- a/plugins/config-schema/api-report.md +++ b/plugins/config-schema/api-report.md @@ -41,11 +41,9 @@ export const configSchemaPlugin: BackstagePlugin< {} >; -// Warning: (ae-missing-release-tag) "StaticSchemaLoader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class StaticSchemaLoader implements ConfigSchemaApi { - constructor({ url }?: { url?: string }); + constructor(options?: { url?: string }); // (undocumented) schema$(): Observable; } diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 0d0357cd7c..accf5e2d01 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-config-schema", "description": "A Backstage plugin that lets you browse the configuration schema of your app", - "version": "0.1.14", + "version": "0.1.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,25 +22,26 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.5", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "jsonschema": "^1.2.6", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/config-schema/src/api/StaticSchemaLoader.ts b/plugins/config-schema/src/api/StaticSchemaLoader.ts index 71eae63376..5fc979b76d 100644 --- a/plugins/config-schema/src/api/StaticSchemaLoader.ts +++ b/plugins/config-schema/src/api/StaticSchemaLoader.ts @@ -24,12 +24,14 @@ const DEFAULT_URL = 'config-schema.json'; /** * A ConfigSchemaApi implementation that loads the configuration from a URL. + * + * @public */ export class StaticSchemaLoader implements ConfigSchemaApi { private readonly url: string; - constructor({ url = DEFAULT_URL }: { url?: string } = {}) { - this.url = url; + constructor(options: { url?: string } = {}) { + this.url = options?.url ?? DEFAULT_URL; } schema$(): Observable { diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index c43d7c0612..183cc27dcf 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-cost-insights +## 0.11.13 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.11.12 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 12133fcc64..94ec2bb5fe 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-cost-insights", "description": "A Backstage plugin that helps you keep track of your cloud spend", - "version": "0.11.12", + "version": "0.11.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,33 +32,34 @@ }, "dependencies": { "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.9.6", - "@types/react": "*", "@types/recharts": "^1.8.14", "classnames": "^2.2.6", "history": "^5.0.0", "luxon": "^2.0.2", "pluralize": "^8.0.0", "qs": "^6.9.4", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", "recharts": "^1.8.5", "regression": "^2.0.1", "yup": "^0.32.9" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 40882ebd95..58c8592273 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -148,5 +148,4 @@ export type CostInsightsApi = { export const costInsightsApiRef = createApiRef({ id: 'plugin.costinsights.service', - description: 'Provides cost data and alerts for the cost-insights plugin', }); diff --git a/plugins/cost-insights/src/testUtils/providers.tsx b/plugins/cost-insights/src/testUtils/providers.tsx index 09d2feb71b..fb18c1a68c 100644 --- a/plugins/cost-insights/src/testUtils/providers.tsx +++ b/plugins/cost-insights/src/testUtils/providers.tsx @@ -187,6 +187,9 @@ export const MockCostInsightsApiProvider = ({ getIdToken: jest.fn(), getUserId: jest.fn(), signOut: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), }; const defaultCostInsightsApi: CostInsightsApi = { diff --git a/plugins/explore-react/CHANGELOG.md b/plugins/explore-react/CHANGELOG.md index 0560527f80..0123dcd77a 100644 --- a/plugins/explore-react/CHANGELOG.md +++ b/plugins/explore-react/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-explore-react +## 0.0.8 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.3.0 + ## 0.0.7 ### Patch Changes diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json index a67f8b6892..ad7f92751e 100644 --- a/plugins/explore-react/package.json +++ b/plugins/explore-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-explore-react", "description": "A frontend library for Backstage plugins that want to interact with the explore plugin", - "version": "0.0.7", + "version": "0.0.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core-plugin-api": "^0.2.0" + "@backstage/core-plugin-api": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.22", + "@backstage/cli": "^0.10.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/explore-react/src/tools/api.ts b/plugins/explore-react/src/tools/api.ts index 33b4b7b66c..8a91b84785 100644 --- a/plugins/explore-react/src/tools/api.ts +++ b/plugins/explore-react/src/tools/api.ts @@ -18,7 +18,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const exploreToolsConfigRef = createApiRef({ id: 'plugin.explore.toolsconfig', - description: 'Used to configure tools displayed in the explore plugin', }); export type ExploreTool = { diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index 73117d7f32..fce849b17d 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-explore +## 0.3.22 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + - @backstage/plugin-explore-react@0.0.8 + ## 0.3.21 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 8988a7f215..4db5aa31e2 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-explore", "description": "A Backstage plugin for building an exploration page of your software ecosystem", - "version": "0.3.21", + "version": "0.3.22", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,27 +32,28 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/plugin-explore-react": "^0.0.7", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/plugin-explore-react": "^0.0.8", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "classnames": "^2.2.6", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/firehydrant/CHANGELOG.md b/plugins/firehydrant/CHANGELOG.md index b137ed1f73..6cb869284e 100644 --- a/plugins/firehydrant/CHANGELOG.md +++ b/plugins/firehydrant/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-firehydrant +## 0.1.9 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.1.8 ### Patch Changes diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index 421ec10c1c..784346e07c 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-firehydrant", "description": "A Backstage plugin that integrates towards FireHydrant", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,23 +22,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "luxon": "^1.27.0", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/firehydrant/src/api/index.ts b/plugins/firehydrant/src/api/index.ts index b78e81a024..7101b5dc62 100644 --- a/plugins/firehydrant/src/api/index.ts +++ b/plugins/firehydrant/src/api/index.ts @@ -39,7 +39,6 @@ export interface FireHydrantAPI { export const fireHydrantApiRef = createApiRef({ id: 'plugin.firehydrant.service', - description: 'Used by FireHydrant plugin for requests', }); export type Options = { diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index af99e13c4a..b63911f5db 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-fossa +## 0.2.24 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.2.23 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.2.22 ### Patch Changes diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 3731d01661..6542721b61 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-fossa", "description": "A Backstage plugin that integrates towards FOSSA", - "version": "0.2.22", + "version": "0.2.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,26 +33,27 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "cross-fetch": "^3.0.6", "luxon": "^2.0.2", "p-limit": "^3.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts index 918eab62a4..9a3932fea0 100644 --- a/plugins/fossa/src/api/FossaApi.ts +++ b/plugins/fossa/src/api/FossaApi.ts @@ -26,7 +26,6 @@ export interface FindingSummary { export const fossaApiRef = createApiRef({ id: 'plugin.fossa.service', - description: 'Used by the Fossa plugin to make requests', }); export type FossaApi = { diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts index 4debb37ad6..e723a4b00a 100644 --- a/plugins/fossa/src/api/FossaClient.test.ts +++ b/plugins/fossa/src/api/FossaClient.test.ts @@ -24,20 +24,11 @@ import { UrlPatternDiscovery } from '@backstage/core-app-api'; const server = setupServer(); -const identityApi: IdentityApi = { - getUserId() { - return 'jane-fonda'; - }, - getProfile() { - return { email: 'jane-fonda@spotify.com' }; - }, +const identityApi = { async getIdToken() { return Promise.resolve('fake-id-token'); }, - async signOut() { - return Promise.resolve(); - }, -}; +} as IdentityApi; describe('FossaClient', () => { setupRequestMockHandlers(server); diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index ac8dd570a6..7d832c5a4b 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-gcp-projects +## 0.3.10 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.3.9 ### Patch Changes diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 3068119de5..5580595dc8 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-gcp-projects", "description": "A Backstage plugin that helps you manage projects in GCP", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,22 +31,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "^6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gcp-projects/src/api/GcpApi.ts b/plugins/gcp-projects/src/api/GcpApi.ts index 5626796a05..9167d2983f 100644 --- a/plugins/gcp-projects/src/api/GcpApi.ts +++ b/plugins/gcp-projects/src/api/GcpApi.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const gcpApiRef = createApiRef({ id: 'plugin.gcpprojects.service', - description: 'Used by the GCP Projects plugin to make requests', }); export type GcpApi = { diff --git a/plugins/git-release-manager/CHANGELOG.md b/plugins/git-release-manager/CHANGELOG.md index 1c627ab086..c4e50a250b 100644 --- a/plugins/git-release-manager/CHANGELOG.md +++ b/plugins/git-release-manager/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-git-release-manager +## 0.3.4 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.3.3 ### Patch Changes diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index fd1ef75ccc..720f624fdd 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-git-release-manager", "description": "A Backstage plugin that helps you manage releases in git", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,28 +21,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/integration": "^0.6.8", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/integration": "^0.6.10", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^18.5.3", - "@types/react": "*", "luxon": "^2.0.2", "qs": "^6.10.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4", "recharts": "^1.8.5" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/git-release-manager/src/api/serviceApiRef.test.ts b/plugins/git-release-manager/src/api/serviceApiRef.test.ts index 03a6bbae02..b27738671d 100644 --- a/plugins/git-release-manager/src/api/serviceApiRef.test.ts +++ b/plugins/git-release-manager/src/api/serviceApiRef.test.ts @@ -23,7 +23,6 @@ describe('gitReleaseManagerApiRef', () => { expect(result).toMatchInlineSnapshot(` ApiRefImpl { "config": Object { - "description": "Used by the Git Release Manager plugin to make requests", "id": "plugin.git-release-manager.service", }, } diff --git a/plugins/git-release-manager/src/api/serviceApiRef.ts b/plugins/git-release-manager/src/api/serviceApiRef.ts index 75f0203e47..f2c695f455 100644 --- a/plugins/git-release-manager/src/api/serviceApiRef.ts +++ b/plugins/git-release-manager/src/api/serviceApiRef.ts @@ -20,5 +20,4 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const gitReleaseManagerApiRef = createApiRef({ id: 'plugin.git-release-manager.service', - description: 'Used by the Git Release Manager plugin to make requests', }); diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index 7992f201cf..15c8f61978 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-github-actions +## 0.4.27 + +### Patch Changes + +- 89bd772b00: Show empty state only when workflow API call has completed +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/catalog-model@0.9.8 + - @backstage/plugin-catalog-react@0.6.7 + +## 0.4.26 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- cbd20c46f1: Switch to using `LogViewer` component from `@backstage/core-components` to display build logs. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.4.25 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index ea5f104c96..22cd220e9c 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-actions", "description": "A Backstage plugin that integrates towards GitHub Actions", - "version": "0.4.25", + "version": "0.4.27", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,29 +33,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/integration": "^0.6.8", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.8", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-catalog-react": "^0.6.7", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^18.5.3", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", - "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index e30145c4b3..fe516a0211 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const githubActionsApiRef = createApiRef({ id: 'plugin.githubactions.service', - description: 'Used by the GitHub Actions plugin to make requests', }); export type GithubActionsApi = { diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 6f6531044e..cb4b2190fc 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -18,7 +18,6 @@ import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; import { LinearProgress, - Link, makeStyles, Theme, Typography, @@ -34,6 +33,7 @@ import { configApiRef, errorApiRef, useApi } from '@backstage/core-plugin-api'; import { InfoCard, InfoCardVariants, + Link, StructuredMetadataTable, } from '@backstage/core-components'; @@ -71,7 +71,7 @@ const WidgetContent = ({ ), message: lastRun.message, url: ( - + See more on GitHub{' '} diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx index 399fed9557..0f1670bbbc 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { Progress } from '@backstage/core-components'; +import { LogViewer } from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { @@ -32,14 +32,11 @@ import { } from '@material-ui/core'; import DescriptionIcon from '@material-ui/icons/Description'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import React, { Suspense } from 'react'; +import React from 'react'; import { useProjectName } from '../useProjectName'; import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); -const LinePart = React.lazy(() => import('react-lazylog/build/LinePart')); - -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ button: { order: -1, marginRight: 0, @@ -53,49 +50,19 @@ const useStyles = makeStyles(() => ({ justifyContent: 'center', margin: 'auto', }, - normalLog: { + normalLogContainer: { height: '75vh', width: '100%', }, - modalLog: { + modalLogContainer: { height: '100%', width: '100%', }, + log: { + background: theme.palette.background.default, + }, })); -const DisplayLog = ({ - jobLogs, - className, -}: { - jobLogs: any; - className: string; -}) => { - return ( - }> -
- { - if ( - line.toLocaleLowerCase().includes('error') || - line.toLocaleLowerCase().includes('failed') || - line.toLocaleLowerCase().includes('failure') - ) { - return ( - - ); - } - return line; - }} - /> -
-
- ); -}; - /** * A component for Run Logs visualization. */ @@ -123,6 +90,7 @@ export const WorkflowRunLogs = ({ repo, id: runId, }); + const logText = jobLogs.value ? String(jobLogs.value) : undefined; const [open, setOpen] = React.useState(false); const handleOpen = () => { @@ -162,18 +130,19 @@ export const WorkflowRunLogs = ({ onClose={handleClose} > - +
+ +
- {jobLogs.value && ( - + {logText && ( +
+ +
)} ); diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 2506613e0c..81fa036221 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -172,8 +172,9 @@ export const WorkflowRunsTable = ({ }); const githubHost = hostname || 'github.com'; + const hasNoRuns = !loading && !tableProps.loading && !runs; - return !runs ? ( + return hasNoRuns ? ( ; // Warning: (ae-missing-release-tag) "EntityGithubDeploymentsCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const EntityGithubDeploymentsCard: ({ - last, - lastStatuses, - columns, -}: { +export const EntityGithubDeploymentsCard: (props: { last?: number | undefined; lastStatuses?: number | undefined; columns?: TableColumn[] | undefined; @@ -58,12 +54,9 @@ export const githubDeploymentsPlugin: BackstagePlugin<{}, {}>; // Warning: (ae-missing-release-tag) "GithubDeploymentsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function GithubDeploymentsTable({ - deployments, - isLoading, - reload, - columns, -}: GithubDeploymentsTableProps): JSX.Element; +export function GithubDeploymentsTable( + props: GithubDeploymentsTableProps, +): JSX.Element; // @public (undocumented) export namespace GithubDeploymentsTable { @@ -78,7 +71,7 @@ export namespace GithubDeploymentsTable { // Warning: (ae-missing-release-tag) "GithubStateIndicator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -const GithubStateIndicator: ({ state }: { state: string }) => JSX.Element; +const GithubStateIndicator: (props: { state: string }) => JSX.Element; // Warning: (ae-missing-release-tag) "isGithubDeploymentsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 18c3cc3d05..d593ae7ac0 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-deployments", "description": "A Backstage plugin that integrates towards GitHub Deployments", - "version": "0.1.21", + "version": "0.1.22", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,27 +22,28 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.8", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/graphql": "^4.5.8", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts index bec0167f63..b64d914612 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -91,7 +91,6 @@ export interface GithubDeploymentsApi { export const githubDeploymentsApiRef = createApiRef({ id: 'plugin.github-deployments.service', - description: 'Used by the GitHub Deployments plugin to make requests', }); export type Options = { diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx index c01cd98a45..c1c33607a1 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx @@ -80,15 +80,12 @@ const GithubDeploymentsComponent = ({ ); }; -export const GithubDeploymentsCard = ({ - last, - lastStatuses, - columns, -}: { +export const GithubDeploymentsCard = (props: { last?: number; lastStatuses?: number; columns?: TableColumn[]; }) => { + const { last, lastStatuses, columns } = props; const { entity } = useEntity(); const [host] = [ entity?.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION], diff --git a/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx b/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx index 2de7cdf419..2c48f3eef1 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx @@ -36,12 +36,8 @@ type GithubDeploymentsTableProps = { columns: TableColumn[]; }; -export function GithubDeploymentsTable({ - deployments, - isLoading, - reload, - columns, -}: GithubDeploymentsTableProps) { +export function GithubDeploymentsTable(props: GithubDeploymentsTableProps) { + const { deployments, isLoading, reload, columns } = props; const classes = useStyles(); return ( diff --git a/plugins/github-deployments/src/components/GithubDeploymentsTable/columns.tsx b/plugins/github-deployments/src/components/GithubDeploymentsTable/columns.tsx index e892b12427..9a720c0e75 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsTable/columns.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsTable/columns.tsx @@ -27,8 +27,8 @@ import { Link, } from '@backstage/core-components'; -export const GithubStateIndicator = ({ state }: { state: string }) => { - switch (state) { +export const GithubStateIndicator = (props: { state: string }) => { + switch (props.state) { case 'PENDING': return ; case 'IN_PROGRESS': diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index 42f3533d31..69ece49c63 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-gitops-profiles +## 0.3.10 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.3.9 ### Patch Changes diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 8456eb4919..b6d3f2f569 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-gitops-profiles", "description": "A Backstage plugin that helps you manage GitOps profiles", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,22 +32,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index 86cfd805c9..ca141140d8 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -113,7 +113,6 @@ export type GitOpsApi = { export const gitOpsApiRef = createApiRef({ id: 'plugin.gitops.service', - description: 'Used by the GitOps profiles plugin to make requests', }); export class GitOpsRestApi implements GitOpsApi { diff --git a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx index 28c4bb65f3..9ac93a6d6c 100644 --- a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx @@ -14,10 +14,9 @@ * limitations under the License. */ import React from 'react'; -import { Link } from '@material-ui/core'; import { ClusterStatus } from '../../api'; import { transformStatus } from '../ProfileCatalog/ProfileCatalog'; -import { Table, TableColumn } from '@backstage/core-components'; +import { Link, Table, TableColumn } from '@backstage/core-components'; const columns: TableColumn[] = [ { @@ -25,7 +24,7 @@ const columns: TableColumn[] = [ field: 'name', highlight: true, render: (componentData: any) => ( - + {componentData.name} ), diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 4fbf6b0dab..2a013aa6b3 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-graphiql +## 0.2.24 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + +## 0.2.23 + +### Patch Changes + +- dde1681f33: chore(dependencies): bump `graphiql` package to latest + ## 0.2.22 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index a04f4d6992..7f165cbef5 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.22", + "version": "0.2.24", "private": false, "publishConfig": { "access": "public", @@ -31,23 +31,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "graphiql": "^1.0.0-alpha.10", - "graphql": "15.5.0", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "graphiql": "^1.5.12", + "graphql": "^16.0.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx index ae84b549ec..90f8edd4ad 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { GraphiQLPage } from './GraphiQLPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api'; import { configApiRef } from '@backstage/core-plugin-api'; diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 335545021e..5b505ee828 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", - "@backstage/plugin-catalog-graphql": "^0.2.12", + "@backstage/plugin-catalog-graphql": "^0.2.14", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.35.0", diff --git a/plugins/home/CHANGELOG.md b/plugins/home/CHANGELOG.md index 09e379a625..644d319b70 100644 --- a/plugins/home/CHANGELOG.md +++ b/plugins/home/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-home +## 0.4.7 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.4.6 ### Patch Changes diff --git a/plugins/home/package.json b/plugins/home/package.json index 65f6fe2ee3..4d14388d09 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-home", "description": "A Backstage plugin that helps you build a home page", - "version": "0.4.6", + "version": "0.4.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,24 +21,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "lodash": "^4.17.21", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/ilert/CHANGELOG.md b/plugins/ilert/CHANGELOG.md index 1bff4b22ef..a0698bf4a3 100644 --- a/plugins/ilert/CHANGELOG.md +++ b/plugins/ilert/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-ilert +## 0.1.18 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.1.17 ### Patch Changes diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json index 4dddf17cd9..a476abebd8 100644 --- a/plugins/ilert/package.json +++ b/plugins/ilert/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-ilert", "description": "A Backstage plugin that integrates towards iLert", - "version": "0.1.17", + "version": "0.1.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,11 +22,11 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@date-io/luxon": "1.x", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -34,15 +34,16 @@ "@material-ui/pickers": "^3.3.10", "humanize-duration": "^3.26.0", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts index f05e58878f..13de8c1f2c 100644 --- a/plugins/ilert/src/api/client.ts +++ b/plugins/ilert/src/api/client.ts @@ -41,7 +41,6 @@ import { export const ilertApiRef = createApiRef({ id: 'plugin.ilert.service', - description: 'Used to make requests towards iLert API', }); const DEFAULT_PROXY_PATH = '/ilert'; diff --git a/plugins/jenkins-backend/CHANGELOG.md b/plugins/jenkins-backend/CHANGELOG.md index 5cc91e0d5d..19d743b250 100644 --- a/plugins/jenkins-backend/CHANGELOG.md +++ b/plugins/jenkins-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-jenkins-backend +## 0.1.9 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/backend-common@0.9.12 + ## 0.1.8 ### Patch Changes diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index c909c54806..b8d171f9c1 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -52,6 +52,8 @@ export interface JenkinsInfo { // (undocumented) baseUrl: string; // (undocumented) + crumbIssuer?: boolean; + // (undocumented) headers?: Record; // (undocumented) jobFullName: string; @@ -77,6 +79,8 @@ export interface JenkinsInstanceConfig { // (undocumented) baseUrl: string; // (undocumented) + crumbIssuer?: boolean; + // (undocumented) name: string; // (undocumented) username: string; diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 4db26d94c4..db4298e6c2 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins-backend", "description": "A Backstage backend plugin that integrates towards Jenkins", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", @@ -34,7 +34,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 5f74259eef..a410d8ad92 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -411,4 +411,17 @@ describe('JenkinsApi', () => { }); expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); }); + + it('buildProject with crumbIssuer option', async () => { + const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; + await jenkinsApi.buildProject(info, jobFullName); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + crumbIssuer: true, + }); + expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); + }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 13705ce049..f156259f04 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -146,6 +146,7 @@ export class JenkinsApiImpl { baseUrl: jenkinsInfo.baseUrl, headers: jenkinsInfo.headers, promisify: true, + crumbIssuer: jenkinsInfo.crumbIssuer, }) as any; } diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 08439297a7..21a2022bb3 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -210,6 +210,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', + crumbIssuer: undefined, headers: { Authorization: 'Basic YmFja3N0YWdlIC0gYm90OjEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNlZGYwMTI=', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 1a992e73f1..f5cb52b696 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -38,6 +38,7 @@ export interface JenkinsInfo { baseUrl: string; headers?: Record; jobFullName: string; // TODO: make this an array + crumbIssuer?: boolean; } export interface JenkinsInstanceConfig { @@ -45,6 +46,7 @@ export interface JenkinsInstanceConfig { baseUrl: string; username: string; apiKey: string; + crumbIssuer?: boolean; } /** @@ -70,6 +72,7 @@ export class JenkinsConfig { baseUrl: c.getString('baseUrl'), username: c.getString('username'), apiKey: c.getString('apiKey'), + crumbIssuer: c.getOptionalBoolean('crumbIssuer'), })) || []; // load unnamed default config @@ -81,6 +84,7 @@ export class JenkinsConfig { const baseUrl = jenkinsConfig.getOptionalString('baseUrl'); const username = jenkinsConfig.getOptionalString('username'); const apiKey = jenkinsConfig.getOptionalString('apiKey'); + const crumbIssuer = jenkinsConfig.getOptionalBoolean('crumbIssuer'); if (hasNamedDefault && (baseUrl || username || apiKey)) { throw new Error( @@ -98,12 +102,13 @@ export class JenkinsConfig { if (unnamedAllPresent) { const unnamedInstanceConfig = [ - { name: DEFAULT_JENKINS_NAME, baseUrl, username, apiKey }, + { name: DEFAULT_JENKINS_NAME, baseUrl, username, apiKey, crumbIssuer }, ] as { name: string; baseUrl: string; username: string; apiKey: string; + crumbIssuer: boolean; }[]; return new JenkinsConfig([ @@ -227,6 +232,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { Authorization: `Basic ${creds}`, }, jobFullName, + crumbIssuer: instanceConfig.crumbIssuer, }; } diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 1daba68a1d..c59c7581af 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-jenkins +## 0.5.14 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.5.13 ### Patch Changes diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index f33e565d89..a6926c1639 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -9,8 +9,8 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityName } from '@backstage/catalog-model'; -import { EntityRef } from '@backstage/catalog-model'; +import type { EntityName } from '@backstage/catalog-model'; +import type { EntityRef } from '@backstage/catalog-model'; import { IdentityApi } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; import { RouteRef } from '@backstage/core-plugin-api'; diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 18341f6eec..862c2cdfb3 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins", "description": "A Backstage plugin that integrates towards Jenkins", - "version": "0.5.13", + "version": "0.5.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,25 +33,27 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 4b23fb4292..492e7f5198 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -19,11 +19,11 @@ import { DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; -import { EntityName, EntityRef } from '@backstage/catalog-model'; +import type { EntityName, EntityRef } from '@backstage/catalog-model'; +import { ResponseError } from '@backstage/errors'; export const jenkinsApiRef = createApiRef({ id: 'plugin.jenkins.service2', - description: 'Used by the Jenkins plugin to make requests', }); export interface Build { @@ -140,7 +140,7 @@ export class JenkinsClient implements JenkinsApi { url.searchParams.append('branch', filter.branch); } - const idToken = await this.identityApi.getIdToken(); + const idToken = await this.getToken(); const response = await fetch(url.href, { method: 'GET', headers: { @@ -151,8 +151,8 @@ export class JenkinsClient implements JenkinsApi { return ( (await response.json()).projects?.map((p: Project) => ({ ...p, - onRestartClick: async () => { - await this.retry({ + onRestartClick: () => { + return this.retry({ entity, jobFullName: p.fullName, buildNumber: String(p.lastBuild.number), @@ -179,7 +179,7 @@ export class JenkinsClient implements JenkinsApi { jobFullName, )}/${encodeURIComponent(buildNumber)}`; - const idToken = await this.identityApi.getIdToken(); + const idToken = await this.getToken(); const response = await fetch(url, { method: 'GET', headers: { @@ -207,12 +207,21 @@ export class JenkinsClient implements JenkinsApi { jobFullName, )}/${encodeURIComponent(buildNumber)}:rebuild`; - const idToken = await this.identityApi.getIdToken(); - await fetch(url, { + const idToken = await this.getToken(); + const response = await fetch(url, { method: 'POST', headers: { ...(idToken && { Authorization: `Bearer ${idToken}` }), }, }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + } + + private async getToken() { + const { token } = await this.identityApi.getCredentials(); + return token; } } diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index b1eaf2c24c..2c464491c2 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,17 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; +import { Link, Progress, Table, TableColumn } from '@backstage/core-components'; +import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; -import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; -import { Link as RouterLink } from 'react-router-dom'; -import { JenkinsRunStatus } from '../Status'; -import { useBuilds } from '../../../useBuilds'; -import { buildRouteRef } from '../../../../plugin'; -import { Table, TableColumn } from '@backstage/core-components'; +import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; -import { useRouteRef } from '@backstage/core-plugin-api'; +import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; +import { buildRouteRef } from '../../../../plugin'; +import { useBuilds } from '../../../useBuilds'; +import { JenkinsRunStatus } from '../Status'; const FailCount = ({ count }: { count: number }): JSX.Element | null => { if (count !== 0) { @@ -107,7 +106,6 @@ const generatedColumns: TableColumn[] = [ return ( ) => ( <>

- + {row.lastBuild?.source?.branchName}

@@ -154,7 +152,7 @@ const generatedColumns: TableColumn[] = [ <>

{row.lastBuild?.tests && ( - + {row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '} passed ) => ( - - - - - - ), + render: (row: Partial) => { + const ActionWrapper = () => { + const [isLoadingRebuild, setIsLoadingRebuild] = useState(false); + const alertApi = useApi(alertApiRef); + + const onRebuild = async () => { + if (row.onRestartClick) { + setIsLoadingRebuild(true); + try { + await row.onRestartClick(); + alertApi.post({ + message: 'Jenkins re-build has successfully executed', + severity: 'success', + }); + } catch (e) { + alertApi.post({ + message: `Jenkins re-build has failed. Error: ${e.message}`, + severity: 'error', + }); + } finally { + setIsLoadingRebuild(false); + } + } + }; + + return ( + + <> + {isLoadingRebuild && } + {!isLoadingRebuild && ( + + + + )} + + + ); + }; + return ; + }, width: '10%', }, ]; diff --git a/plugins/jenkins/src/components/Cards/Cards.test.tsx b/plugins/jenkins/src/components/Cards/Cards.test.tsx index 0af91da179..97b920e666 100644 --- a/plugins/jenkins/src/components/Cards/Cards.test.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.test.tsx @@ -35,7 +35,7 @@ describe('', () => { const jenkinsApi: Partial = { getProjects: () => Promise.resolve([ - { lastBuild: { timestamp: 0, status: 'success' } }, + { lastBuild: { timestamp: 0, status: 'success', url: 'foo' } }, ] as Project[]), }; diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index f0b1ff2bca..cb84964124 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LinearProgress, Link, makeStyles, Theme } from '@material-ui/core'; +import { LinearProgress, makeStyles, Theme } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; import { DateTime, Duration } from 'luxon'; import React from 'react'; @@ -22,6 +22,7 @@ import { ErrorType, useBuilds } from '../useBuilds'; import { InfoCard, InfoCardVariants, + Link, StructuredMetadataTable, WarningPanel, } from '@backstage/core-components'; @@ -66,7 +67,7 @@ const WidgetContent = ({ 'latest run': displayDate, duration: displayDuration, link: ( - + See more on Jenkins{' '} diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index a8f64b21e1..7fb5578628 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -32,7 +32,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.5", @@ -44,7 +44,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 33ec4bd95a..be4f2d2a29 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-kafka +## 0.2.22 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.21 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 8da053ab50..d3353a1f0d 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kafka", "description": "A Backstage plugin that integrates towards Kafka", - "version": "0.2.21", + "version": "0.2.22", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,23 +22,24 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kafka/src/api/types.ts b/plugins/kafka/src/api/types.ts index 80a0866290..7804574761 100644 --- a/plugins/kafka/src/api/types.ts +++ b/plugins/kafka/src/api/types.ts @@ -18,8 +18,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const kafkaApiRef = createApiRef({ id: 'plugin.kafka.service', - description: - 'Used by the Kafka plugin to make requests to accompanying backend', }); export type ConsumerGroupOffsetsResponse = { diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 102c9c13e4..ccd7ce7194 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,34 @@ # @backstage/plugin-kubernetes-backend +## 0.4.0 + +### Minor Changes + +- c010632f88: Add pod metrics lookup and display in pod table. + + ## Backwards incompatible changes + + If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed, + you will need to set the `skipMetricsLookup` config flag to `false`. + + See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-kubernetes-common@0.2.0 + - @backstage/backend-common@0.9.13 + +## 0.3.20 + +### Patch Changes + +- 65ddccb5e8: Added apiVersionOverrides config to allow for specifying api versions to use for kubernetes objects +- f6087fc8f8: Query CronJobs from Kubernetes with apiGroup BatchV1beta1 +- Updated dependencies + - @backstage/backend-common@0.9.12 + - @backstage/plugin-kubernetes-common@0.1.7 + ## 0.3.19 ### Patch Changes diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index e1236e61c5..7d3adac1c4 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -10,6 +10,7 @@ import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; // Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -34,6 +35,7 @@ export interface ClusterDetails { name: string; // (undocumented) serviceAccountToken?: string | undefined; + skipMetricsLookup?: boolean; // (undocumented) skipTLSVerify?: boolean; // (undocumented) @@ -164,6 +166,11 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + // (undocumented) + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } // Warning: (ae-missing-release-tag) "KubernetesObjectsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml new file mode 100644 index 0000000000..673064aaf2 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml @@ -0,0 +1,137 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: system:aggregated-metrics-reader + labels: + rbac.authorization.k8s.io/aggregate-to-view: 'true' + rbac.authorization.k8s.io/aggregate-to-edit: 'true' + rbac.authorization.k8s.io/aggregate-to-admin: 'true' +rules: + - apiGroups: ['metrics.k8s.io'] + resources: ['pods'] + verbs: ['get', 'list', 'watch'] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: metrics-server:system:auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: RoleBinding +metadata: + name: metrics-server-auth-reader + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: apiregistration.k8s.io/v1beta1 +kind: APIService +metadata: + name: v1beta1.metrics.k8s.io +spec: + service: + name: metrics-server + namespace: kube-system + group: metrics.k8s.io + version: v1beta1 + insecureSkipTLSVerify: true + groupPriorityMinimum: 100 + versionPriority: 100 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metrics-server + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metrics-server + namespace: kube-system + labels: + k8s-app: metrics-server +spec: + selector: + matchLabels: + k8s-app: metrics-server + template: + metadata: + name: metrics-server + labels: + k8s-app: metrics-server + spec: + serviceAccountName: metrics-server + volumes: + # mount in tmp so we can safely use from-scratch images and/or read-only containers + - name: tmp-dir + emptyDir: {} + containers: + - name: metrics-server + image: k8s.gcr.io/metrics-server-amd64:v0.3.1 + args: + - --kubelet-insecure-tls + - --kubelet-preferred-address-types=InternalIP + imagePullPolicy: Always + volumeMounts: + - name: tmp-dir + mountPath: /tmp + +--- +apiVersion: v1 +kind: Service +metadata: + name: metrics-server + namespace: kube-system + labels: + kubernetes.io/name: 'Metrics-server' +spec: + selector: + k8s-app: metrics-server + ports: + - port: 443 + protocol: TCP + targetPort: 443 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:metrics-server +rules: + - apiGroups: + - '' + resources: + - pods + - nodes + - nodes/stats + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:metrics-server +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:metrics-server +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 0b3f7cb3cb..253cdfde18 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes-backend", "description": "A Backstage backend plugin that integrates towards Kubernetes", - "version": "0.3.19", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,13 +32,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.13", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.5", - "@backstage/plugin-kubernetes-common": "^0.1.6", + "@backstage/plugin-kubernetes-common": "^0.2.0", "@google-cloud/container": "^2.2.0", - "@kubernetes/client-node": "^0.15.0", + "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", @@ -55,7 +55,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.1", "@types/aws4": "^1.5.1", "supertest": "^6.1.3", "aws-sdk-mock": "^5.2.1", diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index e69ac052f1..7f5183e337 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -63,5 +63,23 @@ export interface Config { apiVersion: string; plural: string; }>; + + /** + * (Optional) API Version Overrides + * If set, the specified api version will be used to make requests for the corresponding object. + * If running a legacy Kubernetes version, you may use this to override the default api versions + * that are not supported in your cluster. + */ + apiVersionOverrides?: { + pods?: string; + services?: string; + configmaps?: string; + deployments?: string; + replicasets?: string; + horizontalpodautoscalers?: string; + cronjobs?: string; + jobs?: string; + ingresses?: string; + } & { [pluralKind: string]: string }; }; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index 62346ac6c3..f5d6b56893 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -52,6 +52,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -67,6 +68,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, dashboardUrl: 'https://k8s.foo.com', }, { @@ -74,6 +76,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, }, ], }); @@ -90,6 +93,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, caData: undefined, }, { @@ -98,6 +102,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); @@ -144,6 +149,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'aws', skipTLSVerify: false, + skipMetricsLookup: false, caData: undefined, }, { @@ -154,6 +160,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, { @@ -164,6 +171,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index bb9dbfe13f..36b5f58f2c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -35,6 +35,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false, caData: c.getOptionalString('caData'), authProvider: authProvider, }; diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 545f839907..bd342f566b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -93,6 +93,7 @@ describe('GkeClusterLocator', () => { type: 'gke', projectId: 'some-project', region: 'some-region', + skipMetricsLookup: true, }); const sut = GkeClusterLocator.fromConfigWithClient(config, { @@ -107,6 +108,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: true, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -143,6 +145,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -184,12 +187,14 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, { authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 3aa4a3e941..d7f5050399 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -23,6 +23,7 @@ type GkeClusterLocatorOptions = { projectId: string; region?: string; skipTLSVerify?: boolean; + skipMetricsLookup?: boolean; }; export class GkeClusterLocator implements KubernetesClustersSupplier { @@ -39,6 +40,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { projectId: config.getString('projectId'), region: config.getOptionalString('region') ?? '-', skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: + config.getOptionalBoolean('skipMetricsLookup') ?? false, }; return new GkeClusterLocator(options, client); } @@ -52,7 +55,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // TODO pass caData into the object async getClusters(): Promise { - const { projectId, region, skipTLSVerify } = this.options; + const { projectId, region, skipTLSVerify, skipMetricsLookup } = + this.options; const request = { parent: `projects/${projectId}/locations/${region}`, }; @@ -65,6 +69,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { url: `https://${r.endpoint ?? ''}`, authProvider: 'google', skipTLSVerify, + skipMetricsLookup, })); } catch (e) { throw new ForwardedError( diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index dde4dc1d93..2bc2c719a0 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -53,6 +53,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -61,6 +62,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index de0dcdf373..37dad8c3e4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -29,6 +29,7 @@ import { } from '../types/types'; import { KubernetesBuilder } from './KubernetesBuilder'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node'; describe('KubernetesBuilder', () => { let app: express.Express; @@ -194,6 +195,7 @@ describe('KubernetesBuilder', () => { name: someCluster.name, }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -211,6 +213,12 @@ describe('KubernetesBuilder', () => { }; const fetcher: KubernetesFetcher = { + fetchPodMetricsByNamespace( + _clusterDetails: ClusterDetails, + _namespace: string, + ): Promise { + return Promise.resolve([]); + }, fetchObjectsForService( _params: ObjectFetchParams, ): Promise { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 11cca4917f..3333f7a0fc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -243,6 +243,10 @@ export class KubernetesBuilder { 'kubernetes.objectTypes', ) as KubernetesObjectTypes[]; + const apiVersionOverrides = this.env.config.getOptionalConfig( + 'kubernetes.apiVersionOverrides', + ); + let objectTypesToFetch; if (objectTypesToFetchStrings) { @@ -250,6 +254,17 @@ export class KubernetesBuilder { objectTypesToFetchStrings.includes(obj.objectType), ); } + + if (apiVersionOverrides) { + objectTypesToFetch = objectTypesToFetch ?? DEFAULT_OBJECTS; + + for (const obj of objectTypesToFetch) { + if (apiVersionOverrides.has(obj.objectType)) { + obj.apiVersion = apiVersionOverrides.getString(obj.objectType); + } + } + } + return objectTypesToFetch; } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index 96bd1fe7c1..e813a63f0d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -47,14 +47,14 @@ describe('KubernetesClientProvider', () => { expect(mockGetKubeConfig.mock.calls.length).toBe(1); }); - it('can get apps client by cluster details', async () => { + it('can get custom objects client by cluster details', async () => { const sut = new KubernetesClientProvider(); const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); sut.getKubeConfig = mockGetKubeConfig; - const result = sut.getAppsClientByClusterDetails({ + const result = sut.getCustomObjectsClient({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts index 5f6825ada9..031e40547c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -15,12 +15,9 @@ */ import { - AppsV1Api, - BatchV1Api, - AutoscalingV1Api, CoreV1Api, KubeConfig, - NetworkingV1beta1Api, + Metrics, CustomObjectsApi, } from '@kubernetes/client-node'; import { ClusterDetails } from '../types/types'; @@ -63,28 +60,10 @@ export class KubernetesClientProvider { return kc.makeApiClient(CoreV1Api); } - getAppsClientByClusterDetails(clusterDetails: ClusterDetails) { + getMetricsClient(clusterDetails: ClusterDetails) { const kc = this.getKubeConfig(clusterDetails); - return kc.makeApiClient(AppsV1Api); - } - - getAutoscalingClientByClusterDetails(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(AutoscalingV1Api); - } - - getBatchClientByClusterDetails(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(BatchV1Api); - } - - getNetworkingBeta1Client(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(NetworkingV1beta1Api); + return new Metrics(kc); } getCustomObjectsClient(clusterDetails: ClusterDetails) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 7973448467..da8f703ba0 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -15,13 +15,30 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { ObjectFetchParams } from '../types/types'; +import { ClusterDetails, ObjectFetchParams } from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; const fetchObjectsForService = jest.fn(); +const fetchPodMetricsByNamespace = jest.fn(); const getClustersByServiceId = jest.fn(); +const POD_METRICS_FIXTURE = { + containers: [], + cpu: { + currentUsage: 100, + limitTotal: 102, + requestTotal: 101, + }, + memory: { + currentUsage: '1000', + limitTotal: '1002', + requestTotal: '1001', + }, + pod: {}, +}; + const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((params: ObjectFetchParams) => Promise.resolve( @@ -33,6 +50,34 @@ const mockFetch = (mock: jest.Mock) => { ); }; +const mockMetrics = (mock: jest.Mock) => { + mock.mockImplementation((clusterDetails: ClusterDetails, namespace: string) => + Promise.resolve(generatePodStatus(clusterDetails.name, namespace)), + ); +}; + +function generatePodStatus( + _clusterName: string, + _namespace: string, +): PodStatus[] { + return [ + { + Pod: {}, + CPU: { + CurrentUsage: 100, + RequestTotal: 101, + LimitTotal: 102, + }, + Memory: { + CurrentUsage: BigInt('1000'), + RequestTotal: BigInt('1001'), + LimitTotal: BigInt('1002'), + }, + Containers: [], + }, + ] as any; +} + function generateMockResourcesAndErrors( serviceId: string, clusterName: string, @@ -84,6 +129,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-pods-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -94,6 +140,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-configmaps-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -104,6 +151,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-services-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -128,11 +176,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -161,6 +211,11 @@ describe('handleGetKubernetesObjectsForService', () => { expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe( + 'ns-test-component-test-cluster', + ); + expect(result).toStrictEqual({ items: [ { @@ -168,12 +223,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -184,6 +241,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -194,6 +252,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -205,6 +264,124 @@ describe('handleGetKubernetesObjectsForService', () => { }); }); + it('dont call top for the same namespace twice', async () => { + getClustersByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + ]), + ); + + fetchObjectsForService.mockImplementation((_: ObjectFetchParams) => + Promise.resolve({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: `pod1`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod2`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod3`, + namespace: `ns-b`, + }, + }, + ], + }, + ], + }), + ); + + mockMetrics(fetchPodMetricsByNamespace); + + const sut = new KubernetesFanOutHandler({ + logger: getVoidLogger(), + fetcher: { + fetchObjectsForService, + fetchPodMetricsByNamespace, + }, + serviceLocator: { + getClustersByServiceId, + }, + customResources: [], + }); + + const result = await sut.getKubernetesObjectsByEntity({ + 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(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(2); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe('ns-a'); + expect(fetchPodMetricsByNamespace.mock.calls[1][1]).toBe('ns-b'); + + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + podMetrics: [POD_METRICS_FIXTURE, POD_METRICS_FIXTURE], + resources: [ + { + resources: [ + { + metadata: { + name: 'pod1', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod2', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod3', + namespace: 'ns-b', + }, + }, + ], + type: 'pods', + }, + ], + }, + ], + }); + }); + it('retrieve objects for two clusters', async () => { getClustersByServiceId.mockImplementation(() => Promise.resolve([ @@ -221,11 +398,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -265,12 +444,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -281,6 +462,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -291,6 +473,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -303,12 +486,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -319,6 +504,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -329,6 +515,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -358,11 +545,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -401,12 +590,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -417,6 +608,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -427,6 +619,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -439,12 +632,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -455,6 +650,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -465,6 +661,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -498,11 +695,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -541,12 +740,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -557,6 +758,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -567,6 +769,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -579,12 +782,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -595,6 +800,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -605,6 +811,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -617,6 +824,7 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'error-cluster', }, errors: ['some random cluster error'], + podMetrics: [], resources: [ { type: 'pods', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 41d75a9835..a25c220b80 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -27,9 +27,19 @@ import { import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; import { + ClientContainerStatus, + ClientCurrentResourceUsage, + ClientPodStatus, ClusterObjects, + FetchResponse, ObjectsByEntityResponse, + PodFetchResponse, } from '@backstage/plugin-kubernetes-common'; +import { + ContainerStatus, + CurrentResourceUsage, + PodStatus, +} from '@kubernetes/client-node'; export const DEFAULT_OBJECTS: ObjectToFetch[] = [ { @@ -93,6 +103,50 @@ export interface KubernetesFanOutHandlerOptions export interface KubernetesRequestBody extends ObjectsByEntityRequest {} +const isPodFetchResponse = (fr: FetchResponse): fr is PodFetchResponse => + fr.type === 'pods'; +const isString = (str: string | undefined): str is string => str !== undefined; + +const numberOrBigIntToNumberOrString = ( + value: number | BigInt, +): number | string => { + // @ts-ignore + return typeof value === 'bigint' ? value.toString() : value; +}; + +const toClientSafeResource = ( + current: CurrentResourceUsage, +): ClientCurrentResourceUsage => { + return { + currentUsage: numberOrBigIntToNumberOrString(current.CurrentUsage), + requestTotal: numberOrBigIntToNumberOrString(current.RequestTotal), + limitTotal: numberOrBigIntToNumberOrString(current.LimitTotal), + }; +}; + +const toClientSafeContainer = ( + container: ContainerStatus, +): ClientContainerStatus => { + return { + container: container.Container, + cpuUsage: toClientSafeResource(container.CPUUsage), + memoryUsage: toClientSafeResource(container.MemoryUsage), + }; +}; + +const toClientSafePodMetrics = ( + podMetrics: PodStatus[][], +): ClientPodStatus[] => { + return podMetrics.flat().map((pd: PodStatus): ClientPodStatus => { + return { + pod: pd.Pod, + memory: toClientSafeResource(pd.Memory), + cpu: toClientSafeResource(pd.CPU), + containers: pd.Containers.map(toClientSafeContainer), + }; + }); +}; + export class KubernetesFanOutHandler { private readonly logger: Logger; private readonly fetcher: KubernetesFetcher; @@ -162,10 +216,36 @@ export class KubernetesFanOutHandler { customResources: this.customResources, }) .then(result => { + if (clusterDetailsItem.skipMetricsLookup) { + return Promise.all([ + Promise.resolve(result), + Promise.resolve([]), + ]); + } + // TODO refactor, extract as method + const namespaces: Set = new Set( + result.responses + .filter(isPodFetchResponse) + .flatMap(r => r.resources) + .map(p => p.metadata?.namespace) + .filter(isString), + ); + + const podMetrics = Array.from(namespaces).map(ns => + this.fetcher.fetchPodMetricsByNamespace(clusterDetailsItem, ns), + ); + + return Promise.all([ + Promise.resolve(result), + Promise.all(podMetrics), + ]); + }) + .then(([result, metrics]) => { const objects: ClusterObjects = { cluster: { name: clusterDetailsItem.name, }, + podMetrics: toClientSafePodMetrics(metrics), resources: result.responses, errors: result.errors, }; diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index e86901ef92..e1f701b75c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -106,6 +106,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -116,6 +117,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -197,6 +199,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -207,6 +210,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -316,6 +320,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -326,6 +331,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -336,6 +342,7 @@ describe('KubernetesFetcher', () => { 'v2', 'things', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index e845ebeaeb..a2c3b9a6a8 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -14,13 +14,7 @@ * limitations under the License. */ -import { - AppsV1Api, - AutoscalingV1Api, - BatchV1Api, - CoreV1Api, - NetworkingV1beta1Api, -} from '@kubernetes/client-node'; +import { CoreV1Api, topPods } from '@kubernetes/client-node'; import lodash, { Dictionary } from 'lodash'; import { Logger } from 'winston'; import { @@ -37,13 +31,10 @@ import { KubernetesErrorTypes, } from '@backstage/plugin-kubernetes-common'; import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface Clients { core: CoreV1Api; - apps: AppsV1Api; - autoscaling: AutoscalingV1Api; - batch: BatchV1Api; - networkingBeta1: NetworkingV1beta1Api; } export interface KubernetesClientBasedFetcherOptions { @@ -112,10 +103,26 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); } + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise { + const metricsClient = + this.kubernetesClientProvider.getMetricsClient(clusterDetails); + const coreApi = + this.kubernetesClientProvider.getCoreClientByClusterDetails( + clusterDetails, + ); + + return topPods(coreApi, metricsClient, namespace); + } + private captureKubernetesErrorsRethrowOthers(e: any): KubernetesFetchError { if (e.response && e.response.statusCode) { - this.logger.info( - `statusCode=${e.response.statusCode} for resource ${e.response.request.uri.pathname}`, + this.logger.warn( + `statusCode=${e.response.statusCode} for resource ${ + e.response.request.uri.pathname + } body=[${JSON.stringify(e.response.body)}]`, ); return { errorType: statusCodeToErrorType(e.response.statusCode), @@ -145,6 +152,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { resource.apiVersion, resource.plural, '', + false, '', '', labelSelector, diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index b8aa482e47..a0ec0832c1 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -21,6 +21,7 @@ import type { KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface ObjectFetchParams { serviceId: string; @@ -40,6 +41,10 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } export interface FetchResponseWrapper { @@ -93,6 +98,11 @@ export interface ClusterDetails { authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + /** + * Whether to skip the lookup to the metrics server to retrieve pod resource usage. + * It is not guaranteed that the Kubernetes distro has the metrics server installed. + */ + skipMetricsLookup?: boolean; caData?: string | undefined; /** * Specifies the link to the Kubernetes dashboard managing this cluster. diff --git a/plugins/kubernetes-common/CHANGELOG.md b/plugins/kubernetes-common/CHANGELOG.md index b0e7aca129..8580e73fa7 100644 --- a/plugins/kubernetes-common/CHANGELOG.md +++ b/plugins/kubernetes-common/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-kubernetes-common +## 0.2.0 + +### Minor Changes + +- c010632f88: Add pod metrics lookup and display in pod table. + + ## Backwards incompatible changes + + If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed, + you will need to set the `skipMetricsLookup` config flag to `false`. + + See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details. + +## 0.1.7 + +### Patch Changes + +- 59677fadb1: Improvements to API Reference documentation + ## 0.1.6 ### Patch Changes diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 7db05a7513..f3500aa271 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -4,11 +4,11 @@ ```ts import { Entity } from '@backstage/catalog-model'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; import { V1ConfigMap } from '@kubernetes/client-node'; import { V1CronJob } from '@kubernetes/client-node'; import { V1Deployment } from '@kubernetes/client-node'; import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { V1Job } from '@kubernetes/client-node'; import { V1Pod } from '@kubernetes/client-node'; import { V1ReplicaSet } from '@kubernetes/client-node'; @@ -19,6 +19,44 @@ import { V1Service } from '@kubernetes/client-node'; // @public (undocumented) export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +// Warning: (ae-missing-release-tag) "ClientContainerStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientContainerStatus { + // (undocumented) + container: string; + // (undocumented) + cpuUsage: ClientCurrentResourceUsage; + // (undocumented) + memoryUsage: ClientCurrentResourceUsage; +} + +// Warning: (ae-missing-release-tag) "ClientCurrentResourceUsage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientCurrentResourceUsage { + // (undocumented) + currentUsage: number | string; + // (undocumented) + limitTotal: number | string; + // (undocumented) + requestTotal: number | string; +} + +// Warning: (ae-missing-release-tag) "ClientPodStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientPodStatus { + // (undocumented) + containers: ClientContainerStatus[]; + // (undocumented) + cpu: ClientCurrentResourceUsage; + // (undocumented) + memory: ClientCurrentResourceUsage; + // (undocumented) + pod: V1Pod; +} + // Warning: (ae-missing-release-tag) "ClusterAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -37,6 +75,8 @@ export interface ClusterObjects { // (undocumented) errors: KubernetesFetchError[]; // (undocumented) + podMetrics: ClientPodStatus[]; + // (undocumented) resources: FetchResponse[]; } @@ -110,7 +150,7 @@ export interface HorizontalPodAutoscalersFetchResponse { // @public (undocumented) export interface IngressesFetchResponse { // (undocumented) - resources: Array; + resources: Array; // (undocumented) type: 'ingresses'; } diff --git a/plugins/kubernetes-common/package.json b/plugins/kubernetes-common/package.json index a3ab7710c0..124e3415a7 100644 --- a/plugins/kubernetes-common/package.json +++ b/plugins/kubernetes-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes-common", "description": "Common functionalities for kubernetes, to be shared between kubernetes and kubernetes-backend plugin", - "version": "0.1.6", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -36,10 +36,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@kubernetes/client-node": "^0.15.0" + "@kubernetes/client-node": "^0.16.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1" + "@backstage/cli": "^0.10.1" }, "jest": { "roots": [ diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index af146ae9fa..6b97c0eb7d 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -15,7 +15,7 @@ */ /** - * Common functionalities for kubernetes, to be shared between kubernetes and kubernetes-backend plugin + * Common functionalities for Kubernetes, to be shared between the `kubernetes` and `kubernetes-backend` plugins * * @packageDocumentation */ diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 8f4daa111b..0c5af00a97 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -15,11 +15,11 @@ */ import { - ExtensionsV1beta1Ingress, V1ConfigMap, V1CronJob, V1Deployment, V1HorizontalPodAutoscaler, + V1Ingress, V1Job, V1Pod, V1ReplicaSet, @@ -69,6 +69,7 @@ export interface ClusterAttributes { export interface ClusterObjects { cluster: ClusterAttributes; resources: FetchResponse[]; + podMetrics: ClientPodStatus[]; errors: KubernetesFetchError[]; } @@ -132,7 +133,7 @@ export interface CronJobsFetchResponse { export interface IngressesFetchResponse { type: 'ingresses'; - resources: Array; + resources: Array; } export interface CustomResourceFetchResponse { @@ -151,3 +152,22 @@ export type KubernetesErrorTypes = | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; + +export interface ClientCurrentResourceUsage { + currentUsage: number | string; + requestTotal: number | string; + limitTotal: number | string; +} + +export interface ClientContainerStatus { + container: string; + cpuUsage: ClientCurrentResourceUsage; + memoryUsage: ClientCurrentResourceUsage; +} + +export interface ClientPodStatus { + pod: V1Pod; + cpu: ClientCurrentResourceUsage; + memory: ClientCurrentResourceUsage; + containers: ClientContainerStatus[]; +} diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index 437ea327e4..94826738b3 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,49 @@ # @backstage/plugin-kubernetes +## 0.5.1 + +### Patch Changes + +- 6f0c850a86: Fixed styling bug for the CronJobsAccordions and updated Completed pods to display a green dot. +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/catalog-model@0.9.8 + - @backstage/plugin-catalog-react@0.6.7 + +## 0.5.0 + +### Minor Changes + +- c010632f88: Add pod metrics lookup and display in pod table. + + ## Backwards incompatible changes + + If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed, + you will need to set the `skipMetricsLookup` config flag to `false`. + + See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details. + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-kubernetes-common@0.2.0 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.4.22 + +### Patch Changes + +- 86ed770308: Added accordions to display information on Jobs and CronJobs in the kubernetes plugin. Updated the PodsTable with fewer default columns and the ability to pass in additional ones depending on the use case. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + - @backstage/plugin-kubernetes-common@0.1.7 + ## 0.4.21 ### Patch Changes diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 12de5f098f..068f26b7f5 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -29,6 +29,8 @@ import { } from '@backstage/plugin-kubernetes-common'; import fixture1 from '../src/__fixtures__/1-deployments.json'; import fixture2 from '../src/__fixtures__/2-deployments.json'; +import fixture3 from '../src/__fixtures__/1-cronjobs.json'; +import fixture4 from '../src/__fixtures__/2-cronjobs.json'; import { TestApiProvider } from '@backstage/test-utils'; const mockEntity: Entity = { @@ -64,6 +66,7 @@ class MockKubernetesClient implements KubernetesApi { { cluster: { name: 'mock-cluster' }, resources: this.resources, + podMetrics: [], errors: [], }, ], @@ -102,5 +105,31 @@ createDevApp() ), }) + .addPage({ + path: '/fixture-3', + title: 'Fixture 3', + element: ( + + + + + + ), + }) + .addPage({ + path: '/fixture-4', + title: 'Fixture 4', + element: ( + + + + + + ), + }) .registerPlugin(kubernetesPlugin) .render(); diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 0c78649cf7..02b8e85855 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes", "description": "A Backstage plugin that integrates towards Kubernetes", - "version": "0.4.21", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,30 +31,32 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.7", + "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/plugin-kubernetes-common": "^0.1.6", - "@backstage/theme": "^0.2.13", - "@kubernetes/client-node": "^0.15.0", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.7", + "@backstage/plugin-kubernetes-common": "^0.2.0", + "@kubernetes/client-node": "^0.16.0", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "cronstrue": "^1.122.0", "js-yaml": "^4.0.0", "lodash": "^4.17.21", "luxon": "^2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kubernetes/src/__fixtures__/1-cronjobs.json b/plugins/kubernetes/src/__fixtures__/1-cronjobs.json new file mode 100644 index 0000000000..1edc43710d --- /dev/null +++ b/plugins/kubernetes/src/__fixtures__/1-cronjobs.json @@ -0,0 +1,446 @@ +{ + "cronJobs": [ + { + "metadata": { + "name": "dice-roller-cronjob", + "namespace": "default", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "schedule": "30 5 * * *", + "startingDeadlineSeconds": 1800, + "concurrencyPolicy": "Forbid", + "suspend": false, + "jobTemplate": { + "metadata": { "creationTimestamp": null }, + "spec": { + "backoffLimit": 2, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "name": "busybox", + "image": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"] + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + "successfulJobsHistoryLimit": 2, + "failedJobsHistoryLimit": 2 + }, + "status": { + "active": [ + { + "kind": "Job", + "namespace": "default", + "name": "dice-roller-cronjob-1637028600", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "apiVersion": "batch/v1", + "resourceVersion": "1361174163" + } + ], + "lastScheduleTime": "2021-11-16T02:10:00Z" + } + } + ], + "jobs": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000", + "namespace": "default", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "resourceVersion": "1361029181", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { + "conditions": [ + { + "type": "Complete", + "status": "True", + "lastProbeTime": "2021-11-16T01:11:31Z", + "lastTransitionTime": "2021-11-16T01:11:31Z" + } + ], + "startTime": "2021-11-16T01:10:24Z", + "completionTime": "2021-11-16T01:11:31Z", + "succeeded": 1 + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637028600", + "namespace": "default", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "resourceVersion": "1361174166", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "job-name": "dice-roller-cronjob-1637028600" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { "startTime": "2021-11-16T02:10:22Z", "active": 1 } + } + ], + "pods": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-gstc4", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0", + "resourceVersion": "1361029179", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Succeeded", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:10:27Z", + "reason": "PodCompleted" + }, + { + "type": "Ready", + "status": "False", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:11:31Z", + "reason": "PodCompleted" + }, + { + "type": "ContainersReady", + "status": "False", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:11:31Z", + "reason": "PodCompleted" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:10:24Z" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T01:10:24Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed", + "startedAt": "2021-11-16T01:10:31Z", + "finishedAt": "2021-11-16T01:11:30Z", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615" + } + }, + "lastState": {}, + "ready": false, + "restartCount": 0, + "image": "busybox:latest/node", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": false + } + ], + "qosClass": "Burstable" + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637028600-p4mlc", + "generateName": "dice-roller-cronjob-1637028600-", + "namespace": "default", + "uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39", + "resourceVersion": "1361174579", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "job-name": "dice-roller-cronjob-1637028600" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637028600", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Running", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:25Z" + }, + { + "type": "Ready", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:35Z" + }, + { + "type": "ContainersReady", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:35Z" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:22Z" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T02:10:22Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "running": { "startedAt": "2021-11-16T02:10:31Z" } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": true + } + ], + "qosClass": "Burstable" + } + } + ] +} diff --git a/plugins/kubernetes/src/__fixtures__/2-cronjobs.json b/plugins/kubernetes/src/__fixtures__/2-cronjobs.json new file mode 100644 index 0000000000..6c087023b7 --- /dev/null +++ b/plugins/kubernetes/src/__fixtures__/2-cronjobs.json @@ -0,0 +1,385 @@ +{ + "cronJobs": [ + { + "metadata": { + "name": "dice-roller-cronjob", + "namespace": "default", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "schedule": "* */2 * * *", + "startingDeadlineSeconds": 1800, + "concurrencyPolicy": "Forbid", + "suspend": true, + "jobTemplate": { + "metadata": { "creationTimestamp": null }, + "spec": { + "backoffLimit": 2, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "name": "busybox", + "image": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"] + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + "successfulJobsHistoryLimit": 2, + "failedJobsHistoryLimit": 2 + }, + "status": { + "lastScheduleTime": "2021-11-16T02:10:00Z" + } + } + ], + "jobs": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000", + "namespace": "default", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "resourceVersion": "1361029181", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { + "conditions": [ + { + "type": "Failed", + "status": "True", + "reason": "BackoffLimitExceeded", + "lastProbeTime": "2021-11-16T01:11:31Z", + "lastTransitionTime": "2021-11-16T01:11:31Z" + } + ], + "startTime": "2021-11-16T01:10:24Z", + "failed": 2 + } + } + ], + "pods": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-gstc4", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0", + "resourceVersion": "1361029179", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Failed", + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:13Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:08Z", + "status": "True", + "type": "PodScheduled" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-18T19:10:08Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 1, + "finishedAt": "2021-11-18T19:11:01Z", + "reason": "Error", + "startedAt": "2021-11-18T19:10:17Z", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615" + } + }, + "lastState": {}, + "ready": false, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": false + } + ], + "qosClass": "Burstable" + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-p4mlc", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39", + "resourceVersion": "1361174579", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Failed", + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:13Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:08Z", + "status": "True", + "type": "PodScheduled" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T02:10:22Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 1, + "finishedAt": "2021-11-18T19:11:01Z", + "reason": "Error", + "startedAt": "2021-11-18T19:10:17Z", + "containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739", + "started": true + } + ], + "qosClass": "Burstable" + } + } + ] +} diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 5132c8794d..8b95dd820c 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -22,8 +22,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', - description: - 'Used by the Kubernetes plugin to make requests to accompanying backend', }); export interface KubernetesApi { diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx index ddadd95272..620af05e69 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx @@ -46,6 +46,7 @@ describe('Cluster', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, podsWithErrors: new Set(), diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.tsx index d238396f53..eb12ba20f8 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.tsx @@ -23,12 +23,16 @@ import { Grid, Typography, } from '@material-ui/core'; -import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; +import { + ClientPodStatus, + ClusterObjects, +} from '@backstage/plugin-kubernetes-common'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { DeploymentsAccordions } from '../DeploymentsAccordions'; import { groupResponses } from '../../utils/response'; import { IngressesAccordions } from '../IngressesAccordions'; import { ServicesAccordions } from '../ServicesAccordions'; +import { CronJobsAccordions } from '../CronJobsAccordions'; import { CustomResources } from '../CustomResources'; import { ClusterContext, @@ -37,6 +41,7 @@ import { } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; type ClusterSummaryProps = { clusterName: string; @@ -107,36 +112,50 @@ type ClusterProps = { export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); + const podNameToMetrics = clusterObjects.podMetrics + .flat() + .reduce((accum, next) => { + const name = next.pod.metadata?.name; + if (name !== undefined) { + accum.set(name, next); + } + return accum; + }, new Map()); return ( - - - }> - - - - - - + + + + }> + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + ); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx new file mode 100644 index 0000000000..9d2df328d2 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CronJobsAccordions } from './CronJobsAccordions'; +import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import * as twoCronJobsFixture from '../../__fixtures__/2-cronjobs.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { kubernetesProviders } from '../../hooks/test-utils'; + +describe('CronJobsAccordions', () => { + it('should render 1 active cronjobs', async () => { + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob')).toBeInTheDocument(); + expect(getByText('CronJob')).toBeInTheDocument(); + expect(getByText('namespace: default')).toBeInTheDocument(); + expect(getByText('Active')).toBeInTheDocument(); + }); + + it('should render 1 suspended cronjobs', async () => { + const wrapper = kubernetesProviders(twoCronJobsFixture, new Set()); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob')).toBeInTheDocument(); + expect(getByText('CronJob')).toBeInTheDocument(); + expect(getByText('namespace: default')).toBeInTheDocument(); + expect(getByText('Suspended')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx new file mode 100644 index 0000000000..2581678928 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, { useContext } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1CronJob, V1Job } from '@kubernetes/client-node'; +import { JobsAccordions } from '../JobsAccordions'; +import { CronJobDrawer } from './CronJobsDrawer'; +import { getOwnedResources } from '../../utils/owner'; +import { GroupedResponsesContext } from '../../hooks'; +import { StatusError, StatusOK } from '@backstage/core-components'; +import cronstrue from 'cronstrue'; + +type CronJobsAccordionsProps = { + children?: React.ReactNode; +}; + +type CronJobAccordionProps = { + cronJob: V1CronJob; + ownedJobs: V1Job[]; + children?: React.ReactNode; +}; + +type CronJobSummaryProps = { + cronJob: V1CronJob; + children?: React.ReactNode; +}; + +const CronJobSummary = ({ cronJob }: CronJobSummaryProps) => { + return ( + + + + + + + + + + {cronJob.spec?.suspend ? ( + Suspended + ) : ( + Active + )} + + + + Schedule:{' '} + {cronJob.spec?.schedule + ? `${cronJob.spec.schedule} (${cronstrue.toString( + cronJob.spec.schedule, + )})` + : 'N/A'} + + + + + ); +}; + +const CronJobAccordion = ({ cronJob, ownedJobs }: CronJobAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const CronJobsAccordions = ({}: CronJobsAccordionsProps) => { + const groupedResponses = useContext(GroupedResponsesContext); + + return ( + + {groupedResponses.cronJobs.map((cronJob, i) => ( + + + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx new file mode 100644 index 0000000000..b16e83ee50 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CronJobDrawer } from './CronJobsDrawer'; + +describe('CronJobDrawer', () => { + it('should render cronJob drawer', async () => { + const { getByText, getAllByText } = await renderInTestApp( + , + ); + + expect(getAllByText('dice-roller-cronjob')).toHaveLength(2); + expect(getAllByText('CronJob')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Schedule')).toBeInTheDocument(); + expect(getByText('30 5 * * *')).toBeInTheDocument(); + expect(getByText('Starting Deadline Seconds')).toBeInTheDocument(); + expect(getByText('Last Schedule Time')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx new file mode 100644 index 0000000000..758f17ade0 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { V1CronJob } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid, Chip } from '@material-ui/core'; + +export const CronJobDrawer = ({ + cronJob, + expanded, +}: { + cronJob: V1CronJob; + expanded?: boolean; +}) => { + const namespace = cronJob.metadata?.namespace; + return ( + ({ + schedule: cronJobObj.spec?.schedule ?? '???', + startingDeadlineSeconds: + cronJobObj.spec?.startingDeadlineSeconds ?? '???', + concurrencyPolicy: cronJobObj.spec?.concurrencyPolicy ?? '???', + lastScheduleTime: cronJobObj.status?.lastScheduleTime ?? '???', + })} + > + + + + {cronJob.metadata?.name ?? 'unknown object'} + + + + + CronJob + + + {namespace && ( + + + + )} + + + ); +}; diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/index.ts b/plugins/kubernetes/src/components/CronJobsAccordions/index.ts new file mode 100644 index 0000000000..e72e3f7e6d --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CronJobsAccordions } from './CronJobsAccordions'; diff --git a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx index e14cd9093a..52734e39a8 100644 --- a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx +++ b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx @@ -41,6 +41,7 @@ import { getOwnedPodsThroughReplicaSets, } from '../../../utils/owner'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../../Pods/PodsTable'; type RolloutAccordionsProps = { rollouts: any[]; @@ -237,7 +238,10 @@ const RolloutAccordion = ({ />

- +
diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx index e27c2bfbe1..58ab2fc2ba 100644 --- a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -41,6 +41,7 @@ import { PodNamesWithErrorsContext, } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../Pods/PodsTable'; type DeploymentsAccordionsProps = { children?: React.ReactNode; @@ -161,7 +162,10 @@ const DeploymentAccordion = ({ /> - + ); diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx index 02154c4825..832d6eed91 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx @@ -51,6 +51,7 @@ describe('ErrorPanel', () => { name: 'THIS_CLUSTER', }, resources: [], + podMetrics: [], errors: [ { errorType: 'SYSTEM_ERROR', diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx index 0018c4b070..dc88987d53 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; import { Typography, Grid } from '@material-ui/core'; @@ -23,7 +23,7 @@ export const IngressDrawer = ({ ingress, expanded, }: { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; expanded?: boolean; }) => { return ( @@ -31,7 +31,7 @@ export const IngressDrawer = ({ object={ingress} expanded={expanded} kind="Ingress" - renderObject={(ingressObject: ExtensionsV1beta1Ingress) => { + renderObject={(ingressObject: V1Ingress) => { return ingressObject.spec || {}; }} > diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx index c1d94fef7b..f6d6e4bf68 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx @@ -23,7 +23,7 @@ import { Grid, } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { IngressDrawer } from './IngressDrawer'; import { GroupedResponsesContext } from '../../hooks'; import { StructuredMetadataTable } from '@backstage/core-components'; @@ -31,11 +31,11 @@ import { StructuredMetadataTable } from '@backstage/core-components'; type IngressesAccordionsProps = {}; type IngressAccordionProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; type IngressSummaryProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressSummary = ({ ingress }: IngressSummaryProps) => { @@ -58,7 +58,7 @@ const IngressSummary = ({ ingress }: IngressSummaryProps) => { }; type IngressCardProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressCard = ({ ingress }: IngressCardProps) => { diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx new file mode 100644 index 0000000000..ea16e933c6 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { JobsAccordions } from './JobsAccordions'; +import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { kubernetesProviders } from '../../hooks/test-utils'; +import { V1Job, ObjectSerializer } from '@kubernetes/client-node'; + +describe('JobsAccordions', () => { + it('should render 2 jobs', async () => { + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); + + const jobs: V1Job[] = oneCronJobsFixture.jobs.map( + job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job, + ); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob-1637028600')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + + expect(getByText('dice-roller-cronjob-1637025000')).toBeInTheDocument(); + expect(getByText('Succeeded')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx new file mode 100644 index 0000000000..715be4c88a --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, { useContext } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1Job, V1Pod } from '@kubernetes/client-node'; +import { PodsTable } from '../Pods'; +import { JobDrawer } from './JobsDrawer'; +import { getOwnedResources } from '../../utils/owner'; +import { GroupedResponsesContext } from '../../hooks'; +import { + StatusError, + StatusOK, + StatusPending, +} from '@backstage/core-components'; + +type JobsAccordionsProps = { + jobs: V1Job[]; + children?: React.ReactNode; +}; + +type JobAccordionProps = { + job: V1Job; + ownedPods: V1Pod[]; + children?: React.ReactNode; +}; + +type JobSummaryProps = { + job: V1Job; + children?: React.ReactNode; +}; + +const JobSummary = ({ job }: JobSummaryProps) => { + return ( + + + + + + + + + + {job.status?.succeeded && Succeeded} + {job.status?.active && Running} + {job.status?.failed && Failed} + + Start time: {job.status?.startTime?.toString()} + {job.status?.completionTime && ( + + Completion time: {job.status.completionTime.toString()} + + )} + + + ); +}; + +const JobAccordion = ({ job, ownedPods }: JobAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const JobsAccordions = ({ jobs }: JobsAccordionsProps) => { + const groupedResponses = useContext(GroupedResponsesContext); + + return ( + + {jobs.map((job, i) => ( + + + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx new file mode 100644 index 0000000000..48a4e6fe7e --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { renderInTestApp } from '@backstage/test-utils'; +import { JobDrawer } from './JobsDrawer'; + +describe('JobDrawer', () => { + it('should render job drawer', async () => { + const { getByText, getAllByText } = await renderInTestApp( + , + ); + + expect(getAllByText('dice-roller-cronjob-1637025000')).toHaveLength(2); + expect(getAllByText('Job')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Parallelism')).toBeInTheDocument(); + expect(getByText('Completions')).toBeInTheDocument(); + expect(getByText('Backoff Limit')).toBeInTheDocument(); + expect(getByText('Start Time')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx new file mode 100644 index 0000000000..a7217725b2 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { V1Job } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const JobDrawer = ({ + job, + expanded, +}: { + job: V1Job; + expanded?: boolean; +}) => { + return ( + { + return { + parallelism: jobObj.spec?.parallelism ?? '???', + completions: jobObj.spec?.completions ?? '???', + backoffLimit: jobObj.spec?.backoffLimit ?? '???', + startTime: jobObj.status?.startTime ?? '???', + }; + }} + > + + + + {job.metadata?.name ?? 'unknown object'} + + + + + Job + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/JobsAccordions/index.ts b/plugins/kubernetes/src/components/JobsAccordions/index.ts new file mode 100644 index 0000000000..392309de79 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { JobsAccordions } from './JobsAccordions'; diff --git a/plugins/kubernetes/src/components/KubernetesContent.test.tsx b/plugins/kubernetes/src/components/KubernetesContent.test.tsx index db8cd06782..231eb3b37a 100644 --- a/plugins/kubernetes/src/components/KubernetesContent.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent.test.tsx @@ -73,6 +73,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], @@ -121,6 +122,7 @@ describe('KubernetesContent', () => { resources: twoDeployments.pods, }, ], + podMetrics: [], errors: [], }, { @@ -139,6 +141,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx index 7c6c7e97f7..1201ad106d 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx @@ -19,7 +19,9 @@ import { render } from '@testing-library/react'; import * as pod from './__fixtures__/pod.json'; import * as crashingPod from './__fixtures__/crashing-pod.json'; import { wrapInTestApp } from '@backstage/test-utils'; -import { PodsTable } from './PodsTable'; +import { PodsTable, READY_COLUMNS, RESOURCE_COLUMNS } from './PodsTable'; +import { kubernetesProviders } from '../../hooks/test-utils'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; describe('PodsTable', () => { it('should render pod', async () => { @@ -27,6 +29,24 @@ describe('PodsTable', () => { wrapInTestApp(), ); + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + }); + + it('should render pod with extra columns', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + // titles expect(getByText('name')).toBeInTheDocument(); expect(getByText('phase')).toBeInTheDocument(); @@ -41,9 +61,102 @@ describe('PodsTable', () => { expect(getByText('0')).toBeInTheDocument(); expect(getByText('OK')).toBeInTheDocument(); }); - it('should render crashing pod', async () => { + it('should render pod, with metrics context', async () => { + const podNameToClientPodStatus = new Map(); + + podNameToClientPodStatus.set('dice-roller-6c8646bfd-2m5hv', { + memory: { + currentUsage: '1069056', + requestTotal: '67108864', + limitTotal: '134217728', + }, + cpu: { + currentUsage: 0.4966115, + requestTotal: 0.05, + limitTotal: 0.05, + }, + } as any); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); + const { getByText } = render( + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getByText('requests: 99%')).toBeInTheDocument(); + expect(getByText('limits: 99%')).toBeInTheDocument(); + expect(getByText('requests: 1%')).toBeInTheDocument(); + expect(getByText('limits: 0%')).toBeInTheDocument(); + }); + it('should render placehoplder when empty metrics context', async () => { + const podNameToClientPodStatus = new Map(); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); const { getByText, getAllByText } = render( - wrapInTestApp(), + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getAllByText('unknown')).toHaveLength(2); + }); + it('should render crashing pod with extra columns', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), ); // titles diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx index 7b5497ff37..1d2369e322 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -14,17 +14,30 @@ * limitations under the License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { V1Pod } from '@kubernetes/client-node'; import { PodDrawer } from './PodDrawer'; import { containersReady, containerStatuses, + podStatusToCpuUtil, + podStatusToMemoryUtil, totalRestarts, } from '../../utils/pod'; import { Table, TableColumn } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; -const columns: TableColumn[] = [ +export const READY_COLUMNS: PodColumns = 'READY'; +export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE'; +export type PodColumns = 'READY' | 'RESOURCE'; + +type PodsTablesProps = { + pods: V1Pod[]; + extraColumns?: PodColumns[]; + children?: React.ReactNode; +}; + +const DEFAULT_COLUMNS: TableColumn[] = [ { title: 'name', highlight: true, @@ -34,6 +47,13 @@ const columns: TableColumn[] = [ title: 'phase', render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', }, + { + title: 'status', + render: containerStatuses, + }, +]; + +const READY: TableColumn[] = [ { title: 'containers ready', align: 'center', @@ -45,18 +65,45 @@ const columns: TableColumn[] = [ render: totalRestarts, type: 'numeric', }, - { - title: 'status', - render: containerStatuses, - }, ]; -type DeploymentTablesProps = { - pods: V1Pod[]; - children?: React.ReactNode; -}; +export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { + const podNamesWithMetrics = useContext(PodNamesWithMetricsContext); + const columns: TableColumn[] = [...DEFAULT_COLUMNS]; + + if (extraColumns.includes(READY_COLUMNS)) { + columns.push(...READY); + } + if (extraColumns.includes(RESOURCE_COLUMNS)) { + const resourceColumns: TableColumn[] = [ + { + title: 'CPU usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToCpuUtil(metrics); + }, + }, + { + title: 'Memory usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToMemoryUtil(metrics); + }, + }, + ]; + columns.push(...resourceColumns); + } -export const PodsTable = ({ pods }: DeploymentTablesProps) => { const tableStyle = { minWidth: '0', width: '100%', diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts index 2e982f25f7..dcac7db907 100644 --- a/plugins/kubernetes/src/error-detection/error-detection.test.ts +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -40,6 +40,7 @@ const oneItem = (value: FetchResponse): ObjectsByEntityResponse => { { cluster: { name: CLUSTER_NAME }, errors: [], + podMetrics: [], resources: [value], }, ], @@ -74,6 +75,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-a' }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -84,6 +86,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-b' }, errors: [], + podMetrics: [], resources: [ { type: 'horizontalpodautoscalers', @@ -94,6 +97,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-c' }, errors: [], + podMetrics: [], resources: [ { type: 'deployments', diff --git a/plugins/kubernetes/src/hooks/GroupedResponses.ts b/plugins/kubernetes/src/hooks/GroupedResponses.ts index d000086b7c..35f7e10b5c 100644 --- a/plugins/kubernetes/src/hooks/GroupedResponses.ts +++ b/plugins/kubernetes/src/hooks/GroupedResponses.ts @@ -24,5 +24,7 @@ export const GroupedResponsesContext = React.createContext({ configMaps: [], horizontalPodAutoscalers: [], ingresses: [], + jobs: [], + cronJobs: [], customResources: [], }); diff --git a/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts new file mode 100644 index 0000000000..66a833ad88 --- /dev/null +++ b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; + +export const PodNamesWithMetricsContext = React.createContext< + Map +>(new Map()); diff --git a/plugins/kubernetes/src/hooks/test-utils.tsx b/plugins/kubernetes/src/hooks/test-utils.tsx index c5b7a8f5d9..a7a6f42512 100644 --- a/plugins/kubernetes/src/hooks/test-utils.tsx +++ b/plugins/kubernetes/src/hooks/test-utils.tsx @@ -17,17 +17,25 @@ import React from 'react'; import { GroupedResponsesContext } from './GroupedResponses'; import { PodNamesWithErrorsContext } from './PodNamesWithErrors'; +import { PodNamesWithMetricsContext } from './PodNamesWithMetrics'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const kubernetesProviders = ( - groupedResponses: any, - podsWithErrors: any, + groupedResponses: any = undefined, + podsWithErrors: Set = new Set(), + podNameToMetrics: Map = new Map< + string, + ClientPodStatus + >(), ) => { return (node: React.ReactNode) => ( <> - - {node} - + + + {node} + + ); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index 3c5564e7cc..ffa0fd5961 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -26,8 +26,6 @@ export interface KubernetesAuthProvider { export const kubernetesAuthProvidersApiRef = createApiRef({ id: 'plugin.kubernetes-auth-providers.service', - description: - 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders', }); export interface KubernetesAuthProvidersApi { diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 05337086be..d6ef8fa784 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -21,7 +21,9 @@ import { V1HorizontalPodAutoscaler, V1Service, V1ConfigMap, - ExtensionsV1beta1Ingress, + V1Ingress, + V1Job, + V1CronJob, } from '@kubernetes/client-node'; export interface DeploymentResources { @@ -34,7 +36,9 @@ export interface DeploymentResources { export interface GroupedResponses extends DeploymentResources { services: V1Service[]; configMaps: V1ConfigMap[]; - ingresses: ExtensionsV1beta1Ingress[]; + ingresses: V1Ingress[]; + jobs: V1Job[]; + cronJobs: V1CronJob[]; customResources: any[]; } diff --git a/plugins/kubernetes/src/utils/pod.test.tsx b/plugins/kubernetes/src/utils/pod.test.tsx new file mode 100644 index 0000000000..5822a16b86 --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { currentToDeclaredResourceToPerc, podStatusToCpuUtil } from './pod'; +import { SubvalueCell } from '@backstage/core-components'; + +describe('pod', () => { + describe('currentToDeclaredResourceToPerc', () => { + it('10%', () => { + const tests: (number | string)[][] = [ + [10, 100], + [10, '100'], + ['10', 100], + ['10', '100'], + ]; + tests.forEach(([a, b]) => { + const result = currentToDeclaredResourceToPerc(a, b); + expect(result).toBe('10%'); + }); + }); + }); + describe('podStatusToCpuUtil', () => { + it('does use correct units', () => { + const result = podStatusToCpuUtil({ + cpu: { + // ~50m + currentUsage: 0.4966115, + // 50m + requestTotal: 0.05, + // 100m + limitTotal: 0.1, + }, + } as any); + expect(result).toStrictEqual( + , + ); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx index 8be2d839c3..50cc983b9d 100644 --- a/plugins/kubernetes/src/utils/pod.tsx +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -14,16 +14,20 @@ * limitations under the License. */ -import { V1Pod, V1PodCondition } from '@kubernetes/client-node'; +import { + V1Pod, + V1PodCondition, + V1DeploymentCondition, +} from '@kubernetes/client-node'; import React, { Fragment, ReactNode } from 'react'; import { Chip } from '@material-ui/core'; -import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition'; import { StatusAborted, StatusError, StatusOK, SubvalueCell, } from '@backstage/core-components'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const imageChips = (pod: V1Pod): ReactNode => { const containerStatuses = pod.status?.containerStatuses ?? []; @@ -59,7 +63,13 @@ export const containerStatuses = (pod: V1Pod): ReactNode => { const renderCell = (reason: string | undefined) => ( Container: {next.name}} + value={ + reason === 'Completed' ? ( + Container: {next.name} + ) : ( + Container: {next.name} + ) + } subvalue={reason} />
@@ -102,3 +112,62 @@ export const renderCondition = ( } return [condition.type, ]; }; + +// visible for testing +export const currentToDeclaredResourceToPerc = ( + current: number | string, + resource: number | string, +): string => { + if (typeof current === 'number' && typeof resource === 'number') { + return `${Math.round((current / resource) * 100)}%`; + } + + const numerator: bigint = BigInt(current); + const denominator: bigint = BigInt(resource); + + return `${(numerator * BigInt(100)) / denominator}%`; +}; + +export const podStatusToCpuUtil = (podStatus: ClientPodStatus): ReactNode => { + const cpuUtil = podStatus.cpu; + + let currentUsage: number | string = cpuUtil.currentUsage; + + // current usage number for CPU is a different unit than request/limit total + // this might be a bug in the k8s library + if (typeof cpuUtil.currentUsage === 'number') { + currentUsage = cpuUtil.currentUsage / 10; + } + + return ( + + ); +}; + +export const podStatusToMemoryUtil = ( + podStatus: ClientPodStatus, +): ReactNode => { + const memUtil = podStatus.memory; + + return ( + + ); +}; diff --git a/plugins/kubernetes/src/utils/response.ts b/plugins/kubernetes/src/utils/response.ts index 97501bb102..cfe3d20580 100644 --- a/plugins/kubernetes/src/utils/response.ts +++ b/plugins/kubernetes/src/utils/response.ts @@ -45,6 +45,12 @@ export const groupResponses = ( case 'ingresses': prev.ingresses.push(...next.resources); break; + case 'jobs': + prev.jobs.push(...next.resources); + break; + case 'cronjobs': + prev.cronJobs.push(...next.resources); + break; case 'customresources': prev.customResources.push(...next.resources); break; @@ -60,6 +66,8 @@ export const groupResponses = ( configMaps: [], horizontalPodAutoscalers: [], ingresses: [], + jobs: [], + cronJobs: [], customResources: [], } as GroupedResponses, ); diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 48ef9a178d..f9b5f6a6ea 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-lighthouse +## 0.2.31 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.2.30 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 009252b774..3820e85fdd 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-lighthouse", "description": "A Backstage plugin that integrates towards Lighthouse", - "version": "0.2.30", + "version": "0.2.31", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,30 +34,31 @@ "dependencies": { "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "@types/react": "*", + "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts index 7dbd48b960..b40b70356f 100644 --- a/plugins/lighthouse/src/api.ts +++ b/plugins/lighthouse/src/api.ts @@ -109,7 +109,6 @@ export type LighthouseApi = { export const lighthouseApiRef = createApiRef({ id: 'plugin.lighthouse.service', - description: 'Used by the Lighthouse plugin to make requests', }); export class LighthouseRestApi implements LighthouseApi { diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 10f29f3fd5..541bc0e22d 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -22,10 +22,15 @@ import { CATEGORY_LABELS, buildSparklinesDataForItem, } from '../../utils'; -import { Link, generatePath } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; -import { Table, TableColumn, TrendLine } from '@backstage/core-components'; +import { + Link, + Table, + TableColumn, + TrendLine, +} from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; const columns: TableColumn[] = [ diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index 89b77e9866..3b8ab0dd8a 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-newrelic +## 0.3.10 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + ## 0.3.9 ### Patch Changes diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index bd5e57ae66..9a32e23e18 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-newrelic", "description": "A Backstage plugin that integrates towards New Relic", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,21 +32,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/newrelic/src/api/index.ts b/plugins/newrelic/src/api/index.ts index 980a690d40..2734dd2b4f 100644 --- a/plugins/newrelic/src/api/index.ts +++ b/plugins/newrelic/src/api/index.ts @@ -55,7 +55,6 @@ export type NewRelicApplications = { export const newRelicApiRef = createApiRef({ id: 'plugin.newrelic.service', - description: 'Used by the NewRelic plugin to make requests', }); const DEFAULT_PROXY_PATH_BASE = '/newrelic'; diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 37c3320810..eb2599237c 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-org +## 0.3.31 + +### Patch Changes + +- fe86adbcd2: Added `entityFilterKind` property for `EntityOwnershipCard` +- Updated dependencies + - @backstage/core-plugin-api@0.3.1 + - @backstage/core-components@0.8.1 + - @backstage/catalog-model@0.9.8 + - @backstage/plugin-catalog-react@0.6.7 + +## 0.3.30 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.3.29 ### Patch Changes diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md index 88ce00fd52..60caa45134 100644 --- a/plugins/org/api-report.md +++ b/plugins/org/api-report.md @@ -33,9 +33,11 @@ export const EntityMembersListCard: (_props: { // @public (undocumented) export const EntityOwnershipCard: ({ variant, + entityFilterKind, }: { entity?: Entity | undefined; variant?: InfoCardVariants | undefined; + entityFilterKind?: string[] | undefined; }) => JSX.Element; // Warning: (ae-missing-release-tag) "EntityUserProfileCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -75,9 +77,11 @@ export { orgPlugin as plugin }; // @public (undocumented) export const OwnershipCard: ({ variant, + entityFilterKind, }: { entity?: Entity | undefined; variant?: InfoCardVariants | undefined; + entityFilterKind?: string[] | undefined; }) => JSX.Element; // Warning: (ae-missing-release-tag) "UserProfileCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/org/package.json b/plugins/org/package.json index a9146c8f7a..def4bac002 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-org", "description": "A Backstage plugin that helps you create entity pages for your organization", - "version": "0.3.29", + "version": "0.3.31", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,26 +21,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.8", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.7", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "pluralize": "^8.0.0", "qs": "^6.10.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/catalog-client": "^0.5.2", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index 8d6fc1a002..439365e583 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ -import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { + CatalogEntitiesRequest, + CatalogListResponse, +} from '@backstage/catalog-client'; +import { Entity, GroupEntity, UserEntity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef, @@ -26,6 +30,97 @@ import { queryByText } from '@testing-library/react'; import React from 'react'; import { OwnershipCard } from './OwnershipCard'; +const items = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'my-api', + }, + spec: { + type: 'openapi', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'my-team', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, + { + kind: 'Component', + metadata: { + name: 'my-service', + }, + spec: { + type: 'service', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'my-team', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, + { + kind: 'Component', + metadata: { + name: 'my-library', + namespace: 'other-namespace', + }, + spec: { + type: 'library', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'my-team', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-system', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'my-team', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, +] as Entity[]; + +const getEntitiesMock = ( + request?: CatalogEntitiesRequest, +): Promise> => { + const filterKinds = + !Array.isArray(request?.filter) && Array.isArray(request?.filter?.kind) + ? request?.filter?.kind ?? [] + : []; // we expect the request to be like { filter: { kind: ['API','System'], .... }. If changed in OwnerShipCard, let's change in also here + return Promise.resolve({ + items: items.filter(item => filterKinds.find(k => k === item.kind)), + } as CatalogListResponse); +}; + describe('OwnershipCard', () => { const groupEntity: GroupEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -49,75 +144,12 @@ describe('OwnershipCard', () => { ], }; - const items = [ - { - kind: 'API', - metadata: { - name: 'my-api', - }, - spec: { - type: 'openapi', - }, - relations: [ - { - type: 'ownedBy', - target: { - name: 'my-team', - namespace: 'default', - kind: 'Group', - }, - }, - ], - }, - { - kind: 'Component', - metadata: { - name: 'my-service', - }, - spec: { - type: 'service', - }, - relations: [ - { - type: 'ownedBy', - target: { - name: 'my-team', - namespace: 'default', - kind: 'Group', - }, - }, - ], - }, - { - kind: 'Component', - metadata: { - name: 'my-library', - namespace: 'other-namespace', - }, - spec: { - type: 'library', - }, - relations: [ - { - type: 'ownedBy', - target: { - name: 'my-team', - namespace: 'default', - kind: 'Group', - }, - }, - ], - }, - ] as any; - it('displays entity counts', async () => { const catalogApi: jest.Mocked = { getEntities: jest.fn(), } as any; - catalogApi.getEntities.mockResolvedValue({ - items, - }); + catalogApi.getEntities.mockImplementation(getEntitiesMock); const { getByText } = await renderInTestApp( @@ -132,6 +164,17 @@ describe('OwnershipCard', () => { }, ); + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: { kind: ['Component', 'API'] }, + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'spec.type', + 'relations', + ], + }); + expect(getByText('OPENAPI')).toBeInTheDocument(); expect( queryByText(getByText('OPENAPI').parentElement!, '1'), @@ -144,6 +187,38 @@ describe('OwnershipCard', () => { expect( queryByText(getByText('LIBRARY').parentElement!, '1'), ).toBeInTheDocument(); + expect(() => getByText('SYSTEM')).toThrowError(); + }); + + it('applies CustomFilterDefinition', async () => { + const catalogApi: jest.Mocked = { + getEntities: jest.fn(), + } as any; + + catalogApi.getEntities.mockImplementation(getEntitiesMock); + + const { getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/create': catalogRouteRef, + }, + }, + ); + + expect(getByText('SYSTEM')).toBeInTheDocument(); + expect( + queryByText(getByText('SYSTEM').parentElement!, '1'), + ).toBeInTheDocument(); + expect(getByText('OPENAPI')).toBeInTheDocument(); + expect( + queryByText(getByText('OPENAPI').parentElement!, '1'), + ).toBeInTheDocument(); + expect(() => getByText('LIBRARY')).toThrowError(); }); it('links to the catalog with the group filter', async () => { @@ -151,9 +226,7 @@ describe('OwnershipCard', () => { getEntities: jest.fn(), } as any; - catalogApi.getEntities.mockResolvedValue({ - items, - }); + catalogApi.getEntities.mockImplementation(getEntitiesMock); const { getByText } = await renderInTestApp( @@ -199,9 +272,7 @@ describe('OwnershipCard', () => { getEntities: jest.fn(), } as any; - catalogApi.getEntities.mockResolvedValue({ - items, - }); + catalogApi.getEntities.mockImplementation(getEntitiesMock); const { getByText } = await renderInTestApp( diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index fe23191779..e8d7c44816 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -40,6 +40,7 @@ import { } from '@material-ui/core'; import qs from 'qs'; import React from 'react'; +import pluralize from 'pluralize'; import { useAsync } from 'react-use'; type EntityTypeProps = { @@ -96,7 +97,7 @@ const EntityCountTile = ({ {counter} - {name} + {pluralize(name, counter)} @@ -128,10 +129,12 @@ const getQueryParams = ( export const OwnershipCard = ({ variant, + entityFilterKind, }: { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; variant?: InfoCardVariants; + entityFilterKind?: string[]; }) => { const { entity } = useEntity(); const catalogApi = useApi(catalogApiRef); @@ -142,7 +145,7 @@ export const OwnershipCard = ({ error, value: componentsWithCounters, } = useAsync(async () => { - const kinds = ['Component', 'API']; + const kinds = entityFilterKind ?? ['Component', 'API']; const entitiesList = await catalogApi.getEntities({ filter: { kind: kinds, @@ -162,17 +165,17 @@ export const OwnershipCard = ({ const counts = ownedEntitiesList.reduce( (acc: EntityTypeProps[], ownedEntity) => { - if (typeof ownedEntity.spec?.type !== 'string') return acc; - const match = acc.find( - x => x.kind === ownedEntity.kind && x.type === ownedEntity.spec?.type, + x => + x.kind === ownedEntity.kind && + x.type === (ownedEntity.spec?.type ?? ownedEntity.kind), ); if (match) { match.count += 1; } else { acc.push({ kind: ownedEntity.kind, - type: ownedEntity.spec?.type, + type: ownedEntity.spec?.type?.toString() ?? ownedEntity.kind, count: 1, }); } diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 30709fe68b..298819ed09 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -22,7 +22,6 @@ import { import { Box, Grid, - Link, List, ListItem, ListItemIcon, @@ -34,7 +33,12 @@ 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 { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { + Avatar, + InfoCard, + InfoCardVariants, + Link, +} from '@backstage/core-components'; const CardTitle = ({ title }: { title?: string }) => title ? ( @@ -83,7 +87,7 @@ export const UserProfileCard = ({ - {profile.email} + {profile.email} )} diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index bb74598f2a..e0c6ec0f29 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-pagerduty +## 0.3.19 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.3.18 ### Patch Changes diff --git a/plugins/pagerduty/api-report.md b/plugins/pagerduty/api-report.md index 689edae1e0..bd26531da6 100644 --- a/plugins/pagerduty/api-report.md +++ b/plugins/pagerduty/api-report.md @@ -10,7 +10,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ConfigApi } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { PropsWithChildren } from 'react'; +import { ReactNode } from 'react'; // Warning: (ae-missing-release-tag) "EntityPagerDutyCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -65,12 +65,7 @@ export class PagerDutyClient implements PagerDutyApi { // Warning: (ae-forgotten-export) The symbol "TriggerAlarmRequest" needs to be exported by the entry point index.d.ts // // (undocumented) - triggerAlarm({ - integrationKey, - source, - description, - userName, - }: TriggerAlarmRequest): Promise; + triggerAlarm(request: TriggerAlarmRequest): Promise; } // Warning: (ae-missing-release-tag) "pagerDutyPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -84,9 +79,7 @@ export { pagerDutyPlugin as plugin }; // Warning: (ae-missing-release-tag) "TriggerButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function TriggerButton({ - children, -}: PropsWithChildren): JSX.Element; +export function TriggerButton(props: TriggerButtonProps): JSX.Element; // Warning: (ae-missing-release-tag) "UnauthorizedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index d02c2df57b..1b6c38465f 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-pagerduty", "description": "A Backstage plugin that integrates towards PagerDuty", - "version": "0.3.18", + "version": "0.3.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,26 +32,27 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/react": "*", "classnames": "^2.2.6", "luxon": "2.0.2", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts index c006403fe7..81d3792ecf 100644 --- a/plugins/pagerduty/src/api/client.ts +++ b/plugins/pagerduty/src/api/client.ts @@ -35,7 +35,6 @@ 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 { @@ -91,12 +90,9 @@ export class PagerDutyClient implements PagerDutyApi { return oncalls; } - triggerAlarm({ - integrationKey, - source, - description, - userName, - }: TriggerAlarmRequest): Promise { + triggerAlarm(request: TriggerAlarmRequest): Promise { + const { integrationKey, source, description, userName } = request; + const body = JSON.stringify({ event_action: 'trigger', routing_key: integrationKey, diff --git a/plugins/pagerduty/src/components/TriggerButton/index.tsx b/plugins/pagerduty/src/components/TriggerButton/index.tsx index 93897c77be..6f83effcf4 100644 --- a/plugins/pagerduty/src/components/TriggerButton/index.tsx +++ b/plugins/pagerduty/src/components/TriggerButton/index.tsx @@ -13,14 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback, PropsWithChildren, useState } from 'react'; +import React, { useCallback, ReactNode, useState } from 'react'; import { makeStyles, Button } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { usePagerdutyEntity } from '../../hooks'; import { TriggerDialog } from '../TriggerDialog'; -export type TriggerButtonProps = {}; +export type TriggerButtonProps = { + children?: ReactNode; +}; const useStyles = makeStyles(theme => ({ buttonStyle: { @@ -32,9 +34,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export function TriggerButton({ - children, -}: PropsWithChildren) { +export function TriggerButton(props: TriggerButtonProps) { const { buttonStyle } = useStyles(); const { integrationKey } = usePagerdutyEntity(); const [dialogShown, setDialogShown] = useState(false); @@ -56,7 +56,7 @@ export function TriggerButton({ disabled={disabled} > {integrationKey - ? children ?? 'Create Incident' + ? props.children ?? 'Create Incident' : 'Missing integration key'} {integrationKey && ( diff --git a/plugins/permission-backend/CHANGELOG.md b/plugins/permission-backend/CHANGELOG.md index 5df0a5826a..cca070ae0c 100644 --- a/plugins/permission-backend/CHANGELOG.md +++ b/plugins/permission-backend/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-permission-backend +## 0.2.1 + +### Patch Changes + +- a036b65c2f: Updated to use the new `BackstageIdentityResponse` type from `@backstage/plugin-auth-backend`. + + The `BackstageIdentityResponse` type is backwards compatible with the `BackstageIdentity`, and provides an additional `identity` field with the claims of the user. + +- Updated dependencies + - @backstage/plugin-auth-backend@0.5.0 + - @backstage/backend-common@0.9.13 + - @backstage/plugin-permission-node@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 450ca92330: Change route used for integration between the authorization framework and other plugin backends to use the /.well-known prefix. + +### Patch Changes + +- e7851efa9e: Rename and adjust permission policy return type to reduce nesting +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.10 + - @backstage/plugin-permission-node@0.2.0 + - @backstage/backend-common@0.9.12 + ## 0.1.0 ### Minor Changes diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json index f335079d0b..ed3bd86636 100644 --- a/plugins/permission-backend/package.json +++ b/plugins/permission-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-permission-backend", - "version": "0.1.0", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,11 +19,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.13", "@backstage/config": "^0.1.11", - "@backstage/plugin-auth-backend": "^0.4.9", + "@backstage/plugin-auth-backend": "^0.5.0", "@backstage/plugin-permission-common": "^0.2.0", - "@backstage/plugin-permission-node": "^0.1.0", + "@backstage/plugin-permission-node": "^0.2.1", "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -33,7 +33,7 @@ "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.1", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "msw": "^0.35.0" diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts index 6a35488117..7f46490ce2 100644 --- a/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts @@ -64,7 +64,7 @@ describe('PermissionIntegrationClient', () => { server.listen({ onUnhandledRequest: 'error' }); server.use( rest.post( - `${mockBaseUrl}/permissions/apply-conditions`, + `${mockBaseUrl}/.well-known/backstage/permissions/apply-conditions`, mockApplyConditionsHandler, ), ); diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts index 0d6017cfd3..de42a7f194 100644 --- a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts @@ -54,7 +54,7 @@ export class PermissionIntegrationClient { ): Promise { const endpoint = `${await this.discovery.getBaseUrl( pluginId, - )}/permissions/apply-conditions`; + )}/.well-known/backstage/permissions/apply-conditions`; const request: ApplyConditionsRequest = { resourceRef, diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts index 78e7121da6..26fa7ef8a6 100644 --- a/plugins/permission-backend/src/service/router.test.ts +++ b/plugins/permission-backend/src/service/router.test.ts @@ -161,12 +161,10 @@ describe('createRouter', () => { beforeEach(() => { policy.handle.mockReturnValueOnce({ result: AuthorizeResult.CONDITIONAL, + pluginId: 'test-plugin', + resourceType: 'test-resource-1', conditions: { - pluginId: 'test-plugin', - resourceType: 'test-resource-1', - conditions: { - anyOf: [{ rule: 'test-rule', params: ['abc'] }], - }, + anyOf: [{ rule: 'test-rule', params: ['abc'] }], }, }); }); @@ -265,11 +263,9 @@ describe('createRouter', () => { it('returns a 500 error if the policy returns a different resourceType', async () => { policy.handle.mockReturnValueOnce({ result: AuthorizeResult.CONDITIONAL, - conditions: { - pluginId: 'test-plugin', - resourceType: 'test-resource-2', - conditions: {}, - }, + pluginId: 'test-plugin', + resourceType: 'test-resource-2', + conditions: {}, }); const response = await request(app) diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts index 8caba23b50..41791b8fc8 100644 --- a/plugins/permission-backend/src/service/router.ts +++ b/plugins/permission-backend/src/service/router.ts @@ -23,7 +23,7 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { - BackstageIdentity, + BackstageIdentityResponse, IdentityClient, } from '@backstage/plugin-auth-backend'; import { @@ -71,7 +71,7 @@ export interface RouterOptions { const handleRequest = async ( { id, resourceRef, ...request }: Identified, - user: BackstageIdentity | undefined, + user: BackstageIdentityResponse | undefined, policy: PermissionPolicy, permissionIntegrationClient: PermissionIntegrationClient, authHeader?: string, @@ -80,7 +80,7 @@ const handleRequest = async ( if (response.result === AuthorizeResult.CONDITIONAL) { // Sanity check that any resource provided matches the one expected by the permission - if (request.permission.resourceType !== response.conditions.resourceType) { + if (request.permission.resourceType !== response.resourceType) { throw new Error( `Invalid resource conditions returned from permission policy for permission ${request.permission.name}`, ); @@ -92,7 +92,9 @@ const handleRequest = async ( ...(await permissionIntegrationClient.applyConditions( { resourceRef, - ...response.conditions, + pluginId: response.pluginId, + resourceType: response.resourceType, + conditions: response.conditions, }, authHeader, )), @@ -102,10 +104,7 @@ const handleRequest = async ( return { id, result: AuthorizeResult.CONDITIONAL, - // TODO(mtlewis): this .conditions.conditions situation is a bit awkward. I think it's - // worth exploring a bit of reorganization of the ConditionalPolicyResult type so that - // the naming of property chains like this makes a bit more sense. - conditions: response.conditions.conditions, + conditions: response.conditions, }; } diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index 725aef0dec..495c262b03 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -45,7 +45,7 @@ "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "msw": "^0.35.0" } diff --git a/plugins/permission-node/CHANGELOG.md b/plugins/permission-node/CHANGELOG.md index 37c03b7050..3fdf0ccfff 100644 --- a/plugins/permission-node/CHANGELOG.md +++ b/plugins/permission-node/CHANGELOG.md @@ -1,5 +1,29 @@ # @backstage/plugin-permission-node +## 0.2.1 + +### Patch Changes + +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- a036b65c2f: Updated to use the new `BackstageIdentityResponse` type from `@backstage/plugin-auth-backend`. + + The `BackstageIdentityResponse` type is backwards compatible with the `BackstageIdentity`, and provides an additional `identity` field with the claims of the user. + +- Updated dependencies + - @backstage/plugin-auth-backend@0.5.0 + +## 0.2.0 + +### Minor Changes + +- e7851efa9e: Rename and adjust permission policy return type to reduce nesting +- 450ca92330: Change route used for integration between the authorization framework and other plugin backends to use the /.well-known prefix. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.10 + ## 0.1.0 ### Minor Changes diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index e5fe9a7087..3df1386d4d 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -5,7 +5,7 @@ ```ts import { AuthorizeRequest } from '@backstage/plugin-permission-common'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; import { Router } from 'express'; @@ -32,13 +32,11 @@ export type Condition = TRule extends PermissionRule< : never; // @public -export type ConditionalPolicyResult = { +export type ConditionalPolicyDecision = { result: AuthorizeResult.CONDITIONAL; - conditions: { - pluginId: string; - resourceType: string; - conditions: PermissionCriteria; - }; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; }; // @public @@ -63,11 +61,9 @@ export const createConditionExports: < rules: TRules; }) => { conditions: Conditions; - createConditions: (conditions: PermissionCriteria) => { - pluginId: string; - resourceType: string; - conditions: PermissionCriteria; - }; + createPolicyDecision: ( + conditions: PermissionCriteria, + ) => ConditionalPolicyDecision; }; // @public @@ -87,11 +83,7 @@ export const createConditionTransformer: < ) => ConditionTransformer; // @public -export const createPermissionIntegrationRouter: ({ - resourceType, - rules, - getResource, -}: { +export const createPermissionIntegrationRouter: (options: { resourceType: string; rules: PermissionRule[]; getResource: (resourceRef: string) => Promise; @@ -102,8 +94,8 @@ export interface PermissionPolicy { // (undocumented) handle( request: PolicyAuthorizeRequest, - user?: BackstageIdentity, - ): Promise; + user?: BackstageIdentityResponse, + ): Promise; } // @public @@ -122,9 +114,9 @@ export type PermissionRule< export type PolicyAuthorizeRequest = Omit; // @public -export type PolicyResult = +export type PolicyDecision = | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; } - | ConditionalPolicyResult; + | ConditionalPolicyDecision; ``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 2bddc21aca..5e0a300873 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-permission-node", "description": "Common permission and authorization utilities for backend plugins", - "version": "0.1.0", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,14 +29,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/plugin-auth-backend": "^0.4.9", + "@backstage/plugin-auth-backend": "^0.5.0", "@backstage/plugin-permission-common": "^0.2.0", "@types/express": "^4.17.6", "express": "^4.17.1", "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.1", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/permission-node/src/integration/createConditionExports.test.ts b/plugins/permission-node/src/integration/createConditionExports.test.ts index 6f0f8f8632..f3585d11ad 100644 --- a/plugins/permission-node/src/integration/createConditionExports.test.ts +++ b/plugins/permission-node/src/integration/createConditionExports.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { createConditionExports } from './createConditionExports'; const testIntegration = () => @@ -61,13 +62,16 @@ describe('createConditionExports', () => { }); }); - describe('createConditions', () => { + describe('createPolicyDecisions', () => { it('wraps conditions in an object with resourceType and pluginId', () => { - const { createConditions } = testIntegration(); + const { createPolicyDecision } = testIntegration(); expect( - createConditions({ allOf: [{ rule: 'testRule1', params: ['a', 1] }] }), + createPolicyDecision({ + allOf: [{ rule: 'testRule1', params: ['a', 1] }], + }), ).toEqual({ + result: AuthorizeResult.CONDITIONAL, pluginId: 'test-plugin', resourceType: 'test-resource', conditions: { diff --git a/plugins/permission-node/src/integration/createConditionExports.ts b/plugins/permission-node/src/integration/createConditionExports.ts index 30cff6628a..fd351128ed 100644 --- a/plugins/permission-node/src/integration/createConditionExports.ts +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -15,9 +15,11 @@ */ import { + AuthorizeResult, PermissionCondition, PermissionCriteria, } from '@backstage/plugin-permission-common'; +import { ConditionalPolicyDecision } from '../policy'; import { PermissionRule } from '../types'; import { createConditionFactory } from './createConditionFactory'; @@ -73,11 +75,9 @@ export const createConditionExports = < rules: TRules; }): { conditions: Conditions; - createConditions: (conditions: PermissionCriteria) => { - pluginId: string; - resourceType: string; - conditions: PermissionCriteria; - }; + createPolicyDecision: ( + conditions: PermissionCriteria, + ) => ConditionalPolicyDecision; } => { const { pluginId, resourceType, rules } = options; @@ -89,9 +89,10 @@ export const createConditionExports = < }), {} as Conditions, ), - createConditions: ( + createPolicyDecision: ( conditions: PermissionCriteria, ) => ({ + result: AuthorizeResult.CONDITIONAL, pluginId, resourceType, conditions, diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts index af216dfcb8..15c9cf4003 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -61,7 +61,7 @@ describe('createPermissionIntegrationRouter', () => { expect(router).toBeDefined(); }); - describe('POST /permissions/apply-conditions', () => { + describe('POST /.well-known/backstage/permissions/apply-conditions', () => { it.each([ { rule: 'test-rule-1', params: ['abc', 123] }, { @@ -94,7 +94,7 @@ describe('createPermissionIntegrationRouter', () => { }, ])('returns 200/ALLOW when criteria match (case %#)', async conditions => { const response = await request(app) - .post('/permissions/apply-conditions') + .post('/.well-known/backstage/permissions/apply-conditions') .send({ resourceRef: 'default:test/resource', resourceType: 'test-resource', @@ -135,7 +135,7 @@ describe('createPermissionIntegrationRouter', () => { 'returns 200/DENY when criteria do not match (case %#)', async conditions => { const response = await request(app) - .post('/permissions/apply-conditions') + .post('/.well-known/backstage/permissions/apply-conditions') .send({ resourceRef: 'default:test/resource', resourceType: 'test-resource', @@ -149,7 +149,7 @@ describe('createPermissionIntegrationRouter', () => { it('returns 400 when called with incorrect resource type', async () => { const response = await request(app) - .post('/permissions/apply-conditions') + .post('/.well-known/backstage/permissions/apply-conditions') .send({ resourceRef: 'default:test/resource', resourceType: 'test-incorrect-resource', @@ -168,7 +168,7 @@ describe('createPermissionIntegrationRouter', () => { mockGetResource.mockReturnValueOnce(Promise.resolve(undefined)); const response = await request(app) - .post('/permissions/apply-conditions') + .post('/.well-known/backstage/permissions/apply-conditions') .send({ resourceRef: 'default:test/resource', resourceType: 'test-resource', @@ -198,7 +198,7 @@ describe('createPermissionIntegrationRouter', () => { { conditions: { anyOf: [] } }, ])(`returns 400 for invalid input %#`, async input => { const response = await request(app) - .post('/permissions/apply-conditions') + .post('/.well-known/backstage/permissions/apply-conditions') .send(input); expect(response.status).toEqual(400); diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index 99e282b144..b8d3e02a81 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -116,23 +116,21 @@ const applyConditions = ( * This is used to construct the `createPermissionIntegrationRouter`, a function to add an * authorization route to your backend plugin. This route will be called by the `permission-backend` * when authorization conditions relating to this plugin need to be evaluated. + * * @public */ -export const createPermissionIntegrationRouter = ({ - resourceType, - rules, - getResource, -}: { +export const createPermissionIntegrationRouter = (options: { resourceType: string; rules: PermissionRule[]; getResource: (resourceRef: string) => Promise; }): Router => { + const { resourceType, rules, getResource } = options; const router = Router(); const getRule = createGetRule(rules); router.post( - '/permissions/apply-conditions', + '/.well-known/backstage/permissions/apply-conditions', express.json(), async ( req, diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts index f04c2a094c..c8216989a1 100644 --- a/plugins/permission-node/src/policy/index.ts +++ b/plugins/permission-node/src/policy/index.ts @@ -15,8 +15,8 @@ */ export type { - ConditionalPolicyResult, + ConditionalPolicyDecision, PermissionPolicy, PolicyAuthorizeRequest, - PolicyResult, + PolicyDecision, } from './types'; diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index 44380bad26..d122ad8d8d 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -20,7 +20,7 @@ import { PermissionCondition, PermissionCriteria, } from '@backstage/plugin-permission-common'; -import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; /** * An authorization request to be evaluated by the {@link PermissionPolicy}. @@ -48,13 +48,11 @@ export type PolicyAuthorizeRequest = Omit; * identifiers needed to evaluate the returned conditions. * @public */ -export type ConditionalPolicyResult = { +export type ConditionalPolicyDecision = { result: AuthorizeResult.CONDITIONAL; - conditions: { - pluginId: string; - resourceType: string; - conditions: PermissionCriteria; - }; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; }; /** @@ -62,9 +60,9 @@ export type ConditionalPolicyResult = { * * @public */ -export type PolicyResult = +export type PolicyDecision = | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } - | ConditionalPolicyResult; + | ConditionalPolicyDecision; /** * A policy to evaluate authorization requests for any permissioned action performed in Backstage. @@ -85,6 +83,6 @@ export type PolicyResult = export interface PermissionPolicy { handle( request: PolicyAuthorizeRequest, - user?: BackstageIdentity, - ): Promise; + user?: BackstageIdentityResponse, + ): Promise; } diff --git a/plugins/permission-react/.eslintrc.js b/plugins/permission-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/permission-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/permission-react/CHANGELOG.md b/plugins/permission-react/CHANGELOG.md new file mode 100644 index 0000000000..44c5a326d6 --- /dev/null +++ b/plugins/permission-react/CHANGELOG.md @@ -0,0 +1,23 @@ +# @backstage/plugin-permission-react + +## 0.1.1 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly +- Updated dependencies + - @backstage/core-plugin-api@0.3.0 + +## 0.1.0 + +### Minor Changes + +- 6ed24445a9: Add @backstage/plugin-permission-react + + @backstage/plugin-permission-react is a library containing utils for implementing permissions in your frontend Backstage plugins. See [the authorization PRFC](https://github.com/backstage/backstage/pull/7761) for more details. + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.2.2 diff --git a/plugins/permission-react/README.md b/plugins/permission-react/README.md new file mode 100644 index 0000000000..72383f024d --- /dev/null +++ b/plugins/permission-react/README.md @@ -0,0 +1,5 @@ +# permission + +**NOTE: THIS PACKAGE IS EXPERIMENTAL!** + +Components and hooks to help implement permissions in Backstage frontend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md new file mode 100644 index 0000000000..c10317495a --- /dev/null +++ b/plugins/permission-react/api-report.md @@ -0,0 +1,60 @@ +## API Report File for "@backstage/plugin-permission-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeResponse } from '@backstage/plugin-permission-common'; +import { ComponentProps } from 'react'; +import { Config } from '@backstage/config'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; +import { Permission } from '@backstage/plugin-permission-common'; +import { ReactElement } from 'react'; +import { Route } from 'react-router'; + +// @public (undocumented) +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +// @public +export class IdentityPermissionApi implements PermissionApi { + // (undocumented) + authorize(request: AuthorizeRequest): Promise; + // (undocumented) + static create(options: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }): IdentityPermissionApi; +} + +// @public +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +// @public +export const permissionApiRef: ApiRef; + +// @public +export const PermissionedRoute: ( + props: ComponentProps & { + permission: Permission; + resourceRef?: string; + errorComponent?: ReactElement | null; + }, +) => JSX.Element; + +// @public +export const usePermission: ( + permission: Permission, + resourceRef?: string | undefined, +) => AsyncPermissionResult; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/permission-react/package.json b/plugins/permission-react/package.json new file mode 100644 index 0000000000..7f49c5eb73 --- /dev/null +++ b/plugins/permission-react/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/plugin-permission-react", + "version": "0.1.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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/permission-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.11", + "@backstage/core-plugin-api": "^0.3.0", + "@backstage/plugin-permission-common": "^0.2.0", + "cross-fetch": "^3.0.6", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.10.1", + "@backstage/test-utils": "^0.1.24", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.7" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts new file mode 100644 index 0000000000..19de564f84 --- /dev/null +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, IdentityApi } from '@backstage/core-plugin-api'; +import { PermissionApi } from './PermissionApi'; +import { + AuthorizeRequest, + AuthorizeResponse, + PermissionClient, +} from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; + +/** + * The default implementation of the PermissionApi, which simply calls the authorize method of the given + * {@link @backstage/plugin-permission-common#PermissionClient}. + * @public + */ +export class IdentityPermissionApi implements PermissionApi { + private constructor( + private readonly permissionClient: PermissionClient, + private readonly identityApi: IdentityApi, + ) {} + + static create(options: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + const { configApi, discoveryApi, identityApi } = options; + const permissionClient = new PermissionClient({ discoveryApi, configApi }); + return new IdentityPermissionApi(permissionClient, identityApi); + } + + async authorize(request: AuthorizeRequest): Promise { + const response = await this.permissionClient.authorize([request], { + token: await this.identityApi.getIdToken(), + }); + return response[0]; + } +} diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts new file mode 100644 index 0000000000..5c224ff06d --- /dev/null +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + AuthorizeRequest, + AuthorizeResponse, +} from '@backstage/plugin-permission-common'; +import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; + +/** + * This API is used by various frontend utilities that allow developers to implement authorization wihtin their frontend + * plugins. A plugin developer will likely not have to interact with this API or its implementations directly, but + * rather with the aforementioned utility components/hooks. + * @public + */ +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +/** + * A Backstage ApiRef for the Permission API. See https://backstage.io/docs/api/utility-apis for more information on + * Backstage ApiRefs. + * @public + */ +export const permissionApiRef: ApiRef = createApiRef({ + id: 'plugin.permission.api', +}); diff --git a/plugins/permission-react/src/apis/index.ts b/plugins/permission-react/src/apis/index.ts new file mode 100644 index 0000000000..223339f891 --- /dev/null +++ b/plugins/permission-react/src/apis/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { permissionApiRef } from './PermissionApi'; +export type { PermissionApi } from './PermissionApi'; +export { IdentityPermissionApi } from './IdentityPermissionApi'; diff --git a/plugins/permission-react/src/components/PermissionedRoute.test.tsx b/plugins/permission-react/src/components/PermissionedRoute.test.tsx new file mode 100644 index 0000000000..3d7d1b7af7 --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PermissionedRoute } from '.'; +import { usePermission } from '../hooks'; +import { renderInTestApp } from '@backstage/test-utils'; + +jest.mock('../hooks', () => ({ + usePermission: jest.fn(), +})); +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +describe('PermissionedRoute', () => { + it('Does not render when loading', async () => { + mockUsePermission.mockReturnValue({ loading: true, allowed: false }); + + const { queryByText } = await renderInTestApp( + content} + />, + ); + + expect(queryByText('content')).not.toBeTruthy(); + }); + + it('Renders given element if authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + const { getByText } = await renderInTestApp( + content} + />, + ); + + expect(getByText('content')).toBeTruthy(); + }); + + it('Renders not found page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + await expect( + renderInTestApp( + content} + />, + ), + ).rejects.toThrowError('Reached NotFound Page'); + }); + + it('Renders custom error page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + const { getByText } = await renderInTestApp( + content} + errorComponent={

Custom Error

} + />, + ); + + expect(getByText('Custom Error')).toBeTruthy(); + }); +}); diff --git a/plugins/permission-react/src/components/PermissionedRoute.tsx b/plugins/permission-react/src/components/PermissionedRoute.tsx new file mode 100644 index 0000000000..76017308aa --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, { ComponentProps, ReactElement } from 'react'; +import { Route } from 'react-router'; +import { useApp } from '@backstage/core-plugin-api'; +import { usePermission } from '../hooks'; +import { Permission } from '@backstage/plugin-permission-common'; + +/** + * Returns a React Router Route which only renders the element when authorized. If unauthorized, the Route will render a + * NotFoundErrorPage (see {@link @backstage/core-app-api#AppComponents}). + * + * @public + */ +export const PermissionedRoute = ( + props: ComponentProps & { + permission: Permission; + resourceRef?: string; + errorComponent?: ReactElement | null; + }, +) => { + const { permission, resourceRef, errorComponent, ...otherProps } = props; + const permissionResult = usePermission(permission, resourceRef); + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + + let shownElement: ReactElement | null | undefined = + errorComponent === undefined ? : errorComponent; + + if (permissionResult.loading) { + shownElement = null; + } else if (permissionResult.allowed) { + shownElement = props.element; + } + + return ; +}; diff --git a/plugins/permission-react/src/components/index.ts b/plugins/permission-react/src/components/index.ts new file mode 100644 index 0000000000..05f13bca81 --- /dev/null +++ b/plugins/permission-react/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PermissionedRoute } from './PermissionedRoute'; diff --git a/plugins/permission-react/src/hooks/index.ts b/plugins/permission-react/src/hooks/index.ts new file mode 100644 index 0000000000..5fca80b89e --- /dev/null +++ b/plugins/permission-react/src/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { usePermission } from './usePermission'; +export type { AsyncPermissionResult } from './usePermission'; diff --git a/plugins/permission-react/src/hooks/usePermission.test.tsx b/plugins/permission-react/src/hooks/usePermission.test.tsx new file mode 100644 index 0000000000..4cf0bc6530 --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from 'react'; +import { render } from '@testing-library/react'; +import { usePermission } from './usePermission'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { permissionApiRef } from '../apis'; + +const mockAuthorize = jest.fn(); + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +const TestComponent: FC = () => { + const { loading, allowed, error } = usePermission(permission); + return ( +
+ {loading && 'loading'} + {error && 'error'} + {allowed ? 'content' : null} +
+ ); +}; + +describe('usePermission', () => { + it('Returns loading when permissionApi has not yet responded.', () => { + mockAuthorize.mockReturnValueOnce(new Promise(() => {})); + + const { getByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(getByText('loading')).toBeTruthy(); + }); + + it('Returns allowed when permissionApi allows authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.ALLOW }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(await findByText('content')).toBeTruthy(); + }); + + it('Returns not allowed when permissionApi denies authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.DENY }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + await expect(findByText('content')).rejects.toThrowError(); + }); +}); diff --git a/plugins/permission-react/src/hooks/usePermission.ts b/plugins/permission-react/src/hooks/usePermission.ts new file mode 100644 index 0000000000..129904d7df --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { permissionApiRef } from '../apis'; +import { + AuthorizeResult, + Permission, +} from '@backstage/plugin-permission-common'; + +/** @public */ +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +/** + * React hook utlity for authorization. Given a {@link @backstage/plugin-permission-common#Permission} and an optional + * resourceRef, it will return whether or not access is allowed (for the given resource, if resourceRef is provided). See + * {@link @backstage/plugin-permission-common/PermissionClient#authorize} for more details. + * @public + */ +export const usePermission = ( + permission: Permission, + resourceRef?: string, +): AsyncPermissionResult => { + const permissionApi = useApi(permissionApiRef); + + const { loading, error, value } = useAsync(async () => { + const { result } = await permissionApi.authorize({ + permission, + resourceRef, + }); + + return result; + }, [permissionApi, permission, resourceRef]); + + if (loading) { + return { loading: true, allowed: false }; + } + if (error) { + return { error, loading: false, allowed: false }; + } + return { loading: false, allowed: value === AuthorizeResult.ALLOW }; +}; diff --git a/plugins/permission-react/src/index.ts b/plugins/permission-react/src/index.ts new file mode 100644 index 0000000000..57352c5cdd --- /dev/null +++ b/plugins/permission-react/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './components'; +export * from './hooks'; +export * from './apis'; diff --git a/plugins/permission-react/src/setupTests.ts b/plugins/permission-react/src/setupTests.ts new file mode 100644 index 0000000000..992b60d3a4 --- /dev/null +++ b/plugins/permission-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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/package.json b/plugins/proxy-backend/package.json index 78586449c7..68be26391e 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -43,7 +43,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index bb30692476..6fbecdbc3d 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -31,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", - "axios": "^0.21.1", + "axios": "^0.24.0", "camelcase-keys": "^6.2.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -48,7 +48,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.1", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index aee0a74525..8368deee5e 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-rollbar +## 0.3.20 + +### Patch Changes + +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/plugin-catalog-react@0.6.5 + ## 0.3.19 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 0e4086bd4a..012cb24490 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-rollbar", "description": "A Backstage plugin that integrates towards Rollbar", - "version": "0.3.19", + "version": "0.3.20", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,33 +33,34 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "lodash": "^4.17.21", - "react": "^16.13.1", - "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-use": "^17.2.4" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "@types/react": "*", + "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, diff --git a/plugins/rollbar/src/api/RollbarApi.ts b/plugins/rollbar/src/api/RollbarApi.ts index 4c28630db6..3da52338ec 100644 --- a/plugins/rollbar/src/api/RollbarApi.ts +++ b/plugins/rollbar/src/api/RollbarApi.ts @@ -23,8 +23,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const rollbarApiRef = createApiRef({ id: 'plugin.rollbar.service', - description: - 'Used by the Rollbar plugin to make requests to accompanying backend', }); export interface RollbarApi { diff --git a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md index 43a522482f..b177521d8b 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-scaffolder-backend-module-cookiecutter +## 0.1.5 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-scaffolder-backend@0.15.15 + - @backstage/backend-common@0.9.12 + ## 0.1.4 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 95dab84e88..bff3f49c83 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend-module-cookiecutter", "description": "A module for the scaffolder backend that lets you template projects using cookiecutter", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", + "@backstage/backend-common": "^0.9.12", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", - "@backstage/plugin-scaffolder-backend": "^0.15.13", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-scaffolder-backend": "^0.15.15", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "command-exists": "^1.2.9", @@ -32,7 +32,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.0", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/jest": "^26.0.7", diff --git a/plugins/scaffolder-backend-module-rails/CHANGELOG.md b/plugins/scaffolder-backend-module-rails/CHANGELOG.md index 9dcd6bcb88..5893faedbb 100644 --- a/plugins/scaffolder-backend-module-rails/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-rails/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-scaffolder-backend-module-rails +## 0.2.0 + +### Minor Changes + +- 64db0efffe: update publish format from ESM to CJS + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-scaffolder-backend@0.15.16 + - @backstage/backend-common@0.9.13 + ## 0.1.7 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index 37b077568e..28dc5f7ba6 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -1,19 +1,19 @@ { "name": "@backstage/plugin-scaffolder-backend-module-rails", "description": "A module for the scaffolder backend that lets you template projects using Rails", - "version": "0.1.7", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", "private": false, "publishConfig": { "access": "public", - "main": "dist/index.esm.js", + "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, "scripts": { - "build": "backstage-cli plugin:build", - "start": "backstage-cli plugin:serve", + "build": "backstage-cli backend:build", + "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", @@ -21,17 +21,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.10", - "@backstage/plugin-scaffolder-backend": "^0.15.13", + "@backstage/backend-common": "^0.9.13", + "@backstage/plugin-scaffolder-backend": "^0.15.16", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", "command-exists": "^1.2.9", "fs-extra": "^9.0.0" }, "devDependencies": { - "@backstage/cli": "^0.9.0", + "@backstage/cli": "^0.10.1", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/command-exists": "^1.2.0", diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts index 00a5690c0a..56c45fa015 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -96,6 +96,16 @@ export function createFetchRailsAction(options: { description: "Don't run Webpack install", type: 'boolean', }, + skipTest: { + title: 'skipTest', + description: 'Skip test files', + type: 'boolean', + }, + force: { + title: 'force', + description: 'Overwrite files that already exist', + type: 'boolean', + }, api: { title: 'api', description: 'Preconfigure smaller stack for API only apps', diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts index 99a113f4f7..cced9f7ac2 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts @@ -27,6 +27,8 @@ describe('railsArgumentResolver', () => { [{ api: true }, ['--api']], [{ skipBundle: true }, ['--skip-bundle']], [{ skipWebpackInstall: true }, ['--skip-webpack-install']], + [{ skipTest: true }, ['--skip-test']], + [{ force: true }, ['--force']], [{ webpacker: 'vue' }, ['--webpack', 'vue']], [{ database: 'postgresql' }, ['--database', 'postgresql']], [{ railsVersion: 'dev' }, ['--dev']], diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts index 60251a702d..4006ead630 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts @@ -51,6 +51,8 @@ export type RailsRunOptions = { railsVersion?: RailsVersion; skipBundle?: boolean; skipWebpackInstall?: boolean; + skipTest?: boolean; + force?: boolean; }; export const railsArgumentResolver = ( @@ -76,6 +78,14 @@ export const railsArgumentResolver = ( argumentsToRun.push('--skip-webpack-install'); } + if (options?.skipTest) { + argumentsToRun.push('--skip-test'); + } + + if (options?.force) { + argumentsToRun.push('--force'); + } + if ( options?.webpacker && Object.values(Webpacker).includes(options?.webpacker as Webpacker) diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 092a678fce..50d10b564c 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,38 @@ # @backstage/plugin-scaffolder-backend +## 0.15.17 + +### Patch Changes + +- eec0750d8d: Makes cookiecutter a default, but optional action based on if a containerRunner argument is passed in to createRouter or createBuiltinActions +- ed52f74ab3: Adding changes to create GitLab Merge Request using custom action +- Updated dependencies + - @backstage/plugin-catalog-backend@0.19.2 + - @backstage/backend-common@0.9.14 + - @backstage/catalog-model@0.9.8 + +## 0.15.16 + +### Patch Changes + +- 2a3fb13718: Bump esbuild to ^0.14.1 +- Updated dependencies + - @backstage/backend-common@0.9.13 + - @backstage/plugin-catalog-backend@0.19.1 + +## 0.15.15 + +### Patch Changes + +- 0398ea25d3: Removed unused scaffolder visibility configuration; this has been moved to publish actions. Deprecated scaffolder provider configuration keys; these should use the integrations configuration instead. +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- c6b44d80ad: Add options to spawn in runCommand helper +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-catalog-backend@0.19.0 + - @backstage/plugin-scaffolder-backend-module-cookiecutter@0.1.5 + - @backstage/backend-common@0.9.12 + ## 0.15.14 ### Patch Changes diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 4b27b938c9..b7c1a9146c 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -24,6 +24,7 @@ import { PluginDatabaseManager } from '@backstage/backend-common'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; +import { SpawnOptionsWithoutStdio } from 'child_process'; import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; @@ -67,7 +68,7 @@ export const createBuiltinActions: (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - containerRunner: ContainerRunner; + containerRunner?: ContainerRunner; config: Config; }) => TemplateAction[]; @@ -178,6 +179,13 @@ export function createPublishGitlabAction(options: { config: Config; }): TemplateAction; +// Warning: (ae-missing-release-tag) "createPublishGitlabMergeRequestAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createPublishGitlabMergeRequestAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction; + // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -288,7 +296,7 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) - containerRunner: ContainerRunner; + containerRunner?: ContainerRunner; // (undocumented) database: PluginDatabaseManager; // (undocumented) @@ -304,11 +312,12 @@ export interface RouterOptions { // Warning: (ae-forgotten-export) The symbol "RunCommandOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "runCommand" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export const runCommand: ({ command, args, logStream, + options, }: RunCommandOptions) => Promise; // @public (undocumented) diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 126c55e166..aa4ecf8053 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -28,30 +28,30 @@ export interface Config { * The commit message used when new components are created. */ defaultCommitMessage?: string; + /** + * @deprecated Replaced by parameters for the publish:github action + */ github?: { [key: string]: string; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'internal' | 'private'; }; + /** + * @deprecated Use the Gitlab integration instead + */ gitlab?: { api: { [key: string]: string }; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'internal' | 'private'; }; + /** + * @deprecated Use the Azure integration instead + */ azure?: { baseUrl: string; api: { [key: string]: string }; }; + /** + * @deprecated Use the Bitbucket integration instead + */ bitbucket?: { api: { [key: string]: string }; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 602028f9b3..3686c33c40 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend", "description": "The Backstage backend plugin that helps you create new things", - "version": "0.15.14", + "version": "0.15.17", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,15 +31,15 @@ "build:assets": "node scripts/build-nunjucks.js" }, "dependencies": { - "@backstage/backend-common": "^0.9.11", + "@backstage/backend-common": "^0.9.14", "@backstage/catalog-client": "^0.5.2", - "@backstage/catalog-model": "^0.9.7", + "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", - "@backstage/integration": "^0.6.9", - "@backstage/plugin-catalog-backend": "^0.18.0", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-catalog-backend": "^0.19.2", "@backstage/plugin-scaffolder-common": "^0.1.1", - "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.4", + "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.5", "@backstage/types": "^0.1.1", "@gitbeaker/core": "^34.6.0", "@gitbeaker/node": "^34.6.0", @@ -73,15 +73,15 @@ "vm2": "^3.9.5" }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/test-utils": "^0.1.24", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", "@types/mock-fs": "^4.13.0", "@types/nunjucks": "^3.1.4", "@types/supertest": "^2.0.8", - "esbuild": "^0.13.14", + "esbuild": "^0.14.1", "jest-when": "^3.1.0", "mock-fs": "^5.1.0", "msw": "^0.35.0", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index d38dfe3cdc..93ecceb76a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -36,6 +36,7 @@ import { createPublishGithubAction, createPublishGithubPullRequestAction, createPublishGitlabAction, + createPublishGitlabMergeRequestAction, } from './publish'; import { createGithubActionsDispatchAction, @@ -46,22 +47,17 @@ export const createBuiltinActions = (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - containerRunner: ContainerRunner; + containerRunner?: ContainerRunner; config: Config; }) => { const { reader, integrations, containerRunner, catalogClient, config } = options; - return [ + const actions = [ createFetchPlainAction({ reader, integrations, }), - createFetchCookiecutterAction({ - reader, - integrations, - containerRunner, - }), createFetchTemplateAction({ integrations, reader, @@ -77,6 +73,9 @@ export const createBuiltinActions = (options: { integrations, config, }), + createPublishGitlabMergeRequestAction({ + integrations, + }), createPublishBitbucketAction({ integrations, config, @@ -97,4 +96,16 @@ export const createBuiltinActions = (options: { integrations, }), ]; + + if (containerRunner) { + actions.push( + createFetchCookiecutterAction({ + reader, + integrations, + containerRunner, + }), + ); + } + + return actions; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index eff65e329f..90f060fd88 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { spawn } from 'child_process'; +import { SpawnOptionsWithoutStdio, spawn } from 'child_process'; import { PassThrough, Writable } from 'stream'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; @@ -22,18 +22,27 @@ import { Octokit } from '@octokit/rest'; import { assertError } from '@backstage/errors'; export type RunCommandOptions = { + /** command to run */ command: string; + /** arguments to pass the command */ args: string[]; + /** options to pass to spawn */ + options?: SpawnOptionsWithoutStdio; + /** stream to capture stdout and stderr output */ logStream?: Writable; }; +/** + * Run a command in a sub-process, normally a shell command. + */ export const runCommand = async ({ command, args, logStream = new PassThrough(), + options, }: RunCommandOptions) => { await new Promise((resolve, reject) => { - const process = spawn(command, args); + const process = spawn(command, args, options); process.stdout.on('data', stream => { logStream.write(stream); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts new file mode 100644 index 0000000000..2ac369249b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createTemplateAction } from '../../createTemplateAction'; +import { readFile } from 'fs-extra'; +import { Gitlab } from '@gitbeaker/node'; +import globby from 'globby'; +import { Types } from '@gitbeaker/core'; + +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { InputError } from '@backstage/errors'; +import { parseRepoUrl } from './util'; +import { resolveSafeChildPath } from '@backstage/backend-common'; + +export type GitlabMergeRequestActionInput = { + projectid: string; + repoUrl: string; + title: string; + description: string; + branchName: string; + targetPath: string; +}; + +export const createPublishGitlabMergeRequestAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction({ + id: 'publish:gitlab:merge-request', + schema: { + input: { + required: ['projectid', 'repoUrl', 'targetPath', 'branchName'], + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository Location', + description: `Accepts the format 'gitlab.com/group_name/project_name' where 'project_name' is the repository name and 'group_name' is a group or username`, + }, + projectid: { + type: 'string', + title: 'projectid', + description: 'Project ID/Name(slug) of the Gitlab Project', + }, + title: { + type: 'string', + title: 'Merge Request Name', + description: 'The name for the merge request', + }, + description: { + type: 'string', + title: 'Merge Request Description', + description: 'The description of the merge request', + }, + branchName: { + type: 'string', + title: 'Destination Branch name', + description: 'The description of the merge request', + }, + targetPath: { + type: 'string', + title: 'Repository Subdirectory', + description: 'Subdirectory of repository to apply changes to', + }, + }, + }, + output: { + type: 'object', + properties: { + projectid: { + title: 'Gitlab Project id/Name(slug)', + type: 'string', + }, + mergeRequestURL: { + title: 'MergeRequest(MR) URL', + type: 'string', + description: 'Link to the merge request in GitLab', + }, + }, + }, + }, + async handler(ctx) { + const repoUrl = ctx.input.repoUrl; + const { host } = parseRepoUrl(repoUrl, integrations); + const integrationConfig = integrations.gitlab.byHost(host); + + const actions: Types.CommitAction[] = []; + + const destinationBranch = ctx.input.branchName; + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + if (!integrationConfig.config.token) { + throw new InputError(`No token available for host ${host}`); + } + + const api = new Gitlab({ + host: integrationConfig.config.baseUrl, + token: integrationConfig.config.token, + }); + + const fileRoot = ctx.workspacePath; + const localFilePaths = await globby([`${ctx.input.targetPath}/**`], { + cwd: fileRoot, + gitignore: true, + dot: true, + }); + + const fileContents = await Promise.all( + localFilePaths.map(p => readFile(resolveSafeChildPath(fileRoot, p))), + ); + + const repoFilePaths = localFilePaths.map(repoFilePath => { + return repoFilePath; + }); + + for (let i = 0; i < repoFilePaths.length; i++) { + actions.push({ + action: 'create', + filePath: repoFilePaths[i], + content: fileContents[i].toString(), + }); + } + + const projects = await api.Projects.show(ctx.input.projectid); + + const { default_branch: defaultBranch } = projects; + + try { + await api.Branches.create( + ctx.input.projectid, + destinationBranch, + String(defaultBranch), + ); + } catch (e) { + throw new InputError(`The branch creation failed ${e}`); + } + + try { + await api.Commits.create( + ctx.input.projectid, + destinationBranch, + ctx.input.title, + actions, + ); + } catch (e) { + throw new InputError( + `Committing the changes to ${destinationBranch} failed ${e}`, + ); + } + + try { + const mergeRequestUrl = await api.MergeRequests.create( + ctx.input.projectid, + destinationBranch, + String(defaultBranch), + ctx.input.title, + { description: ctx.input.description }, + ).then((mergeRequest: { web_url: string }) => { + return mergeRequest.web_url; + }); + ctx.output('projectid', ctx.input.projectid); + ctx.output('mergeRequestUrl', mergeRequestUrl); + } catch (e) { + throw new InputError(`Merge request creation failed${e}`); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index a292438d3a..c42b04e55e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -20,3 +20,4 @@ export { createPublishFileAction } from './file'; export { createPublishGithubAction } from './github'; export { createPublishGithubPullRequestAction } from './githubPullRequest'; export { createPublishGitlabAction } from './gitlab'; +export { createPublishGitlabMergeRequestAction } from './gitlabMergeRequest'; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index b8f2968287..9d26857ab9 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -55,7 +55,7 @@ export interface RouterOptions { catalogClient: CatalogApi; actions?: TemplateAction[]; taskWorkers?: number; - containerRunner: ContainerRunner; + containerRunner?: ContainerRunner; taskBroker?: TaskBroker; } diff --git a/plugins/scaffolder-common/package.json b/plugins/scaffolder-common/package.json index 05d5ae3031..9099eda99d 100644 --- a/plugins/scaffolder-common/package.json +++ b/plugins/scaffolder-common/package.json @@ -40,6 +40,6 @@ "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.9.0" + "@backstage/cli": "^0.10.0" } } diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index e809ae6c71..55ccd3e737 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-scaffolder +## 0.11.14 + +### Patch Changes + +- 6845cce533: Can specify allowedOwners to the RepoUrlPicker picker in a template definition +- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used. +- 2edcf7738f: Fix bug with setting owner in RepoUrlPicker causing validation failure +- b291c3176e: Switch to using `LogViewer` component from `@backstage/core-components` to display scaffolder logs. +- Updated dependencies + - @backstage/core-components@0.8.0 + - @backstage/core-plugin-api@0.3.0 + - @backstage/integration-react@0.1.15 + - @backstage/plugin-catalog-react@0.6.5 + +## 0.11.13 + +### Patch Changes + +- ed5bef529e: Add group filtering to the scaffolder page so that individuals can surface specific templates to end users ahead of others, or group templates together. This can be accomplished by passing in a `groups` prop to the `ScaffolderPage` + + ``` + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + ]} + /> + ``` + +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + ## 0.11.12 ### Patch Changes diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 73fd8fae72..45419e82eb 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -96,6 +96,24 @@ export type FieldExtensionOptions = { validation?: CustomFieldValidator; }; +// Warning: (ae-missing-release-tag) "OwnedEntityPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const OwnedEntityPicker: ({ + onChange, + schema: { title, description }, + required, + uiSchema, + rawErrors, + formData, + idSchema, +}: FieldProps) => JSX.Element; + +// Warning: (ae-missing-release-tag) "OwnedEntityPickerFieldExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const OwnedEntityPickerFieldExtension: () => null; + // Warning: (ae-missing-release-tag) "OwnerPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 3a749792f1..f7c6aa62fc 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -17,7 +17,11 @@ import { CatalogClient } from '@backstage/catalog-client'; import { createDevApp } from '@backstage/dev-utils'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + starredEntitiesApiRef, + DefaultStarredEntitiesApi, +} from '@backstage/plugin-catalog-react'; import React from 'react'; import { scaffolderApiRef, ScaffolderClient } from '../src'; import { ScaffolderPage } from '../src/plugin'; @@ -25,14 +29,25 @@ import { configApiRef, discoveryApiRef, identityApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; createDevApp() + .addPage({ + path: '/catalog/:kind/:namespace/:name', + element: , + }) .registerApi({ api: catalogApiRef, deps: { discoveryApi: discoveryApiRef }, factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), }) + .registerApi({ + api: starredEntitiesApiRef, + deps: { storageApi: storageApiRef }, + factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }), + }) .registerApi({ api: scaffolderApiRef, deps: { diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 2cd341d5b9..3d42126624 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder", "description": "The Backstage plugin that helps you create new things", - "version": "0.11.12", + "version": "0.11.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,20 +34,19 @@ "@backstage/catalog-client": "^0.5.2", "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.5", - "@backstage/core-plugin-api": "^0.2.1", + "@backstage/core-components": "^0.8.1", + "@backstage/core-plugin-api": "^0.3.1", "@backstage/errors": "^0.1.5", - "@backstage/integration": "^0.6.9", - "@backstage/integration-react": "^0.1.14", - "@backstage/plugin-catalog-react": "^0.6.4", - "@backstage/theme": "^0.2.13", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.15", + "@backstage/plugin-catalog-react": "^0.6.5", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@rjsf/core": "^3.2.1", "@rjsf/material-ui": "^3.2.1", - "@types/react": "*", "classnames": "^2.2.6", "git-url-parse": "^11.6.0", "humanize-duration": "^3.25.1", @@ -56,20 +55,22 @@ "lodash": "^4.17.21", "luxon": "^2.0.2", "qs": "^6.9.4", - "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": "^17.2.4", "use-immer": "^0.6.0", "zen-observable": "^0.8.15" }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { - "@backstage/cli": "^0.9.1", - "@backstage/core-app-api": "^0.1.23", - "@backstage/dev-utils": "^0.2.13", - "@backstage/test-utils": "^0.1.23", + "@backstage/cli": "^0.10.2", + "@backstage/core-app-api": "^0.2.1", + "@backstage/dev-utils": "^0.2.14", + "@backstage/plugin-catalog": "^0.7.4", + "@backstage/test-utils": "^0.1.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 34e16708ea..d7696fadde 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -30,7 +30,6 @@ import { ListActionsResponse, ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', - description: 'Used to make requests towards the scaffolder backend', }); type TemplateParameterSchema = { diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 11d12282d2..58ec3dfc02 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -20,7 +20,7 @@ import { Header, Lifecycle, Page, - Progress, + LogViewer, } from '@backstage/core-components'; import { BackstageTheme } from '@backstage/theme'; import { @@ -40,15 +40,13 @@ import Check from '@material-ui/icons/Check'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import classNames from 'classnames'; import { DateTime, Interval } from 'luxon'; -import React, { memo, Suspense, useEffect, useMemo, useState } from 'react'; +import React, { memo, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; import { useInterval } from 'react-use'; import { Status, TaskOutput } from '../../types'; import { useTaskEventStream } from '../hooks/useEventStream'; import { TaskPageLinks } from './TaskPageLinks'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); - // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -213,22 +211,6 @@ export const TaskStatusStepper = memo( }, ); -const TaskLogger = memo(({ log }: { log: string }) => { - return ( - }> -
- -
-
- ); -}); - const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean => !!(entityRef || remoteUrl || links.length > 0); @@ -318,7 +300,9 @@ export const TaskPage = () => { - +
+ +
diff --git a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx index 9b66a0a9b6..2ac0def547 100644 --- a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx +++ b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx @@ -24,11 +24,12 @@ import { Content, ContentHeader, ItemCardGrid, + Link, Progress, WarningPanel, } from '@backstage/core-components'; import { useEntityListProvider } from '@backstage/plugin-catalog-react'; -import { Link, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; import { TemplateCard } from '../TemplateCard'; export type TemplateListProps = { @@ -73,7 +74,7 @@ export const TemplateList = ({ {!error && !loading && !entities.length && ( No templates found that match your filter. Learn more about{' '} - + adding templates . diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 60e9ee8840..7ca65fff25 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -20,9 +20,8 @@ import { } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; -import { fireEvent, within } from '@testing-library/react'; +import { act, fireEvent, within } from '@testing-library/react'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index a8ef524a13..452a5d97b1 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -22,7 +22,7 @@ import { TextField } from '@material-ui/core'; import FormControl from '@material-ui/core/FormControl'; import Autocomplete from '@material-ui/lab/Autocomplete'; import { FieldProps } from '@rjsf/core'; -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useAsync } from 'react-use'; export const EntityPicker = ({ @@ -48,9 +48,18 @@ export const EntityPicker = ({ formatEntityRefTitle(e, { defaultKind }), ); - const onSelect = (_: any, value: string | null) => { - onChange(value || ''); - }; + const onSelect = useCallback( + (_: any, value: string | null) => { + onChange(value || ''); + }, + [onChange], + ); + + useEffect(() => { + if (entityRefs?.length === 1) { + onChange(entityRefs[0]); + } + }, [entityRefs, onChange]); return ( 0 && !formData} > ) => { + const allowedKinds = uiSchema['ui:options']?.allowedKinds as string[]; + const defaultKind = uiSchema['ui:options']?.defaultKind as string | undefined; + const { ownedEntities, loading } = useOwnedEntities(allowedKinds); + + const entityRefs = ownedEntities?.items + .map(e => formatEntityRefTitle(e, { defaultKind })) + .filter(n => n); + + const onSelect = (_: any, value: string | null) => { + onChange(value || ''); + }; + + return ( + 0 && !formData} + > + ( + + )} + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts new file mode 100644 index 0000000000..7cf5add6eb --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { OwnedEntityPicker } from './OwnedEntityPicker'; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index f862fa66fe..92261b6cac 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -13,21 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback, useEffect } from 'react'; -import { FieldProps } from '@rjsf/core'; -import { scaffolderApiRef } from '../../../api'; +import { + Progress, + Select, + SelectedItems, + SelectItem, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { useAsync } from 'react-use'; -import Select from '@material-ui/core/Select'; -import InputLabel from '@material-ui/core/InputLabel'; -import Input from '@material-ui/core/Input'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { FieldProps } from '@rjsf/core'; +import React, { useCallback, useEffect } from 'react'; +import { useAsync } from 'react-use'; +import { scaffolderApiRef } from '../../../api'; -import { useApi } from '@backstage/core-plugin-api'; -import { Progress } from '@backstage/core-components'; - -function splitFormData(url: string | undefined) { +function splitFormData(url: string | undefined, allowedOwners?: string[]) { let host = undefined; let owner = undefined; let repo = undefined; @@ -39,7 +42,7 @@ function splitFormData(url: string | undefined) { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; - owner = parsed.searchParams.get('owner') || undefined; + owner = parsed.searchParams.get('owner') || allowedOwners?.[0]; repo = parsed.searchParams.get('repo') || undefined; // This is azure dev ops specific. not used for any other provider. organization = parsed.searchParams.get('organization') || undefined; @@ -95,18 +98,21 @@ export const RepoUrlPicker = ({ const scaffolderApi = useApi(scaffolderApiRef); const integrationApi = useApi(scmIntegrationsApiRef); const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; + const allowedOwners = uiSchema['ui:options']?.allowedOwners as string[]; const { value: integrations, loading } = useAsync(async () => { return await scaffolderApi.getIntegrationsList({ allowedHosts }); }); - const { host, owner, repo, organization, workspace, project } = - splitFormData(formData); + const { host, owner, repo, organization, workspace, project } = splitFormData( + formData, + allowedOwners, + ); const updateHost = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => { + (value: SelectedItems) => { onChange( serializeFormData({ - host: evt.target.value as string, + host: value as string, owner, repo, organization, @@ -118,6 +124,21 @@ export const RepoUrlPicker = ({ [onChange, owner, repo, organization, workspace, project], ); + const updateOwnerSelect = useCallback( + (value: SelectedItems) => + onChange( + serializeFormData({ + host, + owner: value as string, + repo, + organization, + workspace, + project, + }), + ), + [onChange, host, repo, organization, workspace, project], + ); + const updateOwner = useCallback( (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => onChange( @@ -221,6 +242,16 @@ export const RepoUrlPicker = ({ return ; } + const hostsOptions: SelectItem[] = integrations + ? integrations + .filter(i => allowedHosts?.includes(i.host)) + .map(i => ({ label: i.title, value: i.host })) + : [{ label: 'Loading...', value: 'loading' }]; + + const ownersOptions: SelectItem[] = allowedOwners + ? allowedOwners.map(i => ({ label: i, value: i })) + : [{ label: 'Loading...', value: 'loading' }]; + return ( <> 0 && !host} > - Host - + - - The organization, user or project that this repo will belong to - - - - )} + {host && + integrationApi.byHost(host)?.type !== 'bitbucket' && + !allowedOwners && ( + <> + 0 && !owner} + > + Owner + + + The organization, user or project that this repo will belong to + + + + )} + {/* Show this for all hosts except bitbucket where allowed owner is set */} + {host && + integrationApi.byHost(host)?.type !== 'bitbucket' && + allowedOwners && ( + <> + 0 && !owner} + > +