diff --git a/.changeset/lovely-cycles-shout.md b/.changeset/lovely-cycles-shout.md new file mode 100644 index 0000000000..f65e2520bf --- /dev/null +++ b/.changeset/lovely-cycles-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Update `AddLocationResponse` to optionally return `exists` to signal that the location already exists, this is only returned when calling `addLocation` in dryRun. diff --git a/.changeset/lovely-keys-occur.md b/.changeset/lovely-keys-occur.md new file mode 100644 index 0000000000..b01ccd3bf5 --- /dev/null +++ b/.changeset/lovely-keys-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Update `createLocation` to optionally return `exists` to signal that the location already exists, this is only returned for dry runs. diff --git a/.changeset/mighty-needles-know.md b/.changeset/mighty-needles-know.md new file mode 100644 index 0000000000..7e69a53678 --- /dev/null +++ b/.changeset/mighty-needles-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Disabled ECMAScript transforms in app and backend builds in order to reduce bundle size and runtime performance. For the rationale and a full list of syntax that is no longer transformed, see https://github.com/alangpierce/sucrase#transforms. This also enables TypeScripts `useDefineForClassFields` flag by default, which in rare occasions could cause build failures. For instructions on how to mitigate issues due to the flag, see the [TypeScript documentation](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier). diff --git a/.changeset/neat-pigs-lay.md b/.changeset/neat-pigs-lay.md new file mode 100644 index 0000000000..48fee7a703 --- /dev/null +++ b/.changeset/neat-pigs-lay.md @@ -0,0 +1,22 @@ +--- +'@backstage/create-app': patch +--- + +Switched required engine from Node.js 12 or 14, to 14 or 16. + +To apply these changes to an existing app, switch out the following in the root `package.json`: + +```diff + "engines": { +- "node": "12 || 14" ++ "node": "14 || 16" + }, +``` + +Also get rid of the entire `engines` object in `packages/backend/package.json`, as it is redundant: + +```diff +- "engines": { +- "node": "12 || 14" +- }, +``` diff --git a/.changeset/nice-lions-hug.md b/.changeset/nice-lions-hug.md new file mode 100644 index 0000000000..69dd33fceb --- /dev/null +++ b/.changeset/nice-lions-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Updates the `AboutCard` with a refresh button that allows the entity to be scheduled for refresh. diff --git a/.changeset/short-pens-stare.md b/.changeset/short-pens-stare.md new file mode 100644 index 0000000000..f92ff12941 --- /dev/null +++ b/.changeset/short-pens-stare.md @@ -0,0 +1,7 @@ +--- +'@backstage/create-app': patch +--- + +Bumped the default `@spotify/prettier-config` dependency to `^11.0.0`. + +This is an optional upgrade, but you may be interested in doing the same, to get the most modern lint rules out there. diff --git a/.changeset/slimy-impalas-admire.md b/.changeset/slimy-impalas-admire.md new file mode 100644 index 0000000000..853d3eee65 --- /dev/null +++ b/.changeset/slimy-impalas-admire.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-react': minor +--- + +Extends the `CatalogClient` interface with a `refreshEntity` method. diff --git a/.changeset/slow-starfishes-retire.md b/.changeset/slow-starfishes-retire.md new file mode 100644 index 0000000000..35fc64fa8b --- /dev/null +++ b/.changeset/slow-starfishes-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Restrict imports on the form `../../plugins/x/src` diff --git a/.changeset/spicy-oranges-tan.md b/.changeset/spicy-oranges-tan.md new file mode 100644 index 0000000000..f835bcffb3 --- /dev/null +++ b/.changeset/spicy-oranges-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-git-release-manager': patch +--- + +Remove 'refresh' icon from success dialog's OK-CTA. User feedback deemed it confusing. diff --git a/.changeset/tidy-windows-compare.md b/.changeset/tidy-windows-compare.md new file mode 100644 index 0000000000..27005542ae --- /dev/null +++ b/.changeset/tidy-windows-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Switched the Jest YAML transform from `yaml-jest` to `jest-transform-yaml`, which works with newer versions of Node.js. diff --git a/.changeset/tiny-berries-battle.md b/.changeset/tiny-berries-battle.md new file mode 100644 index 0000000000..fdcb612d61 --- /dev/null +++ b/.changeset/tiny-berries-battle.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Provide access to the Kubernetes dashboard when viewing a specific resource diff --git a/.changeset/unlucky-laws-divide.md b/.changeset/unlucky-laws-divide.md new file mode 100644 index 0000000000..0a1c69c73a --- /dev/null +++ b/.changeset/unlucky-laws-divide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +The import form is now aware of locations that already exist. It lists them separately and shows a button for triggering a refresh. diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 4c91b755ee..4ad3612ac8 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -38,6 +38,6 @@ labels: bug -- NodeJS Version (v12): +- NodeJS Version (v14): - Operating System and Version (e.g. Ubuntu 14.04): - Browser Information: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d203fafa2c..b68a96efbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] services: postgres13: diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index 3e2fbe0863..f39a89e8c6 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [windows-latest] - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] env: CI: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0fe1642394..d7754a0046 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] env: CI: true diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index c61ee4798b..873f12086a 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] env: CI: true diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index daac9a535d..7bd73e2c61 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] services: postgres13: diff --git a/ADOPTERS.md b/ADOPTERS.md index d7c304421b..430ed36dbb 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -46,3 +46,4 @@ | [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | | [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | | [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | +| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | diff --git a/contrib/docker/kubernetes-example-backend/Dockerfile b/contrib/docker/kubernetes-example-backend/Dockerfile index b7d7a9631e..4f4105838d 100644 --- a/contrib/docker/kubernetes-example-backend/Dockerfile +++ b/contrib/docker/kubernetes-example-backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12-buster +FROM node:14-buster WORKDIR /usr/src/app diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 068a17249c..f48faa202d 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -27,6 +27,8 @@ kubernetes: authProvider: 'serviceAccount' skipTLSVerify: false 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 - url: http://127.0.0.2:9999 name: aws-cluster-1 authProvider: 'aws' @@ -98,6 +100,41 @@ kubectl -n get secret $(kubectl -n get sa ...; +``` + +See also +https://github.com/backstage/backstage/tree/master/plugins/kubernetes/src/utils/clusterLinks/formatters +for real examples. + #### `gke` This cluster locator is designed to work with Kubernetes clusters running in diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index d318eef874..67c4dcac33 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -31,8 +31,8 @@ a structure with up to four elements: - `host`: The host of the Bitbucket instance, e.g. `bitbucket.company.com`. - `token` (optional): An personal access token as expected by Bitbucket. Either an access token **or** a username + appPassword may be supplied. -- `username`: The Bitbucket username to use in API requests. If neither a - username nor token are supplied, anonymous access will be used. +- `username` (optional): The Bitbucket username to use in API requests. If + neither a username nor token are supplied, anonymous access will be used. - `appPassword` (optional): The password for the Bitbucket user. Only needed when using `username` instead of `token`. - `apiBaseUrl` (optional): The URL of the Bitbucket API. For self-hosted diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 7ea892840c..3425b02db9 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -195,6 +195,23 @@ cache store better suited for production deployment. The right cache store for your Backstage instance will depend on your own run-time constraints and those required of the plugins you're running. +### Use memory for cache + +```yaml +backend: + cache: + store: memory +``` + +### Use memcache for cache + +```yaml +backend: + cache: + store: memcache + connection: user:pass@cache.example.com:11211 +``` + Contributions supporting other cache stores are welcome! ## Containerization diff --git a/docs/plugins/integrating-plugin-into-software-catalog.md b/docs/plugins/integrating-plugin-into-software-catalog.md index ae7c3189da..71430e25a0 100644 --- a/docs/plugins/integrating-plugin-into-software-catalog.md +++ b/docs/plugins/integrating-plugin-into-software-catalog.md @@ -10,8 +10,8 @@ description: How to integrate a plugin into software catalog ## Steps 1. [Create a plugin](#create-a-plugin) -1. [Export a router with relative routes](#export-a-router) -1. [Import and use router in the APP](#import-and-use-router-in-the-app) +1. [Reading entities from within your plugin](#reading-entities-from-within-your-plugin) +1. [Import your plugin and embed in the entities page](#import-your-plugin-and-embed-in-the-entities-page) ### Create a plugin @@ -28,98 +28,92 @@ $ yarn create-plugin Creating the plugin... ``` -### Export a router +### Reading entities from within your plugin -Now in the plugin you have a `Router.tsx` file in the `src` folder. By default -it contains only one example route. Create a routing structure needed for your -plugin, keeping in mind that the whole set of routes defined here are going to -be mounted under some different route in the App. - -Example: - -`my-plugin` consists of 2 different views - `/me` and `/about`. I envision -people integrating it into plugin catalog as a tab named "MyPlugin". Then, my -`Routes.tsx` for the plugin is going to look like: +You can access the currently selected entity using the backstage api +[`useEntity`](../reference/plugin-catalog-react.useentity.md). For example, ```tsx - - } /> - } /> - -``` +import { useEntity } from '@backstage/plugin-catalog-react'; -(where MePage and AboutPage are 2 components defined in your plugin and imported -accordingly inside `Router.tsx`) +export const MyPluginEntityContent = () => { + const { entity, loading, error, refresh } = useEntity(); -> Pay attention, if your `MePage` references the `AboutPage` it needs to do it -> through link to `about`, not `/about`. This allows react-router v6 to enable -> its relative routing mechanism. Read more - -> https://reacttraining.com/blog/react-router-v6-pre/#relative-route-path-and-link-to - -### Import and use router in the APP - -In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, -containing Backstage app) import your created Router: - -```tsx -import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; -``` - -Now, you need to mount `MyPluginRouter` onto some route, for example if you had: - -```tsx -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); -``` - -after you add your code it becomes: - -```tsx -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - -); -``` - -All of magic happens thanks to the `EntityPageLayout` component, which comes as -an export from `@backstage/plugin-catalog` package. - -```tsx -type EntityPageLayoutContentProps = { - /** - * Going to be transformed into react-router v6 - * path under the hood. Read more at https://reacttraining.com/blog/react-router-v6-pre - */ - path: string; - /** - * Gets transformed into the title for the tab - */ - title: string; - /** - * Element that is rendered when the location - * matches the path provided - */ - element: JSX.Element; + // Do something with the entity data... }; ``` -> You can either pass the entity from App to the plugin's router as a prop or -> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your -> plugin. +Internally `useEntity` makes use of +[react `Context`s](https://reactjs.org/docs/context.html). The entity context is +provided by the entity page into which your plugin will be embedded. + +### Import your plugin and embed in the entities page + +To begin, you will need to import your plugin in the entities page. Located at +`packages/app/src/components/Catalog/EntityPage.tsx` from the root package of +your backstage app. + +```tsx +import { MyPluginEntityContent } from '@backstage/plugin-my-plugin; +``` + +To add your component to the Entity view, you will need to modify the +`packages/app/src/components/Catalog/EntityPage.tsx`. Depending on the needs of +your plugin, you may only care about certain kinds of +[entities](https://backstage.io/docs/features/software-catalog/descriptor-format), +each of which has its own +[element](https://reactjs.org/docs/rendering-elements.html) for rendering. This +functionality is handled by the `EntitySwitch` component: + +```tsx +export const entityPage = ( + + + + + + + + + {defaultEntityPage} + +); +``` + +At this point, you will need to modify the specific page where you want your +component to appear. If you are extending the Software Catalog model you will +need to add a new case to the `EntitySwitch`. For adding a plugin to an existing +component type, you modify the existing page. For example, if you want to add +your plugin to the `systemPage`, you can add a new tab by adding an +`EntityLayout.Route` such as below: + +```tsx +const systemPage = ( + + + + + + + + + + + + + + + + + + + + + + {/* Adding a new tab to the system view */} + + + + +); +``` diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index e123410fe0..938a57198a 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -20,7 +20,7 @@ title: Adding Custom Plugin to Existing Monorepo App > functionality, extend the Sidebar to make our life easy. Finally, we add > custom code to display GitHub repository information. > -> This document assumes you have Node.js 12 active along with Yarn and Python. +> This document assumes you have Node.js 14 active along with Yarn and Python. > Please note, that at the time of this writing, the current version is > 0.1.1-alpha.21. This guide can still be used with future versions, just, > verify as you go. If you run into issues, you can compare your setup with mine diff --git a/microsite/blog/2021-09-16-the-techdocs-beta-has-landed.md b/microsite/blog/2021-09-16-the-techdocs-beta-has-landed.md index 89318a38dc..c4edf22de2 100644 --- a/microsite/blog/2021-09-16-the-techdocs-beta-has-landed.md +++ b/microsite/blog/2021-09-16-the-techdocs-beta-has-landed.md @@ -63,7 +63,7 @@ If you've been waiting for the beta to give TechDocs a shot, good news! You'll g If you're upgrading from a prior, alpha version of TechDocs, be aware that there are some breaking changes that you will need to account for. Most importantly: -- Be sure your app's instance of TechDocs is wired up to use the composability API. [See full migration instructions here](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). +- Be sure your app's instance of TechDocs is wired up to use the composability API. [See full routing instructions here](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#0338). - If you've configured TechDocs to publish generated content to a remote storage solution provider like AWS S3, Google Cloud Storage, or Azure Blob Storage, you will need to use the TechDocs CLI to migrate the contents of your storage bucket. [See full migration instructions here](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). - In order to take advantage of the stale file deletion functionality, you may need to add some permissions to the account or role used by your TechDocs backend to publish contents to your storage provider. [See "Using Cloud Storage docs"](https://backstage.io/docs/features/techdocs/using-cloud-storage) for updated permissions recommendations. diff --git a/microsite/data/plugins/catalog-graph.yaml b/microsite/data/plugins/catalog-graph.yaml new file mode 100644 index 0000000000..336954b5dc --- /dev/null +++ b/microsite/data/plugins/catalog-graph.yaml @@ -0,0 +1,9 @@ +--- +title: Catalog Graph +author: SDA SE +authorUrl: https://sda.se/ +category: Discovery +description: Extend the Backstage Software Catalog with a graph that shows all entities and their relationships providing an easier way to discover the ecosystem. +documentation: https://github.com/backstage/backstage/blob/master/plugins/catalog-graph/README.md +iconUrl: img/catalog-graph.svg +npmPackageName: '@backstage/plugin-catalog-graph' diff --git a/microsite/static/img/catalog-graph.svg b/microsite/static/img/catalog-graph.svg new file mode 100644 index 0000000000..6cd13b0e66 --- /dev/null +++ b/microsite/static/img/catalog-graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index d362338370..2620ffd32e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "root", "private": true, "engines": { - "node": "12 || 14" + "node": "14 || 16" }, "scripts": { "dev": "concurrently \"yarn start\" \"yarn start-backend\"", @@ -62,7 +62,7 @@ "@types/webpack": "^5.28.0", "@changesets/cli": "^2.14.0", "@octokit/openapi-types": "^2.2.0", - "@spotify/prettier-config": "^10.0.0", + "@spotify/prettier-config": "^11.0.0", "command-exists": "^1.2.9", "concurrently": "^6.0.0", "eslint-plugin-notice": "^0.9.10", diff --git a/packages/app/package.json b/packages/app/package.json index b5a5af180d..b997313485 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,6 +13,7 @@ "@backstage/plugin-api-docs": "^0.6.8", "@backstage/plugin-badges": "^0.2.9", "@backstage/plugin-catalog": "^0.6.15", + "@backstage/plugin-catalog-graph": "^0.1.0", "@backstage/plugin-catalog-import": "^0.5.21", "@backstage/plugin-catalog-react": "^0.4.6", "@backstage/plugin-circleci": "^0.2.23", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 81dbdf4cac..87984bece0 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,20 +14,34 @@ * limitations under the License. */ +import { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_PART, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; import { createApp, FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; -import { HomepageCompositionRoot } from '@backstage/plugin-home'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { CatalogEntityPage, CatalogIndexPage, catalogPlugin, } from '@backstage/plugin-catalog'; - +import { + CatalogGraphPage, + catalogGraphPlugin, +} from '@backstage/plugin-catalog-graph'; import { CatalogImportPage, catalogImportPlugin, @@ -40,12 +54,13 @@ import { import { ExplorePage, explorePlugin } from '@backstage/plugin-explore'; import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { HomepageCompositionRoot } from '@backstage/plugin-home'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { + ScaffolderFieldExtensions, ScaffolderPage, scaffolderPlugin, - ScaffolderFieldExtensions, } from '@backstage/plugin-scaffolder'; import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; @@ -61,12 +76,11 @@ import React from 'react'; import { hot } from 'react-hot-loader/root'; import { Navigate, Route } from 'react-router'; import { apis } from './apis'; -import { Root } from './components/Root'; import { entityPage } from './components/catalog/EntityPage'; -import { searchPage } from './components/search/SearchPage'; -import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions'; import { HomePage } from './components/home/HomePage'; - +import { Root } from './components/Root'; +import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions'; +import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -95,6 +109,9 @@ const app = createApp({ createComponent: scaffolderPlugin.routes.root, viewTechDoc: techdocsPlugin.routes.docRoot, }); + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); bind(apiDocsPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, }); @@ -125,6 +142,28 @@ const routes = ( {entityPage} } /> + + } + /> }> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 4a392e0f05..62fdcf4d3f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -14,15 +14,24 @@ * limitations under the License. */ -import React, { ReactNode, useMemo, useState } from 'react'; -import BadgeIcon from '@material-ui/icons/CallToAction'; +import { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_PART, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; +import { EmptyState } from '@backstage/core-components'; import { EntityApiDefinitionCard, + EntityConsumedApisCard, EntityConsumingComponentsCard, EntityHasApisCard, - EntityProvidingComponentsCard, EntityProvidedApisCard, - EntityConsumedApisCard, + EntityProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { @@ -30,20 +39,23 @@ import { EntityDependsOnComponentsCard, EntityDependsOnResourcesCard, EntityHasComponentsCard, + EntityHasResourcesCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLayout, EntityLinksCard, - EntitySystemDiagramCard, - EntitySwitch, - isComponentType, - isKind, - EntityHasResourcesCard, EntityOrphanWarning, EntityProcessingErrorsPanel, + EntitySwitch, hasCatalogProcessingErrors, + isComponentType, + isKind, isOrphan, } from '@backstage/plugin-catalog'; +import { + Direction, + EntityCatalogGraphCard, +} from '@backstage/plugin-catalog-graph'; import { EntityCircleCIContent, isCircleCIAvailable, @@ -52,6 +64,7 @@ import { EntityCloudbuildContent, isCloudbuildAvailable, } from '@backstage/plugin-cloudbuild'; +import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; import { EntityGithubActionsContent, EntityRecentGithubActionsRunsCard, @@ -87,6 +100,7 @@ import { EntitySentryContent } from '@backstage/plugin-sentry'; 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, @@ -108,8 +122,7 @@ import { EntityTravisCIOverviewCard, isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; -import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; -import { EmptyState } from '@backstage/core-components'; +import React, { ReactNode, useMemo, useState } from 'react'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -246,10 +259,14 @@ const errorsContent = ( const overviewContent = ( {entityWarningContent} - + + + + + @@ -454,9 +471,12 @@ const apiPage = ( {entityWarningContent} - + + + + @@ -523,6 +543,9 @@ const systemPage = ( + + + @@ -535,7 +558,23 @@ const systemPage = ( - + ); @@ -548,6 +587,9 @@ const domainPage = ( + + + diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 77090e13dc..4967fac228 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -97,7 +97,7 @@ "get-port": "^5.1.1", "http-errors": "^1.7.3", "jest": "^26.0.1", - "mock-fs": "^4.13.0", + "mock-fs": "^5.1.0", "msw": "^0.29.0", "mysql2": "^2.2.5", "recursive-readdir": "^2.2.2", diff --git a/packages/backend/package.json b/packages/backend/package.json index 3240635119..f564dfeeee 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,9 +5,6 @@ "types": "src/index.ts", "license": "Apache-2.0", "private": true, - "engines": { - "node": "12 || 14" - }, "homepage": "https://backstage.io", "repository": { "type": "git", diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index a645f37ab8..8a81c0eb10 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -19,6 +19,7 @@ export type AddLocationRequest = { export type AddLocationResponse = { location: Location_2; entities: Entity[]; + exists?: boolean; }; // @public (undocumented) @@ -57,6 +58,11 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; // (undocumented) + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, @@ -102,6 +108,11 @@ export class CatalogClient implements CatalogApi { options?: CatalogRequestOptions, ): Promise; // (undocumented) + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 410d921d3b..1a6bc5c70d 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -136,6 +136,24 @@ export class CatalogClient implements CatalogApi { ); } + async refreshEntity(entityRef: string, options?: CatalogRequestOptions) { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/refresh`, + { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify({ entityRef }), + }, + ); + + if (response.status !== 200) { + throw new Error(await response.text()); + } + } + async addLocation( { type = 'url', target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, @@ -158,7 +176,7 @@ export class CatalogClient implements CatalogApi { throw new Error(await response.text()); } - const { location, entities } = await response.json(); + const { location, entities, exists } = await response.json(); if (!location) { throw new Error(`Location wasn't added: ${target}`); @@ -167,6 +185,7 @@ export class CatalogClient implements CatalogApi { return { location, entities, + exists, }; } diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index e78bd9d713..679588b10a 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -53,6 +53,10 @@ export interface CatalogApi { uid: string, options?: CatalogRequestOptions, ): Promise; + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; // Locations getLocationById( @@ -89,4 +93,6 @@ export type AddLocationRequest = { export type AddLocationResponse = { location: Location; entities: Entity[]; + // Exists is only set in DryRun mode. + exists?: boolean; }; diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index c619df9eee..99767f5d79 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -62,7 +62,10 @@ module.exports = { { vars: 'all', args: 'after-used', ignoreRestSiblings: true }, ], // Avoid cross-package imports - 'no-restricted-imports': [2, { patterns: ['**/../../**/*/src/**'] }], + 'no-restricted-imports': [ + 2, + { patterns: ['**/../../**/*/src/**', '**/../../**/*/src'] }, + ], // Avoid default import from winston as it breaks at runtime 'no-restricted-syntax': [ 'error', diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index b8d3d98c8f..2a85c307b0 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -83,7 +83,7 @@ module.exports = { ...require('module').builtinModules, ], // Avoid cross-package imports - patterns: ['**/../../**/*/src/**'], + patterns: ['**/../../**/*/src/**', '**/../../**/*/src'], }, ], }, diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 4e11a63a6b..1df33d2395 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -82,7 +82,7 @@ async function getConfig() { '\\.(js|jsx|ts|tsx)$': require.resolve('@sucrase/jest-plugin'), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg|eot|woff|woff2|ttf)$': require.resolve('./jestFileTransform.js'), - '\\.(yaml)$': require.resolve('yaml-jest'), + '\\.(yaml)$': require.resolve('jest-transform-yaml'), }, // A bit more opinionated diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index f6b4f65edd..33a8b0a9bd 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -33,6 +33,7 @@ "strictPropertyInitialization": true, "stripInternal": true, "target": "ES2019", - "types": ["node", "jest", "webpack-env"] + "types": ["node", "jest", "webpack-env"], + "useDefineForClassFields": true } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8dabbed89d..3e0a7f4e6f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,7 +44,7 @@ "@spotify/eslint-config-base": "^9.0.0", "@spotify/eslint-config-react": "^10.0.0", "@spotify/eslint-config-typescript": "^10.0.0", - "@sucrase/jest-plugin": "^2.1.0", + "@sucrase/jest-plugin": "^2.1.1", "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "5.5.x", "@svgr/plugin-svgo": "5.4.x", @@ -82,6 +82,7 @@ "jest": "^26.0.1", "jest-css-modules": "^2.1.0", "json-schema": "^0.3.0", + "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", "mini-css-extract-plugin": "^1.4.1", "node-libs-browser": "^2.2.1", @@ -102,7 +103,7 @@ "run-script-webpack-plugin": "^0.0.11", "semver": "^7.3.2", "style-loader": "^1.2.1", - "sucrase": "^3.18.2", + "sucrase": "^3.20.1", "tar": "^6.1.2", "terser-webpack-plugin": "^5.1.3", "ts-loader": "^8.0.17", @@ -112,7 +113,6 @@ "webpack-dev-server": "4.0.0-rc.0", "webpack-node-externals": "^3.0.0", "yaml": "^1.10.0", - "yaml-jest": "^1.0.5", "yml-loader": "^2.1.0", "yn": "^4.0.0" }, @@ -141,7 +141,7 @@ "@types/webpack-dev-server": "^3.11.5", "@types/yarnpkg__lockfile": "^1.1.4", "del": "^6.0.0", - "mock-fs": "^4.13.0", + "mock-fs": "^5.1.0", "nodemon": "^2.0.2", "ts-node": "^10.0.0" }, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 56162acf4b..cf9e873a66 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -39,6 +39,7 @@ export const transforms = (options: TransformOptions): Transforms => { loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['typescript', 'jsx', ...extraTransforms], + disableESTransforms: true, production: !isDev, }, }, @@ -48,6 +49,7 @@ export const transforms = (options: TransformOptions): Transforms => { loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['jsx', ...extraTransforms], + disableESTransforms: true, production: !isDev, }, }, @@ -64,6 +66,7 @@ export const transforms = (options: TransformOptions): Transforms => { loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['jsx', ...extraTransforms], + disableESTransforms: true, production: !isDev, }, }, diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0f5bac2ffc..2f2878799e 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -48,7 +48,7 @@ "@types/mock-fs": "^4.10.0", "@types/node": "^14.14.32", "@types/yup": "^0.29.8", - "mock-fs": "^4.13.0" + "mock-fs": "^5.1.0" }, "files": [ "dist" diff --git a/packages/config/package.json b/packages/config/package.json index 13b074c620..d165ceb6f6 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -33,6 +33,7 @@ "lodash": "^4.17.21" }, "devDependencies": { + "@backstage/test-utils-core": "^0.1.2", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/config/src/reader.test.ts b/packages/config/src/reader.test.ts index 7f3513abf6..0cea80dca1 100644 --- a/packages/config/src/reader.test.ts +++ b/packages/config/src/reader.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { withLogCollector } from '../../test-utils-core/src'; +import { withLogCollector } from '@backstage/test-utils-core'; import { ConfigReader } from './reader'; const DATA = { diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index e987d6b67e..b5c98add13 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -7,6 +7,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; +import { BackstageTheme } from '@backstage/theme'; import { Breadcrumbs as Breadcrumbs_2 } from '@material-ui/core'; import { ButtonProps } from '@material-ui/core'; import { CardHeaderProps } from '@material-ui/core'; @@ -25,7 +26,6 @@ import { LinkProps as LinkProps_2 } from '@material-ui/core'; import { LinkProps as LinkProps_3 } from 'react-router-dom'; import { MaterialTableProps } from '@material-table/core'; import { NavLinkProps } from 'react-router-dom'; -import { Palette } from '@material-ui/core/styles/createPalette'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import PropTypes from 'prop-types'; diff --git a/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx b/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx index 15a1a7d78b..4dbc5d7c1a 100644 --- a/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx +++ b/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx @@ -21,7 +21,7 @@ import { WithStyles, } from '@material-ui/core'; import React from 'react'; -import { BackstageTheme } from '../../../../theme/src'; +import { BackstageTheme } from '@backstage/theme'; const styles = (theme: BackstageTheme) => createStyles({ diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 3f913102cb..eb28e5fe0c 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "engines": { - "node": "12 || 14" + "node": "14 || 16" }, "scripts": { "dev": "concurrently \"yarn start\" \"yarn start-backend\"", @@ -35,7 +35,7 @@ }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@spotify/prettier-config": "^7.0.0", + "@spotify/prettier-config": "^11.0.0", "concurrently": "^6.0.0", "lerna": "^4.0.0", "prettier": "^2.3.2" diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 44af516099..194892d40f 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -4,9 +4,6 @@ "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, - "engines": { - "node": "12 || 14" - }, "scripts": { "build": "backstage-cli backend:bundle", "build-image": "docker build ../.. -f Dockerfile --tag backstage", diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 22871ee78f..8e56c9e002 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -54,7 +54,7 @@ "js-yaml": "^4.0.0", "json5": "^2.1.3", "mime-types": "^2.1.27", - "mock-fs": "^4.13.0", + "mock-fs": "^5.1.0", "p-limit": "^3.1.0", "recursive-readdir": "^2.2.2", "winston": "^3.2.1" diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 6ffcfd6f46..5dd3c768bb 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -33,6 +33,7 @@ describe('CatalogIdentityClient', () => { getOriginLocationByEntity: jest.fn(), getLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; const tokenIssuer: jest.Mocked = { issueToken: jest.fn(), 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 a1606e18f2..c9647176cd 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -68,7 +68,6 @@ beforeEach(() => { describe('AwsALBAuthProvider', () => { const catalogApi = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ addLocation: jest.fn(), removeLocationById: jest.fn(), getEntities: jest.fn(), @@ -77,6 +76,7 @@ describe('AwsALBAuthProvider', () => { getLocationById: jest.fn(), removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), + refreshEntity: jest.fn(), }; const mockRequest = { diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 5d32e64f19..5dc877ec32 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -66,6 +66,7 @@ describe('createRouter', () => { getLocationById: jest.fn(), removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 72216ad151..89ef2c09d1 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -1148,6 +1148,7 @@ export interface LocationService { ): Promise<{ location: Location_2; entities: Entity[]; + exists?: boolean; }>; // (undocumented) deleteLocation(id: string): Promise; diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts index f965ec3077..939f54e793 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts @@ -36,6 +36,7 @@ describe('DefaultLocationServiceTest', () => { describe('createLocation', () => { it('should support dry run', async () => { + store.listLocations.mockResolvedValueOnce([]); orchestrator.process.mockResolvedValueOnce({ ok: true, state: new Map(), @@ -116,6 +117,63 @@ describe('DefaultLocationServiceTest', () => { expect(store.createLocation).not.toBeCalled(); }); + it('should check for location existence when running in dry run', async () => { + const locationSpec = { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }; + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: new Map(), + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bar', + }, + }, + deferredEntities: [], + relations: [], + errors: [], + }); + + store.listLocations.mockResolvedValueOnce([ + { id: '137', ...locationSpec }, + ]); + const result = await locationService.createLocation( + { type: 'url', target: 'https://backstage.io/catalog-info.yaml' }, + true, + ); + + expect(result.exists).toBe(true); + }); + + it('should return exists false when the location does not exist beforehand', async () => { + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: new Map(), + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bar', + }, + }, + deferredEntities: [], + relations: [], + errors: [], + }); + + store.listLocations.mockResolvedValueOnce([ + { id: '987', type: 'url', target: 'https://example.com' }, + ]); + const result = await locationService.createLocation( + { type: 'url', target: 'https://backstage.io/catalog-info.yaml' }, + true, + ); + expect(result.exists).toBe(false); + }); + it('should create location', async () => { const locationSpec = { type: 'url', diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts index 2820ba14b1..7d485bea40 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.ts @@ -36,7 +36,7 @@ export class DefaultLocationService implements LocationService { async createLocation( spec: LocationSpec, dryRun: boolean, - ): Promise<{ location: Location; entities: Entity[] }> { + ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }> { if (dryRun) { return this.dryRunCreateLocation(spec); } @@ -56,7 +56,14 @@ export class DefaultLocationService implements LocationService { private async dryRunCreateLocation( spec: LocationSpec, - ): Promise<{ location: Location; entities: Entity[] }> { + ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }> { + // Run the existence check in parallel with the processing + const existsPromise = this.store + .listLocations() + .then(locations => + locations.some(l => l.type === spec.type && l.target === spec.target), + ); + const entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Location', @@ -100,6 +107,7 @@ export class DefaultLocationService implements LocationService { } return { + exists: await existsPromise, location: { ...spec, id: `${spec.type}:${spec.target}` }, entities, }; diff --git a/plugins/catalog-backend/src/next/NextRouter.test.ts b/plugins/catalog-backend/src/next/NextRouter.test.ts new file mode 100644 index 0000000000..15c8716004 --- /dev/null +++ b/plugins/catalog-backend/src/next/NextRouter.test.ts @@ -0,0 +1,429 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; +import type { Entity, LocationSpec, Location } from '@backstage/catalog-model'; +import express from 'express'; +import request from 'supertest'; +import { EntitiesCatalog } from '../catalog'; +import { LocationService, RefreshService } from './types'; +import { basicEntityFilter } from '../service/request'; +import { createNextRouter } from './NextRouter'; + +describe('createNextRouter readonly disabled', () => { + let entitiesCatalog: jest.Mocked; + let locationService: jest.Mocked; + let app: express.Express; + let refreshService: RefreshService; + + beforeAll(async () => { + entitiesCatalog = { + entities: jest.fn(), + removeEntityByUid: jest.fn(), + batchAddOrUpdateEntities: jest.fn(), + }; + locationService = { + getLocation: jest.fn(), + createLocation: jest.fn(), + listLocations: jest.fn(), + deleteLocation: jest.fn(), + }; + refreshService = { refresh: jest.fn() }; + const router = await createNextRouter({ + entitiesCatalog, + locationService, + logger: getVoidLogger(), + refreshService, + config: new ConfigReader(undefined), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('POST /refresh', () => { + it('refreshes an entity using the refresh service', async () => { + const response = await request(app) + .post('/refresh') + .set('Content-Type', 'application/json') + .send({ entityRef: 'Component/default:foo' }); + expect(response.status).toBe(200); + expect(refreshService.refresh).toHaveBeenCalledWith({ + entityRef: 'Component/default:foo', + }); + }); + }); + describe('GET /entities', () => { + it('happy path: lists entities', async () => { + const entities: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [entities[0]], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(entities); + }); + + it('parses single and multiple request parameters and passes them down', async () => { + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [], + pageInfo: { hasNextPage: false }, + }); + const response = await request(app).get( + '/entities?filter=a=1,a=2,b=3&filter=c=4', + ); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: { + anyOf: [ + { + allOf: [ + { key: 'a', matchValueIn: ['1', '2'] }, + { key: 'b', matchValueIn: ['3'] }, + ], + }, + { allOf: [{ key: 'c', matchValueIn: ['4'] }] }, + ], + }, + }); + }); + }); + + describe('GET /entities/by-uid/:uid', () => { + it('can fetch entity by uid', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + }, + }; + entitiesCatalog.entities.mockResolvedValue({ + entities: [entity], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities/by-uid/zzz'); + + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual(expect.objectContaining(entity)); + }); + + it('responds with a 404 for missing entities', async () => { + entitiesCatalog.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities/by-uid/zzz'); + + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + }); + expect(response.status).toEqual(404); + expect(response.text).toMatch(/uid/); + }); + }); + + describe('GET /entities/by-name/:kind/:namespace/:name', () => { + it('can fetch entity by name', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + }; + entitiesCatalog.entities.mockResolvedValue({ + entities: [entity], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities/by-name/k/ns/n'); + + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ + kind: 'k', + 'metadata.namespace': 'ns', + 'metadata.name': 'n', + }), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual(expect.objectContaining(entity)); + }); + + it('responds with a 404 for missing entities', async () => { + entitiesCatalog.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities/by-name/b/d/c'); + + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); + expect(response.status).toEqual(404); + expect(response.text).toMatch(/name/); + }); + }); + + describe('DELETE /entities/by-uid/:uid', () => { + it('can remove', async () => { + entitiesCatalog.removeEntityByUid.mockResolvedValue(undefined); + + const response = await request(app).delete('/entities/by-uid/apa'); + + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa'); + expect(response.status).toEqual(204); + }); + + it('responds with a 404 for missing entities', async () => { + entitiesCatalog.removeEntityByUid.mockRejectedValue( + new NotFoundError('nope'), + ); + + const response = await request(app).delete('/entities/by-uid/apa'); + + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa'); + expect(response.status).toEqual(404); + }); + }); + + describe('GET /locations', () => { + it('happy path: lists locations', async () => { + const locations: Location[] = [ + { id: 'foo', type: 'url', target: 'example.com' }, + ]; + locationService.listLocations.mockResolvedValueOnce(locations); + + const response = await request(app).get('/locations'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { data: { id: 'foo', target: 'example.com', type: 'url' } }, + ]); + }); + }); + + describe('POST /locations', () => { + it('rejects malformed locations', async () => { + const spec = { + typez: 'b', + target: 'c', + } as unknown as LocationSpec; + + const response = await request(app).post('/locations').send(spec); + + expect(locationService.createLocation).not.toHaveBeenCalled(); + expect(response.status).toEqual(400); + }); + + it('passes the body down', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + locationService.createLocation.mockResolvedValue({ + location: { id: 'a', ...spec }, + entities: [], + }); + + const response = await request(app).post('/locations').send(spec); + + expect(locationService.createLocation).toHaveBeenCalledTimes(1); + expect(locationService.createLocation).toHaveBeenCalledWith(spec, false); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); + }); + + it('supports dry run', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + locationService.createLocation.mockResolvedValue({ + location: { id: 'a', ...spec }, + entities: [], + }); + + const response = await request(app) + .post('/locations?dryRun=true') + .send(spec); + + expect(locationService.createLocation).toHaveBeenCalledTimes(1); + expect(locationService.createLocation).toHaveBeenCalledWith(spec, true); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); + }); + }); +}); + +describe('createNextRouter readonly enabled', () => { + let entitiesCatalog: jest.Mocked; + let app: express.Express; + let locationService: jest.Mocked; + + beforeAll(async () => { + entitiesCatalog = { + entities: jest.fn(), + removeEntityByUid: jest.fn(), + batchAddOrUpdateEntities: jest.fn(), + }; + locationService = { + getLocation: jest.fn(), + createLocation: jest.fn(), + listLocations: jest.fn(), + deleteLocation: jest.fn(), + }; + const router = await createNextRouter({ + entitiesCatalog, + locationService, + logger: getVoidLogger(), + config: new ConfigReader({ + catalog: { + readonly: true, + }, + }), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /entities', () => { + it('happy path: lists entities', async () => { + const entities: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [entities[0]], + pageInfo: { hasNextPage: false }, + }); + + const response = await request(app).get('/entities'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(entities); + }); + }); + + describe('DELETE /entities/by-uid/:uid', () => { + // this delete is allowed as there is no other way to remove entities + it('is allowed', async () => { + const response = await request(app).delete('/entities/by-uid/apa'); + + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa'); + expect(response.status).toEqual(204); + }); + }); + + describe('GET /locations', () => { + it('happy path: lists locations', async () => { + const locations: Location[] = [ + { id: 'foo', type: 'url', target: 'example.com' }, + ]; + locationService.listLocations.mockResolvedValueOnce(locations); + + const response = await request(app).get('/locations'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { data: { id: 'foo', target: 'example.com', type: 'url' } }, + ]); + }); + }); + + describe('POST /locations', () => { + it('is not allowed', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + const response = await request(app).post('/locations').send(spec); + + expect(locationService.createLocation).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + expect(response.text).toMatch(/not allowed in readonly/); + }); + + it('supports dry run', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + locationService.createLocation.mockResolvedValue({ + location: { id: 'a', ...spec }, + entities: [], + }); + + const response = await request(app) + .post('/locations?dryRun=true') + .send(spec); + + expect(locationService.createLocation).toHaveBeenCalledTimes(1); + expect(locationService.createLocation).toHaveBeenCalledWith(spec, true); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index fa058c4138..4131b5835b 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -21,7 +21,7 @@ export interface LocationService { createLocation( spec: LocationSpec, dryRun: boolean, - ): Promise<{ location: Location; entities: Entity[] }>; + ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }>; listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 8e8b91d013..fe23e15dd3 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -25,12 +25,14 @@ import { LocationResponse } from '../catalog/types'; import { HigherOrderOperation } from '../ingestion/types'; import { createRouter } from './router'; import { basicEntityFilter } from './request'; +import { RefreshService } from '../next'; describe('createRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; let locationsCatalog: jest.Mocked; let higherOrderOperation: jest.Mocked; let app: express.Express; + let refreshService: RefreshService; beforeAll(async () => { entitiesCatalog = { @@ -51,11 +53,13 @@ describe('createRouter readonly disabled', () => { addLocation: jest.fn(), refreshAllLocations: jest.fn(), }; + refreshService = { refresh: jest.fn() }; const router = await createRouter({ entitiesCatalog, locationsCatalog, higherOrderOperation, logger: getVoidLogger(), + refreshService, config: new ConfigReader(undefined), }); app = express().use(router); @@ -65,6 +69,18 @@ describe('createRouter readonly disabled', () => { jest.resetAllMocks(); }); + describe('POST /refresh', () => { + it('refreshes an entity using the refresh service', async () => { + const response = await request(app) + .post('/refresh') + .set('Content-Type', 'application/json') + .send({ entityRef: 'Component/default:foo' }); + expect(response.status).toBe(200); + expect(refreshService.refresh).toHaveBeenCalledWith({ + entityRef: 'Component/default:foo', + }); + }); + }); describe('GET /entities', () => { it('happy path: lists entities', async () => { const entities: Entity[] = [ diff --git a/plugins/catalog-graph/.eslintrc.js b/plugins/catalog-graph/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-graph/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md new file mode 100644 index 0000000000..0e06e15f4c --- /dev/null +++ b/plugins/catalog-graph/README.md @@ -0,0 +1,83 @@ +# catalog-graph + +Welcome to the catalog graph plugin! The catalog graph visualizes the relations +between entities, like ownership, grouping or API relationships. + +The plugin comes with these features: + +- `EntityCatalogGraphCard`: + A card that displays the directly related entities to the current entity. + This card is for use on the entity page. + The card can be customized, for example filtering for specific relations. + +- `CatalogGraphPage`: + A standalone page that can be added to your application providing a viewer for your entities and their relations. + The viewer can be used to navigate through the entities and filter for specific relations. + You can access it from the `EntityCatalogGraphCard`. + +- `EntityRelationsGraph`: + A react component that can be used to build own customized entity relation graphs. + +## Usage + +To use the catalog graph plugin, you have to add some things to your Backstage app: + +1. Add a dependency to your `packages/app/package.json`, run: + ```sh + yarn add @backstage/plugin-catalog-graph + ``` +2. Add the `CatalogGraphPage` to your `packages/app/src/App.tsx`: + + ```typescript + + … + } />… + + ``` + + You can configure the page to open with some initial filters: + + ```typescript + + } + /> + ``` + +3. Bind the external routes of the `catalogGraphPlugin` in your `packages/app/src/App.tsx`: + + ```typescript + bindRoutes({ bind }) { + … + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); + … + } + ``` + +4. Add `EntityCatalogGraphCard` to any entity page that you want in your `packages/app/src/components/catalog/EntityPage.tsx`: + + ```typescript + + + + ``` diff --git a/plugins/catalog-graph/api-report.md b/plugins/catalog-graph/api-report.md new file mode 100644 index 0000000000..fd9999dc4a --- /dev/null +++ b/plugins/catalog-graph/api-report.md @@ -0,0 +1,139 @@ +## API Report File for "@backstage/plugin-catalog-graph" + +> 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 { DependencyGraphTypes } from '@backstage/core-components'; +import { EntityName } from '@backstage/catalog-model'; +import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { InfoCardVariants } from '@backstage/core-components'; +import { MouseEvent as MouseEvent_2 } from 'react'; +import { MouseEventHandler } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public +export const ALL_RELATION_PAIRS: RelationPairs; + +// @public +export const CatalogGraphPage: ({ + relationPairs, + initialState, +}: { + relationPairs?: RelationPairs | undefined; + initialState?: + | { + selectedRelations?: string[] | undefined; + selectedKinds?: string[] | undefined; + rootEntityRefs?: string[] | undefined; + maxDepth?: number | undefined; + unidirectional?: boolean | undefined; + mergeRelations?: boolean | undefined; + direction?: Direction | undefined; + showFilters?: boolean | undefined; + } + | undefined; +}) => JSX.Element; + +// @public +export const catalogGraphPlugin: BackstagePlugin< + { + catalogGraph: RouteRef; + }, + { + catalogEntity: ExternalRouteRef< + { + name: string; + kind: string; + namespace: string; + }, + false + >; + } +>; + +// @public +export const catalogGraphRouteRef: RouteRef; + +// @public +export enum Direction { + BOTTOM_TOP = 'BT', + LEFT_RIGHT = 'LR', + RIGHT_LEFT = 'RL', + TOP_BOTTOM = 'TB', +} + +// @public +export const EntityCatalogGraphCard: ({ + variant, + relationPairs, + maxDepth, + unidirectional, + mergeRelations, + kinds, + relations, + direction, + height, + title, +}: { + variant?: InfoCardVariants | undefined; + relationPairs?: RelationPairs | undefined; + maxDepth?: number | undefined; + unidirectional?: boolean | undefined; + mergeRelations?: boolean | undefined; + kinds?: string[] | undefined; + relations?: string[] | undefined; + direction?: Direction | undefined; + height?: number | undefined; + title?: string | undefined; +}) => JSX.Element; + +// @public +export type EntityEdge = DependencyGraphTypes.DependencyEdge<{ + relations: string[]; + label: 'visible'; +}>; + +// @public +export type EntityNode = DependencyGraphTypes.DependencyNode<{ + name: string; + kind?: string; + title?: string; + namespace: string; + focused?: boolean; + color?: 'primary' | 'secondary' | 'default'; + onClick?: MouseEventHandler; +}>; + +// @public +export const EntityRelationsGraph: ({ + rootEntityNames, + maxDepth, + unidirectional, + mergeRelations, + kinds, + relations, + direction, + onNodeClick, + relationPairs, + className, +}: { + rootEntityNames: EntityName | EntityName[]; + maxDepth?: number | undefined; + unidirectional?: boolean | undefined; + mergeRelations?: boolean | undefined; + kinds?: string[] | undefined; + relations?: string[] | undefined; + direction?: Direction | undefined; + onNodeClick?: + | ((value: EntityNode, event: MouseEvent_2) => void) + | undefined; + relationPairs?: RelationPairs | undefined; + className?: string | undefined; +}) => JSX.Element; + +// @public +export type RelationPairs = [string, string][]; +``` diff --git a/plugins/catalog-graph/dev/index.tsx b/plugins/catalog-graph/dev/index.tsx new file mode 100644 index 0000000000..ed14e5fce9 --- /dev/null +++ b/plugins/catalog-graph/dev/index.tsx @@ -0,0 +1,167 @@ +/* + * 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 { CatalogListResponse } from '@backstage/catalog-client'; +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_HAS_PART, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PART_OF, + RELATION_PROVIDES_API, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { Content, Header, Page } from '@backstage/core-components'; +import { createDevApp } from '@backstage/dev-utils'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { + CatalogGraphPage, + catalogGraphPlugin, + EntityCatalogGraphCard, +} from '../src'; + +type DataRelation = [string, string, string]; +type DataEntity = [string, string, DataRelation[]]; + +const entities = ( + [ + [ + 'Domain', + 'wayback', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_HAS_PART, 'System', 'wayback'], + ], + ], + [ + 'System', + 'wayback', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'Domain', 'wayback'], + [RELATION_HAS_PART, 'Component', 'wayback-archive'], + [RELATION_HAS_PART, 'Component', 'wayback-search'], + [RELATION_HAS_PART, 'API', 'wayback-api'], + ], + ], + [ + 'Component', + 'wayback-archive', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_PROVIDES_API, 'API', 'wayback-api'], + ], + ], + [ + 'Component', + 'wayback-search', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_CONSUMES_API, 'API', 'wayback-api'], + ], + ], + [ + 'API', + 'wayback-api', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_API_PROVIDED_BY, 'Component', 'wayback-archive'], + [RELATION_API_CONSUMED_BY, 'Component', 'wayback-search'], + ], + ], + [ + 'Group', + 'team-a', + [ + [RELATION_OWNER_OF, 'Component', 'wayback-archive'], + [RELATION_OWNER_OF, 'Component', 'wayback-search'], + [RELATION_OWNER_OF, 'API', 'wayback-api'], + [RELATION_OWNER_OF, 'Domain', 'wayback'], + [RELATION_OWNER_OF, 'System', 'wayback'], + ], + ], + ] as DataEntity[] +).reduce((o, d) => { + const [kind, name, relations] = d; + + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { + name, + }, + relations: relations.map(([type, k, n]) => ({ + target: { kind: k, name: n, namespace: ENTITY_DEFAULT_NAMESPACE }, + type, + })), + }; + const entityRef = stringifyEntityRef(entity); + o[entityRef] = entity; + return o; +}, {} as { [entityRef: string]: Entity }); + +createDevApp() + .registerPlugin(catalogGraphPlugin) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory() { + return { + async getEntityByName(name: EntityName): Promise { + return entities[stringifyEntityRef(name)]; + }, + async getEntities(): Promise> { + return { items: Object.values(entities) }; + }, + } as Partial as unknown as CatalogApi; + }, + }) + .addPage({ + title: 'Graph Card', + element: ( + +
+ + + + + + + + + + + ), + }) + .addPage({ + element: , + }) + .render(); diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json new file mode 100644 index 0000000000..6073d6be91 --- /dev/null +++ b/plugins/catalog-graph/package.json @@ -0,0 +1,60 @@ +{ + "name": "@backstage/plugin-catalog-graph", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-client": "^0.3.18", + "@backstage/catalog-model": "^0.9.2", + "@backstage/core-components": "^0.4.2", + "@backstage/core-plugin-api": "^0.1.8", + "@backstage/plugin-catalog-react": "^0.4.5", + "@backstage/theme": "^0.2.10", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "@types/react": "*", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4", + "classnames": "^2.3.1", + "react-router": "6.0.0-beta.0", + "qs": "^6.9.4", + "lodash": "^4.17.15", + "p-limit": "^3.1.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.12", + "@backstage/dev-utils": "^0.2.9", + "@backstage/test-utils": "^0.1.17", + "@backstage/core-app-api": "^0.1.13", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@testing-library/react-hooks": "^3.4.2", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.29.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx new file mode 100644 index 0000000000..8207517e03 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes'; +import { CatalogGraphCard } from './CatalogGraphCard'; + +describe('', () => { + let entity: Entity; + let wrapper: JSX.Element; + let catalog: jest.Mocked; + let apis: ApiRegistry; + + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + beforeEach(() => { + entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + catalog = { + getEntities: jest.fn(), + getEntityByName: jest.fn(async _ => ({ ...entity, relations: [] })), + removeEntityByUid: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + addLocation: jest.fn(), + removeLocationById: jest.fn(), + refreshEntity: jest.fn(), + }; + apis = ApiRegistry.with(catalogApiRef, catalog); + + wrapper = ( + + + + + + ); + }); + + test('renders without exploding', async () => { + const { findByText, findAllByTestId } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }); + + expect(await findByText('b:d/c')).toBeInTheDocument(); + expect(await findAllByTestId('node')).toHaveLength(1); + expect(catalog.getEntityByName).toBeCalledTimes(1); + }); + + test('renders with custom title', async () => { + const { findByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }, + ); + + expect(await findByText('Custom Title')).toBeInTheDocument(); + }); + + test('renders link to standalone viewer', async () => { + const { findByText, getByText } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }); + + expect(await findByText('b:d/c')).toBeInTheDocument(); + const button = getByText('View graph'); + expect(button).toBeInTheDocument(); + expect(button.closest('a')).toHaveAttribute( + 'href', + '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc', + ); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx new file mode 100644 index 0000000000..abb10d4008 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.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 { + getEntityName, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { makeStyles, Theme } from '@material-ui/core'; +import qs from 'qs'; +import React, { MouseEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes'; +import { + Direction, + EntityNode, + EntityRelationsGraph, + RelationPairs, + ALL_RELATION_PAIRS, +} from '../EntityRelationsGraph'; + +const useStyles = makeStyles({ + card: ({ height }) => ({ + display: 'flex', + flexDirection: 'column', + maxHeight: height, + minHeight: height, + }), + graph: { + flex: 1, + minHeight: 0, + }, +}); + +export const CatalogGraphCard = ({ + variant = 'gridItem', + relationPairs = ALL_RELATION_PAIRS, + maxDepth = 1, + unidirectional = true, + mergeRelations = true, + kinds, + relations, + direction = Direction.LEFT_RIGHT, + height, + title = 'Relations', +}: { + variant?: InfoCardVariants; + relationPairs?: RelationPairs; + maxDepth?: number; + unidirectional?: boolean; + mergeRelations?: boolean; + kinds?: string[]; + relations?: string[]; + direction?: Direction; + height?: number; + title?: string; +}) => { + const { entity } = useEntity(); + const entityName = getEntityName(entity); + const catalogEntityRoute = useRouteRef(catalogEntityRouteRef); + const catalogGraphRoute = useRouteRef(catalogGraphRouteRef); + const navigate = useNavigate(); + const classes = useStyles({ height }); + + const onNodeClick = useCallback( + (node: EntityNode, _: MouseEvent) => { + const nodeEntityName = parseEntityRef(node.id); + const path = catalogEntityRoute({ + kind: nodeEntityName.kind.toLocaleLowerCase('en-US'), + namespace: nodeEntityName.namespace.toLocaleLowerCase('en-US'), + name: nodeEntityName.name, + }); + navigate(path); + }, + [catalogEntityRoute, navigate], + ); + + const catalogGraphParams = qs.stringify( + { rootEntityRefs: [stringifyEntityRef(entity)] }, + { arrayFormat: 'brackets', addQueryPrefix: true }, + ); + const catalogGraphUrl = `${catalogGraphRoute()}${catalogGraphParams}`; + + return ( + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts b/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts new file mode 100644 index 0000000000..725f90331f --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/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 { CatalogGraphCard } from './CatalogGraphCard'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx new file mode 100644 index 0000000000..c291faadbd --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -0,0 +1,169 @@ +/* + * 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 { RELATION_HAS_PART, RELATION_PART_OF } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { catalogEntityRouteRef } from '../../routes'; +import { CatalogGraphPage } from './CatalogGraphPage'; + +const navigate = jest.fn(); + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useNavigate: () => navigate, +})); + +describe('', () => { + let wrapper: JSX.Element; + let catalog: jest.Mocked; + + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + beforeEach(() => { + const entityC = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'b', + namespace: 'd', + name: 'e', + }, + }, + ], + }; + const entityE = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'e', + namespace: 'd', + }, + relations: [ + { + type: RELATION_HAS_PART, + target: { + kind: 'b', + namespace: 'd', + name: 'c', + }, + }, + ], + }; + catalog = { + getEntities: jest.fn(), + getEntityByName: jest.fn(async n => (n.name === 'e' ? entityE : entityC)), + removeEntityByUid: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + addLocation: jest.fn(), + removeLocationById: jest.fn(), + refreshEntity: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalog); + + wrapper = ( + + + + ); + }); + + afterEach(() => jest.resetAllMocks()); + + test('should render without exploding', async () => { + const { getByText, findByText, findAllByTestId } = await renderInTestApp( + wrapper, + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }, + ); + + expect(getByText('Catalog Graph')).toBeInTheDocument(); + expect(await findByText('b:d/c')).toBeInTheDocument(); + expect(await findByText('b:d/e')).toBeInTheDocument(); + expect(await findAllByTestId('node')).toHaveLength(2); + expect(catalog.getEntityByName).toBeCalledTimes(2); + }); + + test('should toggle filters', async () => { + const { getByText, queryByText } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }); + + expect(queryByText('Max Depth')).toBeNull(); + + userEvent.click(getByText('Filters')); + + expect(getByText('Max Depth')).toBeInTheDocument(); + }); + + test('should select other entity', async () => { + const { getByText, findByText, findAllByTestId } = await renderInTestApp( + wrapper, + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }, + ); + + expect(await findAllByTestId('node')).toHaveLength(2); + + userEvent.click(getByText('b:d/e')); + + expect(await findByText('hasPart')).toBeInTheDocument(); + }); + + test('should navigate to entity', async () => { + const { getByText, findAllByTestId } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }); + + expect(await findAllByTestId('node')).toHaveLength(2); + + userEvent.click(getByText('b:d/e'), { shiftKey: true }); + + expect(navigate).toBeCalledWith('/entity/{kind}/{namespace}/{name}'); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx new file mode 100644 index 0000000000..0ad50a8174 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -0,0 +1,241 @@ +/* + * 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 { parseEntityRef } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + Header, + Page, + SupportButton, +} from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { formatEntityRefTitle } from '@backstage/plugin-catalog-react'; +import { Grid, makeStyles, Paper, Typography } from '@material-ui/core'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; +import { ToggleButton } from '@material-ui/lab'; +import React, { MouseEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { catalogEntityRouteRef } from '../../routes'; +import { + Direction, + EntityNode, + EntityRelationsGraph, + RelationPairs, + ALL_RELATION_PAIRS, +} from '../EntityRelationsGraph'; +import { DirectionFilter } from './DirectionFilter'; +import { MaxDepthFilter } from './MaxDepthFilter'; +import { SelectedKindsFilter } from './SelectedKindsFilter'; +import { SelectedRelationsFilter } from './SelectedRelationsFilter'; +import { SwitchFilter } from './SwitchFilter'; +import { useCatalogGraphPage } from './useCatalogGraphPage'; + +const useStyles = makeStyles(theme => ({ + content: { + minHeight: 0, + }, + container: { + height: '100%', + maxHeight: '100%', + minHeight: 0, + }, + fullHeight: { + maxHeight: '100%', + display: 'flex', + minHeight: 0, + }, + graphWrapper: { + position: 'relative', + flex: 1, + minHeight: 0, + display: 'flex', + }, + graph: { + flex: 1, + minHeight: 0, + }, + legend: { + position: 'absolute', + bottom: 0, + right: 0, + padding: theme.spacing(1), + '& .icon': { + verticalAlign: 'bottom', + }, + }, + filters: { + display: 'grid', + gridGap: theme.spacing(1), + gridAutoRows: 'auto', + [theme.breakpoints.up('lg')]: { + display: 'block', + }, + [theme.breakpoints.only('md')]: { + gridTemplateColumns: 'repeat(3, 1fr)', + }, + [theme.breakpoints.only('sm')]: { + gridTemplateColumns: 'repeat(2, 1fr)', + }, + [theme.breakpoints.down('xs')]: { + gridTemplateColumns: 'repeat(1, 1fr)', + }, + }, +})); + +export const CatalogGraphPage = ({ + relationPairs = ALL_RELATION_PAIRS, + initialState, +}: { + relationPairs?: RelationPairs; + initialState?: { + selectedRelations?: string[]; + selectedKinds?: string[]; + rootEntityRefs?: string[]; + maxDepth?: number; + unidirectional?: boolean; + mergeRelations?: boolean; + direction?: Direction; + showFilters?: boolean; + }; +}) => { + const navigate = useNavigate(); + const classes = useStyles(); + const catalogEntityRoute = useRouteRef(catalogEntityRouteRef); + const { + maxDepth, + setMaxDepth, + selectedKinds, + setSelectedKinds, + selectedRelations, + setSelectedRelations, + unidirectional, + setUnidirectional, + mergeRelations, + setMergeRelations, + direction, + setDirection, + rootEntityNames, + setRootEntityNames, + showFilters, + toggleShowFilters, + } = useCatalogGraphPage({ initialState }); + const onNodeClick = useCallback( + (node: EntityNode, event: MouseEvent) => { + const nodeEntityName = parseEntityRef(node.id); + + if (event.shiftKey) { + const path = catalogEntityRoute({ + kind: nodeEntityName.kind.toLocaleLowerCase('en-US'), + namespace: nodeEntityName.namespace.toLocaleLowerCase('en-US'), + name: nodeEntityName.name, + }); + navigate(path); + } else { + setRootEntityNames([nodeEntityName]); + } + }, + [catalogEntityRoute, navigate, setRootEntityNames], + ); + + return ( + +
formatEntityRefTitle(e)).join(', ')} + /> + + toggleShowFilters()} + > + Filters + + } + > + + Start tracking your component in by adding it to the software + catalog. + + + + {showFilters && ( + + + + + + + + + )} + + + + Use pinch & zoom to move + around the diagram. Click to change active node, shift click to + navigate to entity. + + 0 + ? selectedKinds + : undefined + } + relations={ + selectedRelations && selectedRelations.length > 0 + ? selectedRelations + : undefined + } + mergeRelations={mergeRelations} + unidirectional={unidirectional} + onNodeClick={onNodeClick} + direction={direction} + relationPairs={relationPairs} + className={classes.graph} + /> + + + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx new file mode 100644 index 0000000000..a3ee8d7e51 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx @@ -0,0 +1,47 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Direction } from '../EntityRelationsGraph'; +import { DirectionFilter } from './DirectionFilter'; + +describe('', () => { + test('should display current value', () => { + const { getByText } = render( + {}} />, + ); + + expect(getByText('Left to right')).toBeInTheDocument(); + }); + + test('should select direction', async () => { + const onChange = jest.fn(); + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('Right to left')).toBeInTheDocument(); + + userEvent.click(getByTestId('select')); + userEvent.click(getByText('Top to bottom')); + + await waitFor(() => { + expect(getByText('Top to bottom')).toBeInTheDocument(); + expect(onChange).toBeCalledWith(Direction.TOP_BOTTOM); + }); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx new file mode 100644 index 0000000000..815ed1f292 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -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 { Select } from '@backstage/core-components'; +import { Box } from '@material-ui/core'; +import React, { useCallback } from 'react'; +import { Direction } from '../EntityRelationsGraph'; + +const DIRECTION_DISPLAY_NAMES = { + [Direction.LEFT_RIGHT]: 'Left to right', + [Direction.RIGHT_LEFT]: 'Right to left', + [Direction.TOP_BOTTOM]: 'Top to bottom', + [Direction.BOTTOM_TOP]: 'Bottom to top', +}; + +export type Props = { + value: Direction; + onChange: (value: Direction) => void; +}; + +export const DirectionFilter = ({ value, onChange }: Props) => { + const handleChange = useCallback(v => onChange(v as Direction), [onChange]); + + return ( + +