diff --git a/.changeset/blue-lions-worry.md b/.changeset/blue-lions-worry.md new file mode 100644 index 0000000000..911e1507b2 --- /dev/null +++ b/.changeset/blue-lions-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Fix bug retrieving current theme diff --git a/.changeset/green-beds-sell.md b/.changeset/green-beds-sell.md new file mode 100644 index 0000000000..44b7adcc87 --- /dev/null +++ b/.changeset/green-beds-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated transform of `.esm.js` files to be able to handle dynamic imports. diff --git a/.changeset/itchy-camels-grin.md b/.changeset/itchy-camels-grin.md new file mode 100644 index 0000000000..eac3c6a102 --- /dev/null +++ b/.changeset/itchy-camels-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-splunk-on-call': patch +--- + +Added splunk-on-call plugin. diff --git a/.changeset/loud-owls-beam.md b/.changeset/loud-owls-beam.md new file mode 100644 index 0000000000..764bfdf72a --- /dev/null +++ b/.changeset/loud-owls-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added githubApp authentication to the scaffolder-backend plugin diff --git a/.changeset/metal-spoons-change.md b/.changeset/metal-spoons-change.md new file mode 100644 index 0000000000..41828d08ae --- /dev/null +++ b/.changeset/metal-spoons-change.md @@ -0,0 +1,59 @@ +--- +'@backstage/create-app': patch +--- + +Updated docker build to use `backstage-cli backend:bundle` instead of `backstage-cli backend:build-image`. + +To apply this change to an existing application, change the following in `packages/backend/package.json`: + +```diff +- "build": "backstage-cli backend:build", +- "build-image": "backstage-cli backend:build-image --build --tag backstage", ++ "build": "backstage-cli backend:bundle", ++ "build-image": "docker build ../.. -f Dockerfile --tag backstage", +``` + +Note that the backend build is switched to `backend:bundle`, and the `build-image` script simply calls `docker build`. This means the `build-image` script no longer builds all packages, so you have to run `yarn build` in the root first. + +In order to work with the new build method, the `Dockerfile` at `packages/backend/Dockerfile` has been updated with the following contents: + +```dockerfile +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` + +FROM node:14-buster-slim + +WORKDIR /app + +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ + +RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +Note that the base image has been switched from `node:14-buster` to `node:14-buster-slim`, significantly reducing the image size. This is enabled by the removal of the `nodegit` dependency, so if you are still using this in your project you will have to stick with the `node:14-buster` base image. + +A `.dockerignore` file has been added to the root of the repo as well, in order to keep the docker context upload small. It lives in the root of the repo with the following contents: + +```gitignore +.git +node_modules +packages +!packages/backend/dist +plugins +``` diff --git a/.changeset/new-peaches-melt.md b/.changeset/new-peaches-melt.md new file mode 100644 index 0000000000..aff00e0fa0 --- /dev/null +++ b/.changeset/new-peaches-melt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Minor typo in migration diff --git a/.changeset/pink-coins-sniff.md b/.changeset/pink-coins-sniff.md new file mode 100644 index 0000000000..b9714f116a --- /dev/null +++ b/.changeset/pink-coins-sniff.md @@ -0,0 +1,16 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-react': patch +--- + +Introduce new cards to `@backstage/plugin-catalog` that can be added to entity pages: + +- `EntityHasSystemsCard` to display systems of a domain. +- `EntityHasComponentsCard` to display components of a system. +- `EntityHasSubcomponentsCard` to display subcomponents of a subcomponent. +- In addition, `EntityHasApisCard` to display APIs of a system is added to `@backstage/plugin-api-docs`. + +`@backstage/plugin-catalog-react` now provides an `EntityTable` to build own cards for entities. +The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, +`EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. diff --git a/.changeset/quick-ways-develop.md b/.changeset/quick-ways-develop.md new file mode 100644 index 0000000000..f3c8087bff --- /dev/null +++ b/.changeset/quick-ways-develop.md @@ -0,0 +1,13 @@ +--- +'@backstage/core': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-org': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-sonarqube': patch +--- + +Use a more strict type for `variant` of cards. diff --git a/.changeset/short-pots-report.md b/.changeset/short-pots-report.md new file mode 100644 index 0000000000..4323a957f3 --- /dev/null +++ b/.changeset/short-pots-report.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-org': patch +--- + +- Fixes padding in `MembersListCard` +- Fixes email icon size in `GroupProfileCard` +- Uniform sizing across `GroupProfileCard` and `UserProfileCard` diff --git a/.changeset/sour-shoes-perform.md b/.changeset/sour-shoes-perform.md new file mode 100644 index 0000000000..e2361cb815 --- /dev/null +++ b/.changeset/sour-shoes-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-pagerduty': minor +--- + +Improved the UI of the pagerduty plugin, and added a standalone TriggerButton diff --git a/.changeset/spicy-pants-teach.md b/.changeset/spicy-pants-teach.md new file mode 100644 index 0000000000..8e16ae089e --- /dev/null +++ b/.changeset/spicy-pants-teach.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Expose `useRelatedEntities` from `@backstage/plugin-catalog-react` to retrieve +entities references via relations from the API. diff --git a/.changeset/techdocs-improve-error-reporting.md b/.changeset/techdocs-improve-error-reporting.md new file mode 100644 index 0000000000..fc4e550afd --- /dev/null +++ b/.changeset/techdocs-improve-error-reporting.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Improved error reporting in AzureBlobStorage to surface errors when fetching metadata and uploading files fails. diff --git a/.changeset/techdocs-metal-turkeys-sleep.md b/.changeset/techdocs-metal-turkeys-sleep.md new file mode 100644 index 0000000000..79cf11ce53 --- /dev/null +++ b/.changeset/techdocs-metal-turkeys-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Pass user and group ID when invoking docker container. When TechDocs invokes Docker, docker could be run as a `root` user which results in generation of files by applications run by non-root user (e.g. TechDocs) will not have access to modify. This PR passes in current user and group ID to docker so that the file permissions of the generated files and folders are correct. diff --git a/.changeset/violet-maps-occur.md b/.changeset/violet-maps-occur.md new file mode 100644 index 0000000000..112095d257 --- /dev/null +++ b/.changeset/violet-maps-occur.md @@ -0,0 +1,8 @@ +--- +'@backstage/core': patch +--- + +Add support for custom empty state of `Table` components. + +You can now optionally pass `emptyContent` to `Table` that is displayed +if the table has now rows. diff --git a/.changeset/wicked-forks-sleep.md b/.changeset/wicked-forks-sleep.md index 985d184f75..8f7f034094 100644 --- a/.changeset/wicked-forks-sleep.md +++ b/.changeset/wicked-forks-sleep.md @@ -1,13 +1,6 @@ --- -'@backstage/cli': minor -'@backstage/create-app': minor +'@backstage/cli': patch +'@backstage/create-app': patch --- -Upgrading to lerna@4.0.0. This changes the interface for importing, and because we can't run multiple versions of lerna, this is a breaking change. - -You'll need to update your root `package.json` like the following: - -```diff -- "lerna": "^3.20.2", -+ "lerna": "^4.0.0", -``` +Upgrading to lerna@4.0.0. diff --git a/.dockerignore b/.dockerignore index 7f7dee96c5..e34ae2c37d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ .git +docs +cypress +microsite node_modules -packages/*/node_modules -plugins/*/node_modules -plugins/*/dist +packages +!packages/backend/dist +plugins diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 519216ecd8..c5a0b02529 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -213,6 +213,7 @@ Sneha Snyk sourcemaps sparklines +Splunk Spotifiers spotify Spotify @@ -221,6 +222,8 @@ squidfunk src stdout stefanalund +subcomponent +subcomponents subkey subtree superfences diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d163a42954..ddc5449cd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) So...feel ready to jump in? Let's do this. 👏🏻💯 -Start by reading our [Getting Started](https://backstage.io/docs/getting-started/) page. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). +Start by reading our [Getting Started for Contributors](https://backstage.io/docs/getting-started/contributors) page to get yourself setup with a fresh copy of Backstage ready for your contributions. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). ## Coding Guidelines diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 5b5656f1b1..308544e0f1 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -181,7 +181,25 @@ The `IgnoringErrorApi` would then be imported in the app, and wired up like this: ```ts -builder.add(errorApiRef, new IgnoringErrorApi()); +const app = createApp({ + apis: [ + /* ApiFactories */ + createApiFactory(errorApiRef, new IgnoringErrorApi()), + + // OR + // If your API has dependencies, you use the object form + createApiFactory({ + api: errorApiRef, + deps: { configApi: configApiRef }, + factory({ configApi }) { + return new IgnoringErrorApi({ + reportingUrl: configApi.getString('error.reportingUrl'), + }); + }, + }), + ], + // ... other options +}); ``` Note that the above line will cause an error if `IgnoreErrorApi` does not fully diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 726f2a922c..ea72f7e050 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -206,15 +206,15 @@ The following is an example of a `Dockerfile` that can be used to package the output of `backstage-cli backend:bundle` into an image: ```Dockerfile -FROM node:14-buster +FROM node:14-buster-slim WORKDIR /app ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ -RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" +RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ -CMD node packages/backend +CMD ["node", "packages/backend"] ``` ```text diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index de2d505c87..32c44e9595 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -142,6 +142,8 @@ variables** You should follow the [AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). +TechDocs needs access to read files and metadata of the S3 bucket. + If the environment variables - `AWS_ACCESS_KEY_ID` @@ -149,15 +151,21 @@ If the environment variables - `AWS_REGION` are set and can be used to access the bucket you created in step 2, they will be -used by the AWS SDK v3 Node.js client for authentication. -[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) +used by the AWS SDK V2 Node.js client for authentication. +[Refer to the official documentation for loading credentials in Node.js from environment variables](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html). If the environment variables are missing, the AWS SDK tries to read the `~/.aws/credentials` file for credentials. -[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html) +[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html) -Note that the region of the bucket has to be set for the AWS SDK to work. -[See this](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html). +If you are using Amazon EC2 instance to deploy Backstage, you do not need to +obtain the access keys separately. They can be made available in the environment +automatically by defining appropriate IAM role with access to the bucket. Read +more in +[official AWS documentation for using IAM roles.](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html#use-roles). + +The AWS Region of the bucket is optional since TechDocs uses AWS SDK V2 and not +V3. **3b. Authentication using app-config.yaml** @@ -181,13 +189,7 @@ techdocs: ``` Refer to the -[official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html). - -Note: If you are using Amazon EC2 instance to deploy Backstage, you do not need -to obtain the access keys separately. They can be made available in the -environment automatically by defining appropriate IAM role with access to the -bucket. Read more -[here](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html#use-roles). +[official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html). **4. That's it!** diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/contributors.md similarity index 71% rename from docs/getting-started/development-environment.md rename to docs/getting-started/contributors.md index 01fd7bdc24..a0c3f3dc4c 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/contributors.md @@ -1,6 +1,6 @@ --- -id: development-environment -title: Development Environment +id: contributors +title: Contributors # prettier-ignore description: Documentation on how to get set up for doing development on the Backstage repository --- @@ -10,6 +10,21 @@ repository. ## Cloning the Repository +Ok. So you're gonna want some code right? Go ahead and fork the repository into +your own GitHub account and clone that code to your local machine or you can +grab the one for the origin like so: + +```bash +git clone git@github.com/backstage/backstage --depth 1 +``` + +If you cloned a fork, you can add the upstream dependency like so: + +```bash +git remote add upstream git@github.com:backstage/backstage +git pull upstream master +``` + After you have cloned the Backstage repository, you should run the following commands once to set things up for development: @@ -25,9 +40,11 @@ Open a terminal window and start the web app by using the following command from the project root. Make sure you have run the above mentioned commands first. ```bash -$ yarn start +$ yarn dev ``` +This is going to start two things, the frontend (:3000) and the backend (:7000). + This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. @@ -38,12 +55,27 @@ setting an environment variable `PORT` on your local machine. e.g. Once successfully started, you should see the following message in your terminal window: +```sh +$ concurrently "yarn start" "yarn start-backend" +$ yarn workspace example-app start +$ yarn workspace example-backend start +$ backstage-cli app:serve +$ backstage-cli backend:dev +[0] Loaded config from app-config.yaml +[1] Build succeeded +[0] ℹ 「wds」: Project is running at http://localhost:3000/ +[0] ℹ 「wds」: webpack output is served from / +[0] ℹ 「wds」: Content not from webpack is served from $BACKSTAGE_DIR/packages/app/public +[0] ℹ 「wds」: 404s will fallback to /index.html +[0] ℹ 「wdm」: wait until bundle finished: / +[1] 2021-02-12T20:58:17.614Z backstage info Loaded config from app-config.yaml ``` -You can now view example-app in the browser. - Local: http://localhost:8080 - On Your Network: http://192.168.1.224:8080 -``` +You'll see how you get both logs for the frontend `webpack-dev-server` which +serves the react app ([0]) and the backend ([1]); + +Visit http://localhost:3000 and you should see the bleeding edge of Backstage +ready for contributions! ## Editor diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 0663d3faa1..9abb68d21b 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -177,20 +177,6 @@ the root directory: yarn workspace backend start ``` +Now you're free to hack away on your own Backstage installation! + ### Troubleshooting - -#### Cannot find module - -You may encounter an error similar to below: - -``` -internal/modules/cjs/loader.js:968 - throw err; - ^ - -Error: Cannot find module '../build/Debug/nodegit.node' -``` - -This can occur if an npm dependency is not completely installed. Because some -dependencies run a post-install script, ensure that both your npm and yarn -configs have the `ignore-scripts` flag set to `false`. diff --git a/docs/getting-started/deployment-docker.md b/docs/getting-started/deployment-docker.md new file mode 100644 index 0000000000..9486e0aeb0 --- /dev/null +++ b/docs/getting-started/deployment-docker.md @@ -0,0 +1,238 @@ +--- +id: deployment-docker +title: Docker +description: Documentation on how to deploy Backstage as a Docker image +--- + +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 split frontend content into a separate image. + +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 +connect to an external PostgreSQL instance where the backend plugins can store +their state, rather than using SQLite. + +### Host Build + +This section describes how to build a Docker image from a Backstage repo with +most of the build happening outside of Docker. This is almost always the faster +approach, as the build steps tend to execute faster, and it's possible to have +more efficient caching of dependencies on the host, where a single change won't +bust the entire cache. + +The required steps in the host build are to install dependencies with +`yarn install`, generate type definitions using `yarn tsc`, and build all +packages with `yarn build`. + +> NOTE: Using `yarn build` to build packages and bundle the backend assumes that +> you have migrated to using `backstage-cli backend:bundle` as your build script +> in the backend package. + +In a CI workflow it might look something like this: + +```bash +yarn install --frozen-lockfile + +# tsc outputs type definitions to dist-types/ in the repo root, which are then consumed by the build +yarn tsc + +# Build all packages and in the end bundle them all up into the packages/backend/dist folder. +yarn build +``` + +Once the host build is complete, we are ready to build our image. We use the +following `Dockerfile`, which is also included when creating a new app with +`@backstage/create-app`: + +```Dockerfile +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` + +FROM node:14-buster-slim + +WORKDIR /app + +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ + +RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +For more details on how the `backend:bundle` command and the `skeleton.tar.gz` +file works, see the +[`backend:bundle` command docs](../cli/commands.md#backendbundle) + +The `Dockerfile` is typically placed at `packages/backend/Dockerfile`, but needs +to be executed with the root of the repo as the build context, in order to get +access to the root `yarn.lock` and `package.json`, along with any other files +that might be needed, such as `.npmrc`. + +In order to speed up the build we can significantly reduce the build context +size using the following `.dockerignore` in the root of the repo: + +```text +.git +node_modules +packages +!packages/backend/dist +plugins +``` + +With the project build and the `.dockerignore` and `Dockerfile` in place, we are +now ready to build the final image. Assuming we're at the root of the repo, we +execute the build like this: + +```bash +docker image build . -f packages/backend/Dockerfile --tag backstage +``` + +To try out the image locally you can run the following: + +```sh +docker run -it -p 7000:7000 backstage +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` + +### Multistage Build + +This section describes how to set up a multi-stage Docker build that builds the +entire project within Docker. This is typically slower than a host build, but is +sometimes desired because Docker in Docker is not available in the build +environment, or due to other requirements. + +The build is split into three different stages, where the first stage finds all +of the `package.json`s that are relevant for the initial install step enabling +us to cache the initial `yarn install` that installs all dependencies. The +second stage executes the build itself, and is similar to the steps we execute +on the host in the host build. The third and final stage then packages it all +together into the final image, and is similar to the `Dockerfile` of the host +build. + +The following `Dockerfile` executes the multi-stage build and should be added to +the repo root: + +```Dockerfile +# Stage 1 - Create yarn install skeleton layer +FROM node:14-buster-slim AS packages + +WORKDIR /app +COPY package.json yarn.lock ./ + +COPY packages packages +COPY plugins plugins + +RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf + +# Stage 2 - Install dependencies and build packages +FROM node:14-buster-slim AS build + +WORKDIR /app +COPY --from=packages /app . + +RUN yarn install --frozen-lockfile --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +COPY . . + +RUN yarn tsc +RUN yarn --cwd packages/backend backstage-cli backend:bundle --build-dependencies + +# Stage 3 - Build the actual backend image and install production dependencies +FROM node:14-buster-slim + +WORKDIR /app + +# Copy the install dependencies from the build stage and context +COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/skeleton.tar.gz ./ +RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz + +RUN yarn install --frozen-lockfile --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +# Copy the built packages from the build stage +COPY --from=build /app/packages/backend/dist/bundle.tar.gz . +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz + +# Copy any other files that we need at runtime +COPY app-config.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +Note that a newly created Backstage app will typically not have a `plugins/` +folder, so you will want to comment that line out. This build also does not work +in the main repo, since the `backstage-cli` which is used for the build doesn't +end up being properly installed. + +To speed up the build when not running in a fresh clone of the repo you should +set up a `.dockerignore`. This one is different than the host build one, because +we want to have access to the source code of all packages for the build, but can +ignore any existing build output or dependencies: + +```text +node_modules +packages/*/dist +packages/*/node_modules +plugins/*/dist +plugins/*/node_modules +``` + +Once you have added both the `Dockerfile` and `.dockerignore` to the root of +your project, run the following to build the container under a specified tag. + +```sh +docker image build -t backstage . +``` + +To try out the image locally you can run the following: + +```sh +docker run -it -p 7000:7000 backstage +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` + +### Separate Frontend + +It is sometimes desirable to serve the frontend separately from the backend, +either from a separate image or for example a static file serving provider. The +first step in doing so is to remove the `app-backend` plugin from the backend +package, which is done as follows: + +1. Delete `packages/backend/src/plugins/app.ts` +2. Remove the following lines from `packages/backend/src/index.ts`: + ```tsx + import app from './plugins/app'; + // ... + const appEnv = useHotMemoize(module, () => createEnv('app')); + // ... + .addRouter('', await app(appEnv)); + ``` +3. Remove the `@backstage/plugin-app-backend` and the app package dependency + (e.g. `app`) from `packages/backend/packages.json`. If you don't remove the + app package dependency the app will still be built and bundled with the + backend. + +Once the `app-backend` is removed from the backend, you can use your favorite +static file serving method for serving the frontend. An example of how to set up +an NGINX image is available in the +[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx/Dockerfile) diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index e92b54d08b..cc622d00f5 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -4,98 +4,6 @@ title: Other description: Documentation on different ways of Deployment --- -## Docker - -Here we have an example Dockerfile that you can use to build everything together -in one container. This Dockerfile uses multi-stage builds, and a -`backend:bundle` command from the CLI. - -It also provides caching on the `yarn install`'s so that you don't have to do it -unless absolutely necessary. - -> Note: This Dockerfile assumes that you're running SQLite, or your -> configuration is setup to connect to an external PostgreSQL Database. - -```Dockerfile -# Stage 1 - Create yarn install skeleton layer -FROM node:14-buster AS packages - -WORKDIR /app -COPY package.json yarn.lock ./ - -COPY packages packages - -# Uncomment this line if you have a local plugins folder -# COPY plugins plugins - -RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf - -# Stage 2 - Install dependencies and build packages -FROM node:14-buster AS build - -WORKDIR /app -COPY --from=packages /app . - -RUN yarn install --network-timeout 600000 && rm -rf "$(yarn cache dir)" - -COPY . . - -RUN yarn tsc -RUN yarn --cwd packages/backend backstage-cli backend:bundle --build-dependencies - -# Stage 3 - Build the actual backend image and install production dependencies -FROM node:14-buster - -WORKDIR /app - -# Copy from build stage -COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/skeleton.tar.gz ./ -RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz - -RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" - -COPY --from=build /app/packages/backend/dist/bundle.tar.gz . -RUN tar xzf bundle.tar.gz && rm bundle.tar.gz - -COPY app-config.yaml app-config.production.yaml ./ - -CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"] -``` - -Before building you should also include a `.dockerignore`. This will greatly -improve the context boot up time of Docker as we are no longer sending all of -the `node_modules` into the context. It also helps us avoid some limitations and -errors that may occur when trying to share the `node_modules` folder to inside -the build. - -You can add the following contents to the root of your repository at -`.dockerignore` and it might look something like the following: - -```dockerignore -.git -node_modules -packages/*/node_modules -plugins/*/node_modules -plugins/*/dist -``` - -Once you have added both the `Dockerfile` and `.dockerignore` to the root of -your project, and run the following to build the container under a specified -tag. - -```sh -$ docker build -t example-deployment . -``` - -To run the image locally you can run: - -```sh -$ docker run -it -p 7000:7000 example-deployment -``` - -You should then start to get logs in your terminal, and then you can open your -browser at `http://localhost:7000` - ## Heroku Deploying to Heroku is relatively easy following these steps. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 16c837d676..b946039dfb 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -19,7 +19,7 @@ general, it's easier to fork and clone this project. That will let you stay up to date with the latest changes, and gives you an easier path to make Pull Requests towards this repo. -### Creating a Standalone App +### Create your Backstage App Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have @@ -46,8 +46,3 @@ look something like this. You can read more about this process in You can read more in our [CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) guide, which can help you get setup with a Backstage development environment. - -### Next steps - -Take a look at the [Running Backstage Locally](./running-backstage-locally.md) -guide to learn how to set up Backstage, and how to develop on the platform. diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 6de9d234e3..fe68937c4e 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -104,7 +104,6 @@ The value of Backstage grows with every new plugin that gets added. Here is a collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins. -- [Development Environment](development-environment.md) - [Create a Backstage Plugin](../plugins/create-a-plugin.md) - [Structure of a Plugin](../plugins/structure-of-a-plugin.md) - [Utility APIs](../api/utility-apis.md) diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md new file mode 100644 index 0000000000..848a306381 --- /dev/null +++ b/docs/integrations/github/org.md @@ -0,0 +1,71 @@ +--- +id: org +title: GitHub Organizational Data +sidebar_label: Org Data +# prettier-ignore +description: Setting up ingestion of organizational data from GitHub +--- + +The Backstage catalog can be set up to ingest organizational data - teams and +users - directly from an organization in GitHub or GitHub Enterprise. The result +is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your org setup. + +## Installation + +The processor that performs the import, `GithubOrgReaderProcessor`, comes +installed with the default setup of Backstage. + +If you replace the set of processors in your installation using that facility of +the catalog builder class, you can import and add it as follows. + +```ts +// Typically in packages/backend/src/plugins/catalog.ts +import { GithubOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + +builder.replaceProcessors( + GithubOrgReaderProcessor.fromConfig(config, { logger }), + // ... +); +``` + +## Configuration + +The following configuration enables an import of the teams and users under the +org `https://github.com/my-org-name` on public GitHub. + +```yaml +catalog: + locations: + - type: github-org + target: https://github.com/my-org-name + processors: + githubOrg: + providers: + - target: https://github.com + apiBaseUrl: https://api.github.com + token: + $env: GITHUB_TOKEN +``` + +Locations point out the specific org(s) you want to import. The `type` of these +locations must be `github-org`, and the `target` must point to the exact URL of +some organization. You can have several such location entries if you want, but +typically you will have just one. + +The processor itself is configured in the other block, under +`catalog.processors.githubOrg`. There may be many providers, each targeting a +specific `target` which is supposed to be the address of the home page of GitHub +or your GitHub Enterprise installation. + +The example above assumes that the backend is started with an environment +variable called `GITHUB_TOKEN` that contains a Personal Access Token. The token +needs to have at least the scopes `read:org`, `read:user`, and `user:email` in +the given `target`. + +If you want to address your own GitHub Enterprise instance, replace occurrences +of `https://github.com` in the configuration above with the address of your +GitHub Enterprise home page, and the `apiBaseUrl` to where your API endpoint +lives - commonly on the form `https:///api/v3`. diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md new file mode 100644 index 0000000000..dea33e4024 --- /dev/null +++ b/docs/integrations/ldap/org.md @@ -0,0 +1,280 @@ +--- +id: org +title: LDAP Organizational Data +sidebar_label: Org Data +# prettier-ignore +description: Setting up ingestion of organizational data from LDAP +--- + +The Backstage catalog can be set up to ingest organizational data - groups and +users - directly from an LDAP compatible service. The result is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your org setup. + +## Installation + +The processor that performs the import, `LdapOrgReaderProcessor`, comes +installed with the default setup of Backstage. + +If you replace the set of processors in your installation using that facility of +the catalog builder class, you can import and add it as follows. + +```ts +// Typically in packages/backend/src/plugins/catalog.ts +import { LdapOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + +builder.replaceProcessors( + LdapOrgReaderProcessor.fromConfig(config, { logger }), + // ... +); +``` + +## Configuration + +The following configuration is a small example of how a setup could look for +importing groups and users from a corporate LDAP server. + +```yaml +catalog: + locations: + - type: ldap-org + target: ldaps://ds.example.net + processors: + ldapOrg: + providers: + - target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: + $env: LDAP_SECRET + users: + dn: ou=people,ou=example,dc=example,dc=net + options: + filter: (uid=*) + map: + description: l + set: + metadata.customField: 'hello' + groups: + dn: ou=access,ou=groups,ou=example,dc=example,dc=net + options: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' +``` + +Locations point out the specific org(s) you want to import. The `type` of these +locations must be `ldap-org`, and the `target` must point to the exact URL +(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can +have several such location entries if you want, but typically you will have just +one. + +The processor itself is configured in the other block, under +`catalog.processors.ldapOrg`. There may be many providers, each targeting a +specific `target` which is supposed to be on the same form as the location +`target`. + +These config blocks have a lot of options in them, so we will describe each +"root" key within the block separately. + +### target + +This is the URL of the targeted server, typically on the form +`ldaps://ds.example.net` for SSL enabled servers or `ldap://ds.example.net` +without SSL. + +### bind + +The bind block specifies how the plugin should bind (essentially, to +authenticate) towards the server. It has the following fields. + +```yaml +dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net +secret: + $env: LDAP_SECRET +``` + +The `dn` is the full LDAP DN (distinguished name) for the user that the plugin +authenticates itself as. At this point, only regular user based authentication +is supported. + +The `secret` is the password of the same user. In this example, it is given in +the form of an environment variable `LDAP_SECRET`, that has to be set when the +backend starts. + +### users + +The `users` block defines the settings that govern the reading and +interpretation of users. Its fields are explained in separate sections below. + +### users.dn + +The DN under which users are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +### users.options + +The search options to use when sending the query to the server, when reading all +users. All of the options are shown below, with their default values, but they +are all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of users that are of actual interest to ingest. For example, you may want + # to filter out disabled users. + filter: (uid=*) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +### users.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +### users.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All of the options are +shown below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the processor will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. + rdn: uid + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: uid + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: mail + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.memberOf field of the entity. + memberOf: memberOf +``` + +### groups + +The `groups` block defines the settings that govern the reading and +interpretation of groups. Its fields are explained in separate sections below. + +### groups.dn + +The DN under which groups are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +### groups.options + +The search options to use when sending the query to the server, when reading all +groups. All of the options are shown below, with their default values, but they +are all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of groups that are of actual interest to ingest. For example, you may want + # to filter out disabled groups. + filter: (&(objectClass=some-group-class)(!(groupType=email))) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +### groups.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +### groups.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All of the options are +shown below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the processor will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. If the target field is optional, such as the display +name, the importer will accept missing attributes and just leave the target +field unset. If the target field is mandatory, such as the name of the entity, +validation will fail if the source attribute is missing. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. This value is copied into a + # well known annotation to be able to query by it later. + rdn: cn + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: cn + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.type field of the entity. + type: groupType + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.parent field of the entity. + memberOf: memberOf + # The name of the attribute that shall be used for the values of + # the spec.children field of the entity. + members: member +``` diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 1cac85fbce..27aec8a31f 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -27,17 +27,17 @@ to grant the app more permissions if needed. ### Using the CLI (public GitHub only) -You can use the `backstage-cli` to create GitHub App' using a manifest file that -we provide. This gives us a way to automate some of the work required to create -a GitHub app. +You can use the `backstage-cli` to create a GitHub App using a manifest file +that we provide. This gives us a way to automate some of the work required to +create a GitHub app. -You can read more about the `backstage-cli create-github-app` method -[here](../cli/commands.md#create-github-app) +You can read more about the +[`backstage-cli create-github-app` method](../cli/commands.md#create-github-app). -Once you've gone through the CLI command, it should produce a `yaml` file in the +Once you've gone through the CLI command, it should produce a YAML file in the root of the project which you can then use as an `include` in your -`app-config.yaml`. You can go ahead and skip to -[here](#including-in-integrations-config) if you've got to this part. +`app-config.yaml`. You can go ahead and +[skip ahead](#including-in-integrations-config) if you've already got an app. ### GitHub Enterprise @@ -46,9 +46,9 @@ You have to create the GitHub Application manually using these as GitHub Enterprise does not support creation of apps from manifests. Once the application is created you have to generate a private key for the -application it in a `yaml` file. +application and place it in a YAML file. -The yaml file must include the following information. Please note that the +The YAML file must include the following information. Please note that the indentation for the `privateKey` is required. ```yaml @@ -64,7 +64,7 @@ privateKey: | ### Including in Integrations Config -Once the credentials are stored in a yaml file generated by `create-github-app` +Once the credentials are stored in a YAML file generated by `create-github-app`, or manually by following the [GitHub Enterprise](#gitHub-enterprise) instructions, they can be included in the `app-config.yaml` under the `integrations` section. diff --git a/microsite/data/plugins/splunk-on-call.yaml b/microsite/data/plugins/splunk-on-call.yaml new file mode 100644 index 0000000000..2d6deb0180 --- /dev/null +++ b/microsite/data/plugins/splunk-on-call.yaml @@ -0,0 +1,14 @@ +--- +title: Splunk On-Call +author: Spotify +authorUrl: https://github.com/spotify +category: Monitoring +description: Splunk On-Call offers a simple way to identify incidents and escalation policies. +documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call +iconUrl: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIiBjbGFzcz0idm8tbGVhZi1ibGFjayI+PHBhdGggZD0iTTE1Ljk5OTcgMzJDMjQuODM2MSAzMiAzMiAyNC44MzY4IDMyIDE2LjAwMDJDMzIgNy4xNjM1NiAyNC44MzYxIDAgMTUuOTk5NyAwQzcuMTYzNCAwIDAgNy4xNjM1NiAwIDE2LjAwMDJDMCAyNC44MzY4IDcuMTYzNCAzMiAxNS45OTk3IDMyWk0yMi41Njc1IDE0LjY5MDRMMjMuNjI1OSAxNi41MTU4TDE4Ljg4NTEgMTguOTE0NUwxOC44MSAxOS43MTQxTDIyLjQwMzMgMTguNTc2MUwyMC41OTcyIDIwLjg0MzJMMjAuNDQ2MiAyMS41NjI4TDE4LjI0MzkgMjIuNTUyNUwxOC4wMTc2IDIzLjIzMjJMMTkuMDA0NSAyMi44MTU3TDE2LjM0MjIgMjUuOTEwNFYyOS42MDA0SDE1LjY1NzdWMjUuOTEwNEwxMi45OTU5IDIyLjgxNTdMMTMuOTgyOCAyMy4yMzIyTDEzLjc1NjYgMjIuNTUyNUwxMS41NTQ1IDIxLjU2MjhMMTEuNDAzNiAyMC44NDMyTDkuNTk2OTIgMTguNTc2MUwxMy4xOTA2IDE5LjcxNDFMMTMuMTE0OSAxOC45MTQ1TDguMzc0NDkgMTYuNTE1OEw5LjQzMzM5IDE0LjY5MDRMNy4yMDAyIDExLjUxODlMMTMuMzcxOCAxNC41MDA3TDEzLjQ5NyAxMy42MDM3TDExLjY5MzYgMTAuNjYyTDExLjk1MiAxMC4xNzc0TDExLjExOTUgNi43MTg2N0wxNC4zNTUzIDEwLjEyNjdMMTQuNTQ5MyA4LjI0NDU0TDEzLjU4MTggNS40MjI1M0wxNC44NzMgNS44NTYyNEwxNiAyLjQwMDM5TDE3LjEyNzMgNS44NTYyNEwxOC40MTg2IDUuNDIyNTNMMTcuNDUxMiA4LjI0NDU0TDE3LjY0NTQgMTAuMTI2N0wyMC44ODA3IDYuNzE4NjdMMjAuMDQ4MiAxMC4xNzc0TDIwLjMwNjggMTAuNjYyTDE4LjUwMzQgMTMuNjAzN0wxOC42Mjg4IDE0LjUwMDdMMjQuODAwMiAxMS41MTg5TDIyLjU2NzUgMTQuNjkwNFoiIGZpbGw9JyMyQzJDMkMnLz48L3N2Zz4K +npmPackageName: '@backstage/plugin-splunk-on-call' +tags: + - monitoring + - errors + - alerting + - splunk diff --git a/microsite/sidebars.json b/microsite/sidebars.json index ebce8d900c..0b3c8f5915 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -13,7 +13,7 @@ "Getting Started": [ "getting-started/index", "getting-started/running-backstage-locally", - "getting-started/development-environment", + "getting-started/contributing", "getting-started/create-an-app", { "type": "subcategory", @@ -27,6 +27,7 @@ "type": "subcategory", "label": "Deployment", "ids": [ + "getting-started/deployment-docker", "getting-started/deployment-k8s", "getting-started/deployment-helm", "getting-started/deployment-other" @@ -101,6 +102,18 @@ ] } ], + "Integrations": [ + { + "type": "subcategory", + "label": "GitHub", + "ids": ["integrations/github/org"] + }, + { + "type": "subcategory", + "label": "LDAP", + "ids": ["integrations/ldap/org"] + } + ], "Plugins": [ "plugins/index", "plugins/existing-plugins", diff --git a/mkdocs.yml b/mkdocs.yml index a303fd9978..00824392a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,7 +16,7 @@ nav: - Getting started: - Getting Started: 'getting-started/index.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' - - Local development: 'getting-started/development-environment.md' + - Contributors: 'getting-started/contributors.md' - Demo deployment: https://backstage-demo.roadie.io - Production deployments: - Create an App: 'getting-started/create-an-app.md' @@ -24,6 +24,7 @@ nav: - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - Deployment scenarios: + - Docker: 'getting-started/deployment-docker.md' - Kubernetes: 'getting-started/deployment-k8s.md' - Kubernetes and Helm: 'getting-started/deployment-helm.md' - Other: 'getting-started/deployment-other.md' @@ -64,6 +65,11 @@ nav: - FAQ: 'features/techdocs/FAQ.md' - Kubernetes: - Overview: 'features/kubernetes/index.md' + - Integrations: + - GitHub: + - Org Data: 'integrations/github/org.md' + - LDAP: + - Org Data: 'integrations/ldap/org.md' - Plugins: - Overview: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' diff --git a/package.json b/package.json index 59fe9d8f18..6c39cf2953 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docgen": "lerna run docgen", - "docker-build": "yarn tsc && yarn workspace example-backend build-image", + "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install --frozen-lockfile", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e849667c19..449d018fa3 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -16,8 +16,10 @@ import { ApiEntity, + DomainEntity, Entity, GroupEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { EmptyState } from '@backstage/core'; @@ -25,15 +27,19 @@ import { ApiDefinitionCard, ConsumedApisCard, ConsumingComponentsCard, + EntityHasApisCard, ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { AboutCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, EntityLinksCard, EntityPageLayout, } from '@backstage/plugin-catalog'; -import { useEntity } from '@backstage/plugin-catalog-react'; +import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter, @@ -178,7 +184,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( {isPagerDutyAvailable(entity) && ( - + + + )} @@ -206,6 +214,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( )} + + + ); @@ -425,6 +436,51 @@ const GroupEntityPage = ({ entity }: { entity: Entity }) => ( ); +const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( + + + + + + + + + + + +); + +const SystemEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( + + + + + + + + +); + +const DomainEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + export const EntityPage = () => { const { entity } = useEntity(); @@ -437,6 +493,10 @@ export const EntityPage = () => { return ; case 'user': return ; + case 'system': + return ; + case 'domain': + return ; default: return ; } diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index f1bc764fd0..acef405c8a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,16 +1,26 @@ -FROM node:14-buster +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` -WORKDIR /usr/src/app +FROM node:14-buster-slim + +WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json skeleton.tar ./ +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -# This will copy the contents of the dist-workspace when running the build-image command. -# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. -COPY . . +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ -CMD ["node", "packages/backend"] +CMD ["node", "packages/backend", "--config", "app-config.yaml"] diff --git a/packages/backend/package.json b/packages/backend/package.json index e585d20035..413d157c2d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,8 +18,8 @@ "backstage" ], "scripts": { - "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image --build --tag example-backend", + "build": "backstage-cli backend:bundle", + "build-image": "docker build ../.. -f Dockerfile --tag example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 61cd83b7b0..94cf059cbd 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -83,10 +83,8 @@ async function getConfig() { }, }, - // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed - // TODO: jest is working on module support, it's possible that we can remove this in the future transform: { - '\\.esm\\.js$': require.resolve('jest-esm-transformer'), + '\\.esm\\.js$': require.resolve('./jestEsmTransform.js'), // See jestEsmTransform.js '\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( './jestFileTransform.js', diff --git a/packages/cli/config/jestEsmTransform.js b/packages/cli/config/jestEsmTransform.js new file mode 100644 index 0000000000..99f1a600bc --- /dev/null +++ b/packages/cli/config/jestEsmTransform.js @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const babel = require('@babel/core'); + +// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed +// TODO: jest is working on module support, it's possible that we can remove this in the future +module.exports = { + process(src) { + const result = babel.transform(src, { + babelrc: false, + compact: false, + plugins: [ + // This transforms the regular ESM syntax, import and export statements + require.resolve('@babel/plugin-transform-modules-commonjs'), + // This transforms dynamic `import()`, which is not supported yet in the Node.js VM API + require.resolve('babel-plugin-dynamic-import-node'), + ], + }); + + return result.code; + }, +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index e54696ce43..d360727ebd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,8 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { + "@babel/core": "^7.4.4", + "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", "@backstage/config-loader": "^0.5.1", @@ -53,6 +55,7 @@ "@typescript-eslint/eslint-plugin": "^v4.14.0", "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", + "babel-plugin-dynamic-import-node": "^2.3.3", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", @@ -78,7 +81,6 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "jest-esm-transformer": "^1.0.0", "lodash": "^4.17.19", "mini-css-extract-plugin": "^0.9.0", "ora": "^4.0.3", diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index 7f281c67a5..2154619b01 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -16,14 +16,14 @@ import React from 'react'; import { makeStyles } from '@material-ui/core'; -import { InfoCard } from '../../layout/InfoCard'; +import { InfoCard, InfoCardVariants } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; import { Gauge } from './Gauge'; type Props = { title: string; subheader?: string; - variant?: string; + variant?: InfoCardVariants; /** Progress in % specified as decimal, e.g. "0.23" */ progress: number; deepLink?: BottomLinkProps; diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 4be85e494f..64ceb761f8 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -14,8 +14,10 @@ * limitations under the License. */ +import { makeStyles } from '@material-ui/core'; import React from 'react'; -import { Table, SubvalueCell, TableColumn } from './'; +import { Link } from '../Link'; +import { SubvalueCell, Table, TableColumn } from './'; import { TableFilter } from './Table'; export default { @@ -23,7 +25,16 @@ export default { component: Table, }; -const containerStyle = { width: 850 }; +const useStyles = makeStyles(theme => ({ + container: { + width: 850, + }, + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const data: Array<{}> = []; @@ -43,6 +54,7 @@ const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const testData10 = generateTestData(10); export const DefaultTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -66,7 +78,7 @@ export const DefaultTable = () => { ]; return ( -
+
{ ); }; -export const SubtitleTable = () => { +export const EmptyTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -101,7 +114,49 @@ export const SubtitleTable = () => { ]; return ( -
+
+
+ No data was added yet,  + learn how to add data. + + } + title="Backstage Table" + /> + + ); +}; + +export const SubtitleTable = () => { + const classes = useStyles(); + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
{ }; export const HiddenSearchTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -137,7 +193,7 @@ export const HiddenSearchTable = () => { ]; return ( -
+
{ }; export const SubvalueTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -181,13 +238,14 @@ export const SubvalueTable = () => { ]; return ( -
+
); }; export const DenseTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -211,7 +269,7 @@ export const DenseTable = () => { ]; return ( -
+
{ }; export const FilterTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -261,7 +320,7 @@ export const FilterTable = () => { ]; return ( -
+
', () => { ); expect(rendered.getByText('subtitle')).toBeInTheDocument(); }); + + it('renders custom empty component if empty', async () => { + const rendered = await renderInTestApp( +
EMPTY} + columns={minProps.columns} + data={[]} + />, + ); + expect(rendered.getByText('EMPTY')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index dbb2964845..3892d82bb3 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -42,12 +42,14 @@ import MTable, { Column, Icons, MaterialTableProps, + MTableBody, MTableHeader, MTableToolbar, Options, } from 'material-table'; import React, { forwardRef, + ReactNode, useCallback, useEffect, useRef, @@ -202,6 +204,7 @@ export interface TableProps subtitle?: string; filters?: TableFilter[]; initialState?: TableState; + emptyContent?: ReactNode; onStateChange?: (state: TableState) => any; } @@ -212,6 +215,7 @@ export function Table({ subtitle, filters, initialState, + emptyContent, onStateChange, ...props }: TableProps) { @@ -423,6 +427,23 @@ export function Table({ ], ); + const Body = useCallback( + bodyProps => { + if (emptyContent && data.length === 0) { + return ( + + + + + + ); + } + + return ; + }, + [data, emptyContent, columns], + ); + return (
{filtersOpen && data && filters?.length && ( @@ -438,6 +459,7 @@ export function Table({ ), Toolbar, + Body, }} options={{ ...defaultOptions, ...options }} columns={MTColumns} diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index af10010d14..cf06a5f0a1 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -87,6 +87,8 @@ const VARIANT_STYLES = { }, }; +export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem'; + /** * InfoCard is used to display a paper-styled block on the screen, similar to a panel. * @@ -111,7 +113,7 @@ type Props = { divider?: boolean; deepLink?: BottomLinkProps; slackChannel?: string; - variant?: string; + variant?: InfoCardVariants; style?: object; cardStyle?: object; children?: ReactNode; diff --git a/packages/core/src/layout/InfoCard/index.ts b/packages/core/src/layout/InfoCard/index.ts index d77fcf93c4..35829662d0 100644 --- a/packages/core/src/layout/InfoCard/index.ts +++ b/packages/core/src/layout/InfoCard/index.ts @@ -15,3 +15,4 @@ */ export { InfoCard } from './InfoCard'; +export type { InfoCardVariants } from './InfoCard'; diff --git a/packages/create-app/templates/default-app/.dockerignore b/packages/create-app/templates/default-app/.dockerignore new file mode 100644 index 0000000000..63c9c34286 --- /dev/null +++ b/packages/create-app/templates/default-app/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +packages +!packages/backend/dist +plugins diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index 50514713d3..acef405c8a 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -1,16 +1,26 @@ -FROM node:12-buster +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` -WORKDIR /usr/src/app +FROM node:14-buster-slim + +WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json skeleton.tar ./ +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -# This will copy the contents of the dist-workspace when running the build-image command. -# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. -COPY . . +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ -CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"] +CMD ["node", "packages/backend", "--config", "app-config.yaml"] 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 daee44d8c7..8c8cfae0b3 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 @@ -8,8 +8,8 @@ "node": "12 || 14" }, "scripts": { - "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image --build --tag backstage", + "build": "backstage-cli backend:bundle", + "build-image": "docker build ../.. -f Dockerfile --tag backstage", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts index 60a705f6a7..6633a71b2c 100644 --- a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -14,6 +14,10 @@ * limitations under the License. */ import fs from 'fs'; +import type { + BlobUploadCommonResponse, + ContainerGetPropertiesResponse, +} from '@azure/storage-blob'; export class BlockBlobClient { private readonly blobName; @@ -22,23 +26,33 @@ export class BlockBlobClient { this.blobName = blobName; } - uploadFile(source: string) { - return new Promise((resolve, reject) => { - if (!fs.existsSync(source)) { - reject(''); - } else { - resolve(''); - } + uploadFile(source: string): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 200, + headers: {} as any, + }, }); } exists() { - return new Promise((resolve, reject) => { - if (fs.existsSync(this.blobName)) { - resolve(true); - } else { - reject({ message: 'The object doest not exist !' }); - } + return Promise.resolve(fs.existsSync(this.blobName)); + } +} + +class BlockBlobClientFailUpload extends BlockBlobClient { + uploadFile(source: string): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 500, + headers: {} as any, + }, }); } } @@ -50,9 +64,16 @@ export class ContainerClient { this.containerName = containerName; } - getProperties() { - return new Promise(resolve => { - resolve(''); + getProperties(): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 200, + headers: {} as any, + parsedHeaders: {}, + }, }); } @@ -61,6 +82,27 @@ export class ContainerClient { } } +class ContainerClientFailGetProperties extends ContainerClient { + getProperties(): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 404, + headers: {} as any, + parsedHeaders: {}, + }, + }); + } +} + +class ContainerClientFailUpload extends ContainerClient { + getBlockBlobClient(blobName: string) { + return new BlockBlobClientFailUpload(blobName); + } +} + export class BlobServiceClient { private readonly url; private readonly credential; @@ -71,6 +113,12 @@ export class BlobServiceClient { } getContainerClient(containerName: string) { + if (containerName === 'bad_container') { + return new ContainerClientFailGetProperties(containerName); + } + if (this.credential.accountName === 'failupload') { + return new ContainerClientFailUpload(containerName); + } return new ContainerClient(containerName); } } diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index c4bf102610..fbca6d1ed2 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -30,6 +30,7 @@ import { patchMkdocsYmlPreBuild, runDockerContainer, storeEtagMetadata, + UserOptions, } from './helpers'; const mockEntity = { @@ -114,7 +115,7 @@ describe('helpers', () => { imageName, args, expect.any(Stream), - { + expect.objectContaining({ Volumes: { '/content': {}, '/result': {}, @@ -123,7 +124,7 @@ describe('helpers', () => { HostConfig: { Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, - }, + }), ); }); @@ -139,6 +140,30 @@ describe('helpers', () => { expect(mockDocker.ping).toHaveBeenCalled(); }); + it('should pass through the user and group id from the host machine and set the home dir', async () => { + await runDockerContainer({ + imageName, + args, + docsDir, + outputDir, + dockerClient: mockDocker, + }); + + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + + expect(mockDocker.run).toHaveBeenCalledWith( + imageName, + args, + expect.any(Stream), + expect.objectContaining({ + ...userOptions, + }), + ); + }); + describe('where docker is unavailable', () => { const dockerError = 'a docker error'; diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 32da5d19e8..a88ed13231 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -51,6 +51,12 @@ export type RunCommandOptions = { logStream?: Writable; }; +export type UserOptions = { + User?: string; +}; + +// To be replaced by a runDockerContainer from backend-common +// shared between Scaffolder and TechDocs and any other plugin. export async function runDockerContainer({ imageName, args, @@ -78,6 +84,17 @@ export async function runDockerContainer({ }); }); + const userOptions: UserOptions = {}; + // @ts-ignore + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, @@ -91,6 +108,7 @@ export async function runDockerContainer({ HostConfig: { Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, + ...userOptions, ...createOptions, }, ); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 6c0cadcfae..b0b29e8654 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -52,10 +52,13 @@ export class AwsS3Publish implements PublisherBase { ); } - // Credentials is an optional config. If missing, default AWS environment variables - // or AWS shared credentials file at ~/.aws/credentials will be used to authenticate - // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html - // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. + // 1. AWS environment variables + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html + // 2. AWS shared credentials file at ~/.aws/credentials + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html + // 3. IAM Roles for EC2 + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-iam.html const credentials = config.getOptionalConfig( 'techdocs.publisher.awsS3.credentials', ); @@ -67,9 +70,7 @@ export class AwsS3Publish implements PublisherBase { } // AWS Region is an optional config. If missing, default AWS env variable AWS_REGION - // or AWS shared credentials file at ~/.aws/credentials will be used. Any way, AWS SDK v3 client needs - // to have the AWS Region information for it to work. - // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + // or AWS shared credentials file at ~/.aws/credentials will be used. const region = config.getOptionalString('techdocs.publisher.awsS3.region'); const storageClient = new aws.S3({ diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 80f3cb0cf7..62c81df5c4 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -19,6 +19,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { AzureBlobStoragePublish } from './azureBlobStorage'; import { PublisherBase } from './types'; import type { Entity } from '@backstage/catalog-model'; +import type { Logger } from 'winston'; const createMockEntity = (annotations = {}) => { return { @@ -43,33 +44,40 @@ const getEntityRootDir = (entity: Entity) => { return entityRootDir; }; -const logger = getVoidLogger(); -jest.spyOn(logger, 'info').mockReturnValue(logger); -jest.spyOn(logger, 'error').mockReturnValue(logger); +function createLogger() { + const logger = getVoidLogger(); + jest.spyOn(logger, 'info').mockReturnValue(logger); + jest.spyOn(logger, 'error').mockReturnValue(logger); + return logger; +} let publisher: PublisherBase; -beforeEach(async () => { - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - publisher: { - type: 'azureBlobStorage', - azureBlobStorage: { - credentials: { - accountName: 'accountName', - accountKey: 'accountKey', +describe('publishing with valid credentials', () => { + let logger: Logger; + + beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', }, - containerName: 'containerName', }, }, - }, + }); + + logger = createLogger(); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); }); - publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); -}); - -describe('AzureBlobStoragePublish', () => { describe('publish', () => { it('should publish a directory', async () => { const entity = createMockEntity(); @@ -116,7 +124,7 @@ describe('AzureBlobStoragePublish', () => { }) .catch(error => expect(error.message).toContain( - 'Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory', + 'Unable to upload file(s) to Azure Blob Storage. Failed to read template directory: ENOENT, no such file or directory', ), ); mockFs.restore(); @@ -145,3 +153,92 @@ describe('AzureBlobStoragePublish', () => { }); }); }); + +describe('error reporting', () => { + it('reports an error when unable to read container properties', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'bad_container', + }, + }, + }, + }); + + const logger = createLogger(); + + let error; + try { + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, + ), + ); + }); + + it('reports an error when bad account credentials', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'failupload', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const logger = createLogger(); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + }, + }); + + let error; + try { + await publisher.publish({ + entity, + directory: entityRootDir, + }); + } catch (e) { + error = e; + } + + expect(error.message).toContain( + `Unable to upload file(s) to Azure Blob Storage.`, + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Upload failed for test-namespace/TestKind/test-component-name/index.html with status code 500`, + ), + ); + + mockFs.restore(); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index a02639e63f..7000e9f28c 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -17,7 +17,6 @@ import platformPath from 'path'; import express from 'express'; import { BlobServiceClient, - BlobUploadCommonResponse, StorageSharedKeyCredential, } from '@azure/storage-blob'; import { DefaultAzureCredential } from '@azure/identity'; @@ -79,25 +78,25 @@ export class AzureBlobStoragePublish implements PublisherBase { credential, ); - await storageClient - .getContainerClient(containerName) - .getProperties() - .then(() => { - logger.info( - `Successfully connected to the Azure Blob Storage container ${containerName}.`, - ); - }) - .catch(reason => { - logger.error( - `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + - 'Make sure that the Azure project and container exist and the access key is setup correctly ' + - 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); + try { + const response = await storageClient + .getContainerClient(containerName) + .getProperties(); + + if (response._response.status >= 400) { throw new Error( - `from Azure Blob Storage client library: ${reason.message}`, + `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, ); - }); + } + } catch (e) { + logger.error( + `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(`from Azure Blob Storage client library: ${e.message}`); + } return new AzureBlobStoragePublish(storageClient, containerName, logger); } @@ -122,8 +121,6 @@ export class AzureBlobStoragePublish implements PublisherBase { // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); - const uploadPromises: Array> = []; - // Bound the number of concurrent batches. We want a bit of concurrency for // performance reasons, but not so much that we starve the connection pool // or start thrashing. @@ -140,23 +137,43 @@ export class AzureBlobStoragePublish implements PublisherBase { ); // Azure Blob Storage Container file relative path return limiter(async () => { - await uploadPromises.push( - this.storageClient - .getContainerClient(this.containerName) - .getBlockBlobClient(destination) - .uploadFile(filePath), - ); + const response = await this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath); + + if (response._response.status >= 400) { + return { + ...response, + error: new Error( + `Upload failed for ${filePath} with status code ${response._response.status}`, + ), + }; + } + return { + ...response, + error: undefined, + }; }); }); - await Promise.all(promises).then(() => { + const responses = await Promise.all(promises); + + const failed = responses.filter(r => r.error); + if (failed.length === 0) { this.logger.info( - `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + `Successfully uploaded the ${responses.length} generated file(s) for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, ); - }); - return; + } else { + throw new Error( + failed + .map(r => r.error?.message) + .filter(Boolean) + .join(' '), + ); + } } catch (e) { - const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`; + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. ${e.message}`; this.logger.error(errorMessage); throw new Error(errorMessage); } @@ -241,19 +258,11 @@ export class AzureBlobStoragePublish implements PublisherBase { * A helper function which checks if index.html of an Entity's docs site is available. This * can be used to verify if there are any pre-generated docs available to serve. */ - async hasDocsBeenGenerated(entity: Entity): Promise { - return new Promise(resolve => { - const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; - this.storageClient - .getContainerClient(this.containerName) - .getBlockBlobClient(`${entityRootDir}/index.html`) - .exists() - .then((response: boolean) => { - resolve(response); - }) - .catch(() => { - resolve(false); - }); - }); + hasDocsBeenGenerated(entity: Entity): Promise { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + return this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists(); } } diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx deleted file mode 100644 index 30716a61fb..0000000000 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ApiEntity, - EntityName, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import React from 'react'; -import { ApiTypeTitle } from '../ApiDefinitionCard'; - -type EntityRow = { - entity: ApiEntity; - resolved: { - partOfSystemRelationTitle?: string; - partOfSystemRelations: EntityName[]; - ownedByRelationsTitle?: string; - ownedByRelations: EntityName[]; - }; -}; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'entity.metadata.name', - highlight: true, - render: ({ entity }) => ( - {entity.metadata.name} - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Type', - field: 'entity.spec.type', - render: ({ entity }) => , - }, - { - title: 'Description', - field: 'entity.metadata.description', - width: 'auto', - }, -]; - -type Props = { - title: string; - variant?: string; - entities: (ApiEntity | undefined)[]; -}; - -export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { - const tableStyle: React.CSSProperties = { - minWidth: '0', - width: '100%', - }; - - if (variant === 'gridItem') { - tableStyle.height = 'calc(100% - 10px)'; - } - - const rows = entities - // TODO: For now we skip all APIs that we can't find without a warning! - .filter(e => e !== undefined) - .map(entity => { - const partOfSystemRelations = getEntityRelations( - entity, - RELATION_PART_OF, - { - kind: 'system', - }, - ); - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - - return { - entity: entity as ApiEntity, - resolved: { - ownedByRelationsTitle: ownedByRelations - .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) - .join(', '), - ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelations - .map(r => - formatEntityRefTitle(r, { - defaultKind: 'system', - }), - ) - .join(', '), - partOfSystemRelations, - }, - }; - }); - - return ( - - columns={columns} - title={title} - style={tableStyle} - options={{ - // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; - search: false, - paging: false, - actionsColumnIndex: -1, - padding: 'dense', - }} - data={rows} - /> - ); -}; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index 051b9588a0..5d2978a198 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_CONSUMES_API, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -79,7 +74,7 @@ describe('', () => { ); expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); - expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + expect(getByText(/No Component consumes this API/i)).toBeInTheDocument(); }); it('shows consumed APIs', async () => { @@ -108,34 +103,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'openapi', - lifecycle: 'production', - definition: '...', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], - }); - apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ - type: 'openapi', - title: 'OpenAPI', - component: () =>
, + spec: {}, }); const { getByText } = await renderInTestApp( @@ -147,12 +115,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); + expect(getByText('Consumed APIs')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/OpenAPI/)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 8a808a6488..adfa3cd34c 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,70 +19,67 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { ApisTable } from './ApisTable'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; - -const ApisCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_CONSUMES_API, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_CONSUMES_API, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No Component consumes this API.{' '} + + Learn how to consume APIs. + +
+ } + columns={apiEntityColumns} + entities={entities as ApiEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx new file mode 100644 index 0000000000..7b97f0867e --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { HasApisCard } from './HasApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/No API is part of this system/i)).toBeInTheDocument(); + }); + + it('shows related APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx new file mode 100644 index 0000000000..9f1d487b65 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'API', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No API is part of this system.{' '} + + Learn how to add APIs. + +
+ } + columns={apiEntityColumns} + entities={entities as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 482534f58a..0d49cc328d 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_OWNED_BY, - RELATION_PART_OF, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; +import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -79,7 +74,7 @@ describe('', () => { ); expect(getByText(/Provided APIs/i)).toBeInTheDocument(); - expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + expect(getByText(/No component provides this API/i)).toBeInTheDocument(); }); it('shows consumed APIs', async () => { @@ -108,34 +103,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'openapi', - lifecycle: 'production', - definition: '...', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], - }); - apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ - type: 'openapi', - title: 'OpenAPI', - component: () =>
, + spec: {}, }); const { getByText } = await renderInTestApp( @@ -149,10 +117,6 @@ describe('', () => { await waitFor(() => { expect(getByText(/Provided APIs/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/OpenAPI/)).toBeInTheDocument(); - expect(getByText(/MySystem/)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index ed6e893a03..f02390b6d6 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,70 +19,67 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { ApisTable } from './ApisTable'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; - -const ApisCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_PROVIDES_API, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_PROVIDES_API, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component provides this API.{' '} + + Learn how to provide APIs. + +
+ } + columns={apiEntityColumns} + entities={entities as ApiEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/index.ts b/plugins/api-docs/src/components/ApisCards/index.ts index 2a01a1dc6e..9243bdedd2 100644 --- a/plugins/api-docs/src/components/ApisCards/index.ts +++ b/plugins/api-docs/src/components/ApisCards/index.ts @@ -15,4 +15,5 @@ */ export { ConsumedApisCard } from './ConsumedApisCard'; +export { HasApisCard } from './HasApisCard'; export { ProvidedApisCard } from './ProvidedApisCard'; diff --git a/plugins/api-docs/src/components/ApisCards/presets.tsx b/plugins/api-docs/src/components/ApisCards/presets.tsx new file mode 100644 index 0000000000..151cccf846 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/presets.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiEntity } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { EntityTable } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { ApiTypeTitle } from '../ApiDefinitionCard'; + +export function createSpecApiTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + render: entity => , + }; +} + +// TODO: This could be moved to plugin-catalog-react if we wouldn't have a +// special createSpecApiTypeColumn. But this is required to use ApiTypeTitle to +// resolve the display name of an entity. Is the display name really worth it? + +export const apiEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }), + EntityTable.columns.createSystemColumn(), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + createSpecApiTypeColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx deleted file mode 100644 index bd9b0c765f..0000000000 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ComponentEntity, - EntityName, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import React from 'react'; - -type EntityRow = { - entity: ComponentEntity; - resolved: { - partOfSystemRelationTitle?: string; - partOfSystemRelations: EntityName[]; - ownedByRelationsTitle?: string; - ownedByRelations: EntityName[]; - }; -}; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'entity.metadata.name', - highlight: true, - render: ({ entity }) => ( - {entity.metadata.name} - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Type', - field: 'entity.spec.type', - }, - { - title: 'Description', - field: 'entity.metadata.description', - width: 'auto', - }, -]; - -type Props = { - title: string; - variant?: string; - entities: (ComponentEntity | undefined)[]; -}; - -// TODO: In theory this could also be systems! -export const ComponentsTable = ({ - entities, - title, - variant = 'gridItem', -}: Props) => { - const tableStyle: React.CSSProperties = { - minWidth: '0', - width: '100%', - }; - - if (variant === 'gridItem') { - tableStyle.height = 'calc(100% - 10px)'; - } - - const rows = entities - // TODO: For now we skip all Components that we can't find without a warning! - .filter(e => e !== undefined) - .map(entity => { - const partOfSystemRelations = getEntityRelations( - entity, - RELATION_PART_OF, - { - kind: 'system', - }, - ); - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - - return { - entity: entity as ComponentEntity, - resolved: { - ownedByRelationsTitle: ownedByRelations - .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) - .join(', '), - ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelations - .map(r => - formatEntityRefTitle(r, { - defaultKind: 'system', - }), - ) - .join(', '), - partOfSystemRelations, - }, - }; - }); - - return ( - - columns={columns} - title={title} - style={tableStyle} - options={{ - // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; - search: false, - paging: false, - actionsColumnIndex: -1, - padding: 'dense', - }} - data={rows} - /> - ); -}; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index f45a48842d..7944b3f90a 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_API_CONSUMED_BY, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -77,8 +72,8 @@ describe('', () => { , ); - expect(getByText(/Consumers/i)).toBeInTheDocument(); - expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + expect(getByText('Consumers')).toBeInTheDocument(); + expect(getByText(/No component consumes this API/i)).toBeInTheDocument(); }); it('shows consuming components', async () => { @@ -113,28 +108,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'service', - lifecycle: 'production', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], + spec: {}, }); const { getByText } = await renderInTestApp( @@ -146,11 +120,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Consumers/i)).toBeInTheDocument(); + expect(getByText('Consumers')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx index 56d2fb977d..7e4d97fece 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,70 +19,66 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; -import { ComponentsTable } from './ComponentsTable'; - -const ComponentsCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_API_CONSUMED_BY, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_CONSUMED_BY, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component consumes this API.{' '} + + Learn how to consume APIs. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 4c55393d13..ab1751b9ba 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_API_PROVIDED_BY, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -77,8 +72,8 @@ describe('', () => { , ); - expect(getByText(/Providers/i)).toBeInTheDocument(); - expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + expect(getByText('Providers')).toBeInTheDocument(); + expect(getByText(/No component provides this API/i)).toBeInTheDocument(); }); it('shows providing components', async () => { @@ -113,28 +108,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'service', - lifecycle: 'production', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], + spec: {}, }); const { getByText } = await renderInTestApp( @@ -146,11 +120,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Providers/i)).toBeInTheDocument(); + expect(getByText('Providers')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx index 959ff3feed..d755d8b5af 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,70 +19,66 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; -import { ComponentsTable } from './ComponentsTable'; - -const ComponentsCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_API_PROVIDED_BY, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_PROVIDED_BY, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component provides this API.{' '} + + Learn how to provide APIs. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx deleted file mode 100644 index 3e71168dde..0000000000 --- a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Button, makeStyles, Typography } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { CodeSnippet, EmptyState } from '@backstage/core'; - -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: example -spec: - type: service - lifecycle: production - owner: guest - consumesApis: - - example-api -`; - -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); - -export const MissingConsumesApisEmptyState = () => { - const classes = useStyles(); - return ( - - Components can consume APIs that are displayed on this page. You need - to fill the consumesApis field to enable this tool. - - } - action={ - <> - - Link an API to your component as shown in the highlighted example - below: - -
- -
- - - } - /> - ); -}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx deleted file mode 100644 index 9bf3465a34..0000000000 --- a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Button, makeStyles, Typography } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { CodeSnippet, EmptyState } from '@backstage/core'; - -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: example -spec: - type: service - lifecycle: production - owner: guest - providesApis: - - example-api -`; - -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); - -export const MissingProvidesApisEmptyState = () => { - const classes = useStyles(); - return ( - - Components can implement APIs that are displayed on this page. You - need to fill the providesApis field to enable this tool. - - } - action={ - <> - - Link an API to your component as shown in the highlighted example - below: - -
- -
- - - } - /> - ); -}; diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index e7484f77c3..8bab7f28f4 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -24,4 +24,5 @@ export { EntityConsumingComponentsCard, EntityProvidedApisCard, EntityProvidingComponentsCard, + EntityHasApisCard, } from './plugin'; diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 429fd60095..1d57cc6cc2 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -106,3 +106,11 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide( }, }), ); + +export const EntityHasApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/ApisCards').then(m => m.HasApisCard), + }, + }), +); diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx index f92f03c5cb..49fce69b5a 100644 --- a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { configApiRef, InfoCard, useApi } from '@backstage/core'; +import { + configApiRef, + InfoCard, + InfoCardVariants, + useApi, +} from '@backstage/core'; import { Step, StepContent, Stepper } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import React, { useMemo } from 'react'; @@ -39,7 +44,7 @@ type Props = { flow: ImportFlows, defaults: StepperProvider, ) => StepperProvider; - variant?: string; + variant?: InfoCardVariants; opts?: StepperProviderOpts; }; diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx new file mode 100644 index 0000000000..33bbbd007d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; + +describe('', () => { + it('shows empty table', async () => { + const { getByText } = await renderInTestApp( + EMPTY} + columns={[]} + />, + ); + + expect(getByText('Entities')).toBeInTheDocument(); + expect(getByText('EMPTY')).toBeInTheDocument(); + }); + + it('shows entities', async () => { + const entities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-entity', + }, + spec: {}, + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={[ + { + title: 'Name', + field: 'metadata.name', + }, + ]} + />, + ); + + await waitFor(() => { + expect(getByText('my-entity')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx new file mode 100644 index 0000000000..afcaaea305 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import React, { ReactNode } from 'react'; +import * as columnFactories from './columns'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +type Props = { + title: string; + variant?: 'gridItem'; + entities: T[]; + emptyContent?: ReactNode; + columns: TableColumn[]; +}; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export function EntityTable({ + entities, + title, + emptyContent, + variant = 'gridItem', + columns, +}: Props) { + const classes = useStyles(); + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + return ( + + columns={columns} + title={title} + style={tableStyle} + emptyContent={ + emptyContent &&
{emptyContent}
+ } + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + data={entities} + /> + ); +} + +EntityTable.columns = columnFactories; + +EntityTable.systemEntityColumns = systemEntityColumns; + +EntityTable.componentEntityColumns = componentEntityColumns; diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx new file mode 100644 index 0000000000..7ca99b1100 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -0,0 +1,158 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import React from 'react'; +import { getEntityRelations } from '../../utils'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '../EntityRefLink'; + +export function createEntityRefColumn({ + defaultKind, +}: { + defaultKind?: string; +}): TableColumn { + function formatContent(entity: T): string { + return formatEntityRefTitle(entity, { + defaultKind, + }); + } + + return { + title: 'Name', + highlight: true, + customFilterAndSearch(filter, entity) { + // TODO: We could implement this more efficiently, like searching over + // each field that is displayed individually (kind, namespace, name). + // but that migth confuse the user as it will behave different than a + // simple text search. + // Another alternative would be to cache the values. But writing them + // into the entity feels bad too. + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + // TODO: We could implement this more efficiently by comparing field by field. + // This has similar issues as above. + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => ( + + ), + }; +} + +export function createEntityRelationColumn({ + title, + relation, + defaultKind, + filter: entityFilter, +}: { + title: string; + relation: string; + defaultKind?: string; + filter?: { kind: string }; +}): TableColumn { + function getRelations(entity: T): EntityName[] { + return getEntityRelations(entity, relation, entityFilter); + } + + function formatContent(entity: T): string { + return getRelations(entity) + .map(r => formatEntityRefTitle(r, { defaultKind })) + .join(', '); + } + + return { + title, + customFilterAndSearch(filter, entity) { + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => { + return ( + + ); + }, + }; +} + +export function createOwnerColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Owner', + relation: RELATION_OWNED_BY, + defaultKind: 'group', + }); +} + +export function createDomainColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Domain', + relation: RELATION_PART_OF, + defaultKind: 'domain', + filter: { + kind: 'domain', + }, + }); +} + +export function createSystemColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'System', + relation: RELATION_PART_OF, + defaultKind: 'system', + filter: { + kind: 'system', + }, + }); +} + +export function createMetadataDescriptionColumn< + T extends Entity +>(): TableColumn { + return { + title: 'Description', + field: 'metadata.description', + width: 'auto', + }; +} + +export function createSpecLifecycleColumn(): TableColumn { + return { + title: 'Lifecycle', + field: 'spec.lifecycle', + }; +} + +export function createSpecTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + }; +} diff --git a/plugins/catalog-react/src/components/EntityTable/index.ts b/plugins/catalog-react/src/components/EntityTable/index.ts new file mode 100644 index 0000000000..8a01b9baae --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityTable } from './EntityTable'; diff --git a/plugins/catalog-react/src/components/EntityTable/presets.test.tsx b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx new file mode 100644 index 0000000000..b6b8c26e37 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + RELATION_OWNED_BY, + RELATION_PART_OF, + SystemEntity, +} from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +describe('systemEntityColumns', () => { + it('shows systems', async () => { + const entities: SystemEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'Domain', + name: 'my-domain', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={systemEntityColumns} + />, + ); + + await waitFor(() => { + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('my-namespace/my-domain')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('Some description')).toBeInTheDocument(); + }); + }); +}); + +describe('componentEntityColumns', () => { + it('shows components', async () => { + const entities: ComponentEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'my-system', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={componentEntityColumns} + />, + ); + + await waitFor(() => { + expect(getByText('my-namespace/my-component')).toBeInTheDocument(); + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText('Some description')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/presets.tsx b/plugins/catalog-react/src/components/EntityTable/presets.tsx new file mode 100644 index 0000000000..7ce6b75532 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity, SystemEntity } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { + createDomainColumn, + createEntityRefColumn, + createMetadataDescriptionColumn, + createOwnerColumn, + createSpecLifecycleColumn, + createSpecTypeColumn, + createSystemColumn, +} from './columns'; + +export const systemEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'system' }), + createDomainColumn(), + createOwnerColumn(), + createMetadataDescriptionColumn(), +]; + +export const componentEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'component' }), + createSystemColumn(), + createOwnerColumn(), + createSpecTypeColumn(), + createSpecLifecycleColumn(), + createMetadataDescriptionColumn(), +]; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index fcfa8207b3..5181b8f0ea 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './EntityRefLink'; export * from './EntityProvider'; +export * from './EntityRefLink'; +export * from './EntityTable'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 77de70b8d4..2ecf6e9a90 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -15,3 +15,4 @@ */ export { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; export { useEntityCompoundName } from './useEntityCompoundName'; +export { useRelatedEntities } from './useRelatedEntities'; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/catalog-react/src/hooks/useRelatedEntities.ts similarity index 51% rename from plugins/api-docs/src/components/useRelatedEntities.ts rename to plugins/catalog-react/src/hooks/useRelatedEntities.ts index 5c01d38189..baf6cf5fc7 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.ts @@ -15,36 +15,45 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { useAsyncRetry } from 'react-use'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api'; -// TODO: Maybe this hook is interesting for others too? export function useRelatedEntities( entity: Entity, - type: string, + { type, kind }: { type?: string; kind?: string }, ): { - entities: (Entity | undefined)[] | undefined; + entities: Entity[] | undefined; loading: boolean; error: Error | undefined; } { const catalogApi = useApi(catalogApiRef); - const { loading, value, error } = useAsyncRetry< - (Entity | undefined)[] - >(async () => { + const { loading, value: entities, error } = useAsync(async () => { const relations = - entity.relations && entity.relations.filter(r => r.type === type); + entity.relations && + entity.relations.filter( + r => + (!type || r.type.toLowerCase() === type.toLowerCase()) && + (!kind || r.target.kind.toLowerCase() === kind.toLowerCase()), + ); if (!relations) { return []; } - return await Promise.all( + // TODO: This code could be more efficient if there was an endpoint in the + // backend that either returns the relations of entity (filtered by type) + // or if there is a way to perform a batch request by entity name. However, + // such an implementation would probably be better placed in the graphql API. + const results = await Promise.all( relations?.map(r => catalogApi.getEntityByName(r.target)), ); + // Skip entities that where not found, for example if a relation references + // an entity that doesn't exist. + return results.filter(e => e) as Entity[]; }, [entity, type]); return { - entities: value, + entities, loading, error, }; diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 04c2a78869..2d6cd6bca8 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -79,7 +79,7 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo { type AboutCardProps = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export function AboutCard({ variant }: AboutCardProps) { diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx new file mode 100644 index 0000000000..58df0a53f8 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasComponentsCard } from './HasComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Components')).toBeInTheDocument(); + expect( + getByText(/No component is part of this system/i), + ).toBeInTheDocument(); + }); + + it('shows related components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Components')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx new file mode 100644 index 0000000000..6485d4f525 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component is part of this system.{' '} + + Learn how to add components. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/index.ts b/plugins/catalog/src/components/HasComponentsCard/index.ts similarity index 78% rename from plugins/api-docs/src/components/EmptyState/index.ts rename to plugins/catalog/src/components/HasComponentsCard/index.ts index d195c43eb1..059392c3e8 100644 --- a/plugins/api-docs/src/components/EmptyState/index.ts +++ b/plugins/catalog/src/components/HasComponentsCard/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; -export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; +export { HasComponentsCard } from './HasComponentsCard'; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx new file mode 100644 index 0000000000..79b69d35ff --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasSubcomponentsCard } from './HasSubcomponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-components', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect( + getByText(/No subcomponent is part of this component/i), + ).toBeInTheDocument(); + }); + + it('shows related subcomponents', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx new file mode 100644 index 0000000000..c2635b68e1 --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No subcomponent is part of this component.{' '} + + Learn how to add subcomponents. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts similarity index 57% rename from plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx rename to plugins/catalog/src/components/HasSubcomponentsCard/index.ts index de753713c3..cef0221d3b 100644 --- a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts @@ -14,15 +14,4 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/consumesApis:/i)).toBeInTheDocument(); - }); -}); +export { HasSubcomponentsCard } from './HasSubcomponentsCard'; diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx new file mode 100644 index 0000000000..08923a59c8 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasSystemsCard } from './HasSystemsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/No system is part of this domain/i)).toBeInTheDocument(); + }); + + it('shows related systems', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'System', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx new file mode 100644 index 0000000000..9faa640060 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No system is part of this domain.{' '} + + Learn how to add systems. + + + } + columns={EntityTable.systemEntityColumns} + entities={entities as SystemEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasSystemsCard/index.ts b/plugins/catalog/src/components/HasSystemsCard/index.ts new file mode 100644 index 0000000000..a29d257e7e --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { HasSystemsCard } from './HasSystemsCard'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index ce8518eb55..800c5c4693 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -20,10 +20,13 @@ export { EntityPageLayout } from './components/EntityPageLayout'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; export { + CatalogEntityPage, + CatalogIndexPage, catalogPlugin, catalogPlugin as plugin, - CatalogIndexPage, - CatalogEntityPage, EntityAboutCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, EntityLinksCard, } from './plugin'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index b613b23372..19dc778280 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -17,10 +17,10 @@ import { CatalogClient } from '@backstage/catalog-client'; import { createApiFactory, - createPlugin, - discoveryApiRef, createComponentExtension, + createPlugin, createRoutableExtension, + discoveryApiRef, identityApiRef, } from '@backstage/core'; import { @@ -64,9 +64,7 @@ export const CatalogIndexPage = catalogPlugin.provide( export const CatalogEntityPage = catalogPlugin.provide( createRoutableExtension({ component: () => - import('./components/CatalogEntityPage/CatalogEntityPage').then( - m => m.CatalogEntityPage, - ), + import('./components/CatalogEntityPage').then(m => m.CatalogEntityPage), mountPoint: entityRouteRef, }), ); @@ -87,3 +85,32 @@ export const EntityLinksCard = catalogPlugin.provide( }, }), ); + +export const EntityHasSystemsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasSystemsCard').then(m => m.HasSystemsCard), + }, + }), +); + +export const EntityHasComponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasComponentsCard').then(m => m.HasComponentsCard), + }, + }), +); + +export const EntityHasSubcomponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasSubcomponentsCard').then( + m => m.HasSubcomponentsCard, + ), + }, + }), +); diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index dae68a5ce2..86adb8673f 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -11,6 +11,8 @@ At Spotify, we find that cloud costs are optimized organically when: Cost Insights shows trends over time, at the granularity of Backstage catalog entities - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations. +Learn more with the Backstage blog post [New Cost Insights plugin: The engineer's solution to taming cloud costs](https://backstage.io/blog/2020/10/22/cost-insights-plugin). + ## Install ```bash diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 202bf5aa92..4ce667eb93 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -13,29 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { readGitHubIntegrationConfigs } from '@backstage/integration'; -import { WorkflowRunStatus } from '../WorkflowRunStatus'; import { - Link, - Theme, - makeStyles, - LinearProgress, - Typography, -} from '@material-ui/core'; -import { - InfoCard, - StructuredMetadataTable, configApiRef, errorApiRef, + InfoCard, + InfoCardVariants, + StructuredMetadataTable, useApi, } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + LinearProgress, + Link, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import React, { useEffect } from 'react'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; const useStyles = makeStyles({ externalLinkIcon: { @@ -125,7 +126,7 @@ type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; branch: string; - variant?: string; + variant?: InfoCardVariants; }; export const LatestWorkflowsForBranchCard = ({ diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 4425bc8364..30807482a4 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -19,6 +19,7 @@ import { EmptyState, errorApiRef, InfoCard, + InfoCardVariants, Table, useApi, } from '@backstage/core'; @@ -39,7 +40,7 @@ export type Props = { branch?: string; dense?: boolean; limit?: number; - variant?: string; + variant?: InfoCardVariants; }; export const RecentWorkflowRunsCard = ({ diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 6fc544bcea..0d7c2fe919 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -13,13 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { DateTime, Duration } from 'luxon'; -import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import { + InfoCard, + InfoCardVariants, + StructuredMetadataTable, +} from '@backstage/core'; +import { LinearProgress, Link, makeStyles, Theme } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; -import { useBuilds } from '../useBuilds'; +import { DateTime, Duration } from 'luxon'; +import React from 'react'; import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import { useBuilds } from '../useBuilds'; import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; const useStyles = makeStyles({ @@ -76,7 +80,7 @@ export const LatestRunCard = ({ variant, }: { branch: string; - variant?: string; + variant?: InfoCardVariants; }) => { const { owner, repo } = useProjectSlugFromEntity(); const [{ builds, loading }] = useBuilds(owner, repo, branch); diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx index d30b9a3312..693a8b6ec6 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -13,16 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; import { InfoCard, + InfoCardVariants, Progress, StatusError, StatusOK, StatusWarning, StructuredMetadataTable, } from '@backstage/core'; +import React from 'react'; +import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import AuditStatusIcon from '../AuditStatusIcon'; @@ -96,7 +97,7 @@ export const LastLighthouseAuditCard = ({ variant, }: { dense?: boolean; - variant?: string; + variant?: InfoCardVariants; }) => { const { value: website, loading, error } = useWebsiteForEntity(); diff --git a/plugins/org/package.json b/plugins/org/package.json index 4e12ad8837..4a278a22f5 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,6 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.1", "@backstage/core": "^0.6.1", + "@backstage/core-api": "^0.2.9", "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -29,6 +30,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "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": "^15.3.3" }, diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx new file mode 100644 index 0000000000..39d30d8165 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { GroupEntity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { GroupProfileCard } from '.'; + +export default { + title: 'Plugins/Org/Group Profile Card', + component: GroupProfileCard, +}; + +const dummyDepartment = { + type: 'childOf', + target: { + namespace: 'default', + kind: 'group', + name: 'department-a', + }, +}; + +const defaultEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, + relations: [dummyDepartment], +}; + +export const Default = () => ( + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 9a72606c16..ce13f8391f 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -20,10 +20,10 @@ import { RELATION_CHILD_OF, RELATION_PARENT_OF, } from '@backstage/catalog-model'; -import { Avatar, InfoCard } from '@backstage/core'; +import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core'; import { - getEntityRelations, entityRouteParams, + getEntityRelations, useEntity, } from '@backstage/plugin-catalog-react'; import { @@ -81,7 +81,7 @@ export const GroupProfileCard = ({ }: { /** @deprecated The entity is now grabbed from context instead */ entity?: GroupEntity; - variant: string; + variant?: InfoCardVariants; }) => { const group = useEntity().entity as GroupEntity; const { @@ -117,7 +117,7 @@ export const GroupProfileCard = ({ - + {profile.email} diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx new file mode 100644 index 0000000000..20fb3a10ec --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { EntityContext, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { ApiProvider, ApiRegistry } from '@backstage/core-api'; + +import { MembersListCard } from '.'; + +export default { + title: 'Plugins/Org/Group Members List Card', + component: MembersListCard, +}; + +const makeUser = ({ + name, + uid, + displayName, + email, +}: { + name: string; + uid: string; + displayName: string; + email: string; +}) => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name, + uid, + }, + spec: { + profile: { + displayName, + email, + picture: `https://avatars.dicebear.com/api/avataaars/${email}.svg?background=%23fff`, + }, + }, + relations: [ + { + type: 'memberOf', + target: { + namespace: 'default', + kind: 'group', + name: 'team-a', + }, + }, + ], +}); + +const defaultEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, +}; + +const alice = makeUser({ + name: 'alice', + uid: '123', + displayName: 'Alice Doe', + email: 'alice@example.com', +}); +const bob = makeUser({ + name: 'bob', + uid: '456', + displayName: 'Bob Jones', + email: 'bob@example.com', +}); + +const catalogApi = (items: Entity[]) => ({ + getEntities: () => Promise.resolve({ items }), +}); + +const apiRegistry = (items: Entity[]) => + ApiRegistry.from([[catalogApiRef, catalogApi(items)]]); + +export const Default = () => ( + + + + + + + + + + + +); + +export const Empty = () => ( + + + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 7f8ede2eb7..e60e22a8f2 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -47,7 +47,7 @@ const useStyles = makeStyles((theme: Theme) => borderRadius: '4px', overflow: 'visible', position: 'relative', - margin: theme.spacing(3, 0, 1), + margin: theme.spacing(4, 1, 1), flex: '1', minWidth: '0px', }, @@ -69,7 +69,7 @@ const MemberComponent = ({ const displayName = profile?.displayName ?? metaName; return ( - + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + }, + spec: { + type, + }, + relations: [ + { + type: 'ownedBy', + target: { + namespace: 'default', + kind: 'Group', + name: 'team-a', + }, + }, + ], +}); + +const serviceA = makeComponent({ type: 'service', name: 'service-a' }); +const serviceB = makeComponent({ type: 'service', name: 'service-a' }); +const websiteA = makeComponent({ type: 'website', name: 'website-a' }); + +const catalogApi: Partial = { + getEntities: () => Promise.resolve({ items: [serviceA, serviceB, websiteA] }), +}; + +const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); + +export const Default = () => ( + + + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index 80e80bddcc..30f3bf0967 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { InfoCard, Progress, useApi } from '@backstage/core'; +import { InfoCard, InfoCardVariants, Progress, useApi } from '@backstage/core'; import { catalogApiRef, isOwnerOf, @@ -121,7 +121,7 @@ export const OwnershipCard = ({ }: { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant: string; + variant?: InfoCardVariants; }) => { const { entity } = useEntity(); const catalogApi = useApi(catalogApiRef); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx new file mode 100644 index 0000000000..b30427e923 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { UserEntity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { UserProfileCard } from '.'; + +export default { + title: 'Plugins/Org/User Profile Card', + component: UserProfileCard, +}; + +const dummyGroup = { + type: 'memberOf', + target: { + namespace: 'default', + kind: 'group', + name: 'team-a', + }, +}; + +const defaultEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + }, + spec: { + profile: { + displayName: 'Guest User', + email: 'guest@example.com', + picture: + 'https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff', + }, + memberOf: ['team-a'], + }, + relations: [dummyGroup], +}; + +export const Default = () => ( + + + + + + + + + +); + +const noImageEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + }, + spec: { + profile: { + displayName: 'Guest User', + email: 'guest@example.com', + }, + memberOf: ['team-a'], + }, + relations: [dummyGroup], +}; + +export const NoImage = () => ( + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index a4969f615b..cf558fd55d 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -18,7 +18,7 @@ import { RELATION_MEMBER_OF, UserEntity, } from '@backstage/catalog-model'; -import { Avatar, InfoCard } from '@backstage/core'; +import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core'; import { entityRouteParams, useEntity } from '@backstage/plugin-catalog-react'; import { Box, @@ -73,7 +73,7 @@ export const UserProfileCard = ({ }: { /** @deprecated The entity is now grabbed from context instead */ entity?: UserEntity; - variant: string; + variant?: InfoCardVariants; }) => { const user = useEntity().entity as UserEntity; const { @@ -95,11 +95,11 @@ export const UserProfileCard = ({ return ( } variant={variant}> - + - + {profile?.email && ( diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index c430075b95..93a9ce3e4b 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -37,6 +37,7 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/react": "^16.9", "classnames": "^2.2.6", "luxon": "1.25.0", "react": "^16.13.1", diff --git a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx index bf0d5d2587..3d2e4d0edd 100644 --- a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx +++ b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { ListItem, - ListItemIcon, ListItemSecondaryAction, Tooltip, ListItemText, @@ -25,13 +24,16 @@ import { IconButton, Link, Typography, + Chip, } from '@material-ui/core'; -import { StatusError, StatusWarning } from '@backstage/core'; +import Done from '@material-ui/icons/Done'; +import Warning from '@material-ui/icons/Warning'; import { DateTime, Duration } from 'luxon'; import { Incident } from '../types'; import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { BackstageTheme } from '@backstage/theme'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ denseListIcon: { marginRight: 0, display: 'flex', @@ -42,10 +44,21 @@ const useStyles = makeStyles({ listItemPrimary: { fontWeight: 'bold', }, - listItemIcon: { - minWidth: '1em', + warning: { + borderColor: theme.palette.status.warning, + color: theme.palette.status.warning, + '& *': { + color: theme.palette.status.warning, + }, }, -}); + error: { + borderColor: theme.palette.status.error, + color: theme.palette.status.error, + '& *': { + color: theme.palette.status.error, + }, + }, +})); type Props = { incident: Incident; @@ -62,19 +75,24 @@ export const IncidentListItem = ({ incident }: Props) => { return ( - - -
- {incident.status === 'triggered' ? ( - - ) : ( - - )} -
-
-
+ : } + className={ + incident.status === 'triggered' + ? classes.error + : classes.warning + } + /> + {incident.title} + + } primaryTypographyProps={{ variant: 'body1', className: classes.listItemPrimary, diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx index 9da9f9f11b..88500fbe9b 100644 --- a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -84,13 +84,7 @@ describe('Incidents', () => { }, ] as Incident[], ); - const { - getByText, - getByTitle, - getAllByTitle, - getByLabelText, - queryByTestId, - } = render( + const { getByText, getAllByTitle, queryByTestId } = render( wrapInTestApp( @@ -102,10 +96,10 @@ describe('Incidents', () => { expect(getByText('title2')).toBeInTheDocument(); expect(getByText('person1')).toBeInTheDocument(); expect(getByText('person2')).toBeInTheDocument(); - expect(getByTitle('triggered')).toBeInTheDocument(); - expect(getByTitle('acknowledged')).toBeInTheDocument(); - expect(getByLabelText('Status error')).toBeInTheDocument(); - expect(getByLabelText('Status warning')).toBeInTheDocument(); + expect(getByText('triggered')).toBeInTheDocument(); + expect(getByText('acknowledged')).toBeInTheDocument(); + expect(queryByTestId('chip-triggered')).toBeInTheDocument(); + expect(queryByTestId('chip-acknowledged')).toBeInTheDocument(); // assert links, mailto and hrefs, date calculation expect(getAllByTitle('View in PagerDuty').length).toEqual(2); diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx index e03fb0fdbe..7ec425a062 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -17,68 +17,38 @@ import React, { useState, useCallback } from 'react'; import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { - Button, - makeStyles, - Card, - CardHeader, - Divider, - CardContent, -} from '@material-ui/core'; +import { Card, CardHeader, Divider, CardContent } from '@material-ui/core'; import { Incidents } from './Incident'; import { EscalationPolicy } from './Escalation'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { pagerDutyApiRef, UnauthorizedError } from '../api'; import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; -import { TriggerDialog } from './TriggerDialog'; import { MissingTokenError } from './Errors/MissingTokenError'; import WebIcon from '@material-ui/icons/Web'; - -const useStyles = makeStyles({ - triggerAlarm: { - paddingTop: 0, - paddingBottom: 0, - fontSize: '0.7rem', - textTransform: 'uppercase', - fontWeight: 600, - letterSpacing: 1.2, - lineHeight: 1.5, - '&:hover, &:focus, &.focus': { - backgroundColor: 'transparent', - textDecoration: 'none', - }, - }, -}); - -export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key'; +import { PAGERDUTY_INTEGRATION_KEY } from './constants'; +import { TriggerButton, useShowDialog } from './TriggerButton'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]); -type Props = { - /** @deprecated The entity is now grabbed from context instead */ - entity?: Entity; -}; - -export const PagerDutyCard = (_props: Props) => { - const classes = useStyles(); +export const PagerDutyCard = () => { const { entity } = useEntity(); const api = useApi(pagerDutyApiRef); - const [showDialog, setShowDialog] = useState(false); const [refreshIncidents, setRefreshIncidents] = useState(false); const integrationKey = entity.metadata.annotations![ PAGERDUTY_INTEGRATION_KEY ]; + const setShowDialog = useShowDialog()[1]; + + const showDialog = useCallback(() => { + setShowDialog(true); + }, [setShowDialog]); const handleRefresh = useCallback(() => { setRefreshIncidents(x => !x); }, []); - const handleDialog = useCallback(() => { - setShowDialog(x => !x); - }, []); - const { value: service, loading, error } = useAsync(async () => { const services = await api.getServiceByIntegrationKey(integrationKey); @@ -114,17 +84,8 @@ export const PagerDutyCard = (_props: Props) => { const triggerLink = { label: 'Create Incident', - action: ( - - ), - icon: , + action: , + icon: , }; return ( @@ -140,13 +101,6 @@ export const PagerDutyCard = (_props: Props) => { refreshIncidents={refreshIncidents} /> - ); diff --git a/plugins/pagerduty/src/components/TriggerButton/index.tsx b/plugins/pagerduty/src/components/TriggerButton/index.tsx new file mode 100644 index 0000000000..833e01d8aa --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerButton/index.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useCallback, PropsWithChildren } from 'react'; +import { createGlobalState } from 'react-use'; +import { makeStyles, Button } from '@material-ui/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { BackstageTheme } from '@backstage/theme'; + +import { TriggerDialog } from '../TriggerDialog'; +import { PAGERDUTY_INTEGRATION_KEY } from '../constants'; + +export interface TriggerButtonProps { + design: 'link' | 'button'; + onIncidentCreated?: () => void; +} + +const useStyles = makeStyles(theme => ({ + buttonStyle: { + backgroundColor: theme.palette.error.main, + color: theme.palette.error.contrastText, + '&:hover': { + backgroundColor: theme.palette.error.dark, + }, + }, + triggerAlarm: { + paddingTop: 0, + paddingBottom: 0, + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + lineHeight: 1.5, + '&:hover, &:focus, &.focus': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }, +})); + +export const useShowDialog = createGlobalState(false); + +export function TriggerButton({ + design, + onIncidentCreated, + children, +}: PropsWithChildren) { + const { buttonStyle, triggerAlarm } = useStyles(); + const { entity } = useEntity(); + const [dialogShown = false, setDialogShown] = useShowDialog(); + + const showDialog = useCallback(() => { + setDialogShown(true); + }, [setDialogShown]); + const hideDialog = useCallback(() => { + setDialogShown(false); + }, [setDialogShown]); + + const integrationKey = entity.metadata.annotations![ + PAGERDUTY_INTEGRATION_KEY + ]; + + return ( + <> + + + + ); +} diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx index c390ed1f82..c2084a41ed 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -35,7 +35,7 @@ type Props = { integrationKey: string; showDialog: boolean; handleDialog: () => void; - onIncidentCreated: () => void; + onIncidentCreated?: () => void; }; export const TriggerDialog = ({ @@ -69,11 +69,17 @@ export const TriggerDialog = ({ useEffect(() => { if (value) { - alertApi.post({ - message: `Alarm successfully triggered by ${userName}`, - }); - onIncidentCreated(); - handleDialog(); + (async () => { + alertApi.post({ + message: `Alarm successfully triggered by ${userName}`, + }); + + handleDialog(); + + // The pager duty API isn't always returning the newly created alarm immediately + await new Promise(resolve => setTimeout(resolve, 1000)); + onIncidentCreated?.(); + })(); } }, [value, alertApi, handleDialog, userName, onIncidentCreated]); diff --git a/plugins/pagerduty/src/components/constants.ts b/plugins/pagerduty/src/components/constants.ts new file mode 100644 index 0000000000..a7c32a362e --- /dev/null +++ b/plugins/pagerduty/src/components/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key'; diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts index 2dfedab164..92c3c8e73c 100644 --- a/plugins/pagerduty/src/index.ts +++ b/plugins/pagerduty/src/index.ts @@ -23,6 +23,7 @@ export { isPluginApplicableToEntity as isPagerDutyAvailable, PagerDutyCard, } from './components/PagerDutyCard'; +export { TriggerButton } from './components/TriggerButton'; export { PagerDutyClient, pagerDutyApiRef, diff --git a/plugins/scaffolder-backend/migrations/20210120143715_init.js b/plugins/scaffolder-backend/migrations/20210120143715_init.js index 256ec7d423..5bde7a7c68 100644 --- a/plugins/scaffolder-backend/migrations/20210120143715_init.js +++ b/plugins/scaffolder-backend/migrations/20210120143715_init.js @@ -77,7 +77,7 @@ exports.up = async function up(knex) { exports.down = async function down(knex) { if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('task_events', table => { - table.dropIndex([], 'ctask_events_task_id_idx'); + table.dropIndex([], 'task_events_task_id_idx'); }); } await knex.schema.dropTable('task_events'); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index 5b74ee81ca..81ccdeb026 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -18,14 +18,20 @@ import path from 'path'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +import { + GitHubIntegrationConfig, + GithubCredentialsProvider, +} from '@backstage/integration'; export class GithubPreparer implements PreparerBase { static fromConfig(config: GitHubIntegrationConfig) { - return new GithubPreparer({ token: config.token }); + const credentialsProvider = GithubCredentialsProvider.create(config); + return new GithubPreparer({ credentialsProvider }); } - constructor(private readonly config: { token?: string }) {} + constructor( + private readonly config: { credentialsProvider: GithubCredentialsProvider }, + ) {} async prepare({ url, workspacePath, logger }: PreparerOptions) { const parsedGitUrl = parseGitUrl(url); @@ -36,10 +42,14 @@ export class GithubPreparer implements PreparerBase { parsedGitUrl.filepath ?? '', ); - const git = this.config.token + const { token } = await this.config.credentialsProvider.getCredentials({ + url, + }); + + const git = token ? Git.fromAuth({ username: 'x-access-token', - password: this.config.token, + password: token, logger, }) : Git.fromAuth({ logger }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 407a1633c2..a449eddd5d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -16,7 +16,10 @@ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { initRepoAndPush } from './helpers'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +import { + GitHubIntegrationConfig, + GithubCredentialsProvider, +} from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import { Octokit } from '@octokit/rest'; import path from 'path'; @@ -28,27 +31,24 @@ export class GithubPublisher implements PublisherBase { config: GitHubIntegrationConfig, { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, ) { - if (!config.token) { + if (!config.token && !config.apps) { return undefined; } - const githubClient = new Octokit({ - auth: config.token, - baseUrl: config.apiBaseUrl, - }); + const credentialsProvider = GithubCredentialsProvider.create(config); return new GithubPublisher({ - token: config.token, - client: githubClient, + credentialsProvider, repoVisibility, + apiBaseUrl: config.apiBaseUrl, }); } constructor( private readonly config: { - token: string; - client: Octokit; + credentialsProvider: GithubCredentialsProvider; repoVisibility: RepoVisibilityOptions; + apiBaseUrl: string | undefined; }, ) {} @@ -59,9 +59,25 @@ export class GithubPublisher implements PublisherBase { }: PublisherOptions): Promise { const { owner, name } = parseGitUrl(values.storePath); + const { token } = await this.config.credentialsProvider.getCredentials({ + url: values.storePath, + }); + + if (!token) { + throw new Error( + `No token could be acquired for URL: ${values.storePath}`, + ); + } + + const client = new Octokit({ + auth: token, + baseUrl: this.config.apiBaseUrl, + }); + const description = values.description as string; const access = values.access as string; const remoteUrl = await this.createRemote({ + client, description, access, name, @@ -73,7 +89,7 @@ export class GithubPublisher implements PublisherBase { remoteUrl, auth: { username: 'x-access-token', - password: this.config.token, + password: token, }, logger, }); @@ -86,27 +102,28 @@ export class GithubPublisher implements PublisherBase { } private async createRemote(opts: { + client: Octokit; access: string; name: string; owner: string; description: string; }) { - const { access, description, owner, name } = opts; + const { client, access, description, owner, name } = opts; - const user = await this.config.client.users.getByUsername({ + const user = await client.users.getByUsername({ username: owner, }); const repoCreationPromise = user.data.type === 'Organization' - ? this.config.client.repos.createInOrg({ + ? client.repos.createInOrg({ name, org: owner, private: this.config.repoVisibility !== 'public', visibility: this.config.repoVisibility, description, }) - : this.config.client.repos.createForAuthenticatedUser({ + : client.repos.createForAuthenticatedUser({ name, private: this.config.repoVisibility === 'private', description, @@ -116,7 +133,7 @@ export class GithubPublisher implements PublisherBase { if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); - await this.config.client.teams.addOrUpdateRepoPermissionsInOrg({ + await client.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, team_slug: team, owner, @@ -125,7 +142,7 @@ export class GithubPublisher implements PublisherBase { }); // no need to add access if it's the person who own's the personal account } else if (access && access !== owner) { - await this.config.client.repos.addCollaborator({ + await client.repos.addCollaborator({ owner, repo: name, username: access, diff --git a/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx index c52b76c4bc..b1ba8c7cc2 100644 --- a/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx +++ b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx @@ -14,24 +14,25 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; +import { Entity } from '@backstage/catalog-model'; import { EmptyState, ErrorApi, errorApiRef, InfoCard, + InfoCardVariants, MissingAnnotationEmptyState, Progress, useApi, } from '@backstage/core'; -import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; +import React, { useEffect } from 'react'; import { useAsync } from 'react-use'; import { sentryApiRef } from '../../api'; +import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; import { SENTRY_PROJECT_SLUG_ANNOTATION, useProjectSlug, } from '../useProjectSlug'; -import { Entity } from '@backstage/catalog-model'; export const SentryIssuesWidget = ({ entity, @@ -40,7 +41,7 @@ export const SentryIssuesWidget = ({ }: { entity: Entity; statsFor?: '24h' | '12h'; - variant?: string; + variant?: InfoCardVariants; }) => { const errorApi = useApi(errorApiRef); const sentryApi = useApi(sentryApiRef); diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx index 9d1fc415ff..838e3d6609 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx @@ -16,7 +16,7 @@ import { BackstageTheme } from '@backstage/theme'; import { makeStyles } from '@material-ui/core/styles'; -import { useTheme } from '@material-ui/styles'; +import { useTheme } from '@material-ui/core'; import { Circle } from 'rc-progress'; import React from 'react'; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index 5577048fa9..12ed5b3bf4 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { EmptyState, InfoCard, + InfoCardVariants, MissingAnnotationEmptyState, Progress, useApi, @@ -88,7 +89,7 @@ export const SonarQubeCard = ({ duplicationRatings = defaultDuplicationRatings, }: { entity?: Entity; - variant?: string; + variant?: InfoCardVariants; duplicationRatings?: DuplicationRating[]; }) => { const { entity } = useEntity(); diff --git a/plugins/splunk-on-call/.eslintrc.js b/plugins/splunk-on-call/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/splunk-on-call/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/splunk-on-call/README.md b/plugins/splunk-on-call/README.md new file mode 100644 index 0000000000..73ee05c181 --- /dev/null +++ b/plugins/splunk-on-call/README.md @@ -0,0 +1,120 @@ +# Splunk On-Call + +## Overview + +This plugin displays Splunk On-Call, formerly VictorOps, information about an entity. + +There is a way to trigger an new incident directly to specific users or/and specific teams. + +This plugin requires that entities are annotated with a team name. See more further down in this document. + +This plugin provides: + +- A list of incidents +- A way to trigger a new incident to specific users or/and teams +- A way to acknowledge/resolve an incident +- Information details about the persons on-call + +## Setup instructions + +Install the plugin: + +```bash +yarn add @backstage/plugin-splunk-on-call +``` + +Add it to the app in `plugins.ts`: + +```ts +export { plugin as SplunkOnCall } from '@backstage/plugin-splunk-on-call'; +``` + +Add it to the `EntityPage.tsx`: + +```ts +import { + isPluginApplicableToEntity as isSplunkOnCallAvailable, + SplunkOnCallCard, +} from '@backstage/plugin-splunk-on-call'; +// ... +{ + isSplunkOnCallAvailable(entity) && ( + + + + ); +} +``` + +## Client configuration + +In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide the username of the user making the action. +The user supplied must be a valid Splunk On-Call user and a member of your organization. + +In `app-config.yaml`: + +```yaml +splunkOnCall: + username: +``` + +The user supplied must be a valid Splunk On-Call user and a member of your organization. + +In order to make the API calls, you need to provide a new proxy config which will redirect to the Splunk On-Call API endpoint and add authentication information in the headers: + +```yaml +# app-config.yaml +proxy: + # ... + '/splunk-on-call': + target: https://api.victorops.com/api-public + headers: + X-VO-Api-Id: + $env: SPLUNK_ON_CALL_API_ID + X-VO-Api-Key: + $env: SPLUNK_ON_CALL_API_KEY +``` + +In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`. + +### Adding your team name to the entity annotation + +The information displayed for each entity is based on the team name. +If you want to use this plugin for an entity, you need to label it with the below annotation: + +```yaml +annotations: + splunk.com/on-call-team': +``` + +## Providing the API key and API id + +In order for the client to make requests to the [Splunk On-Call API](https://portal.victorops.com/public/api-docs.html#/) it needs an [API ID and an API Key](https://help.victorops.com/knowledge-base/api/). + +Then start the backend passing the values as an environment variable: + +```bash +$ SPLUNK_ON_CALL_API_KEY='' SPLUNK_ON_CALL_API_ID='' yarn start +``` + +This will proxy the request by adding `X-VO-Api-Id` and `X-VO-Api-Key` headers with the provided values. + +You can also add the values in your helm template: + +```yaml +# backend-secret.yaml +stringData: + # ... + SPLUNK_ON_CALL_API_ID: { { .Values.auth.splunkOnCallApiId } } + SPLUNK_ON_CALL_API_KEY: { { .Values.auth.splunkOnCallApiKey } } +``` + +To enable it you need to provide them in the chart's values: + +```yaml +# values.yaml +auth: + # ... + splunkOnCallApiId: h + splunkOnCallApiKey: h +``` diff --git a/plugins/splunk-on-call/dev/index.tsx b/plugins/splunk-on-call/dev/index.tsx new file mode 100644 index 0000000000..346c92c37e --- /dev/null +++ b/plugins/splunk-on-call/dev/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { splunkOnCallPlugin, SplunkOnCallPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(splunkOnCallPlugin) + .addPage({ + title: 'Splunk On-Call', + element: , + }) + .render(); diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json new file mode 100644 index 0000000000..c0542bd64c --- /dev/null +++ b/plugins/splunk-on-call/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/plugin-splunk-on-call", + "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/splunk-on-call" + }, + "keywords": [ + "backstage", + "splunk-on-call" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "classnames": "^2.2.6", + "luxon": "^1.25.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/splunk-on-call/src/api/client.ts b/plugins/splunk-on-call/src/api/client.ts new file mode 100644 index 0000000000..501dad80a2 --- /dev/null +++ b/plugins/splunk-on-call/src/api/client.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core'; +import { + Incident, + OnCall, + User, + EscalationPolicyInfo, + Team, +} from '../components/types'; +import { + SplunkOnCallApi, + TriggerAlarmRequest, + IncidentsResponse, + OnCallsResponse, + ClientApiConfig, + RequestOptions, + ListUserResponse, + EscalationPolicyResponse, + PatchIncidentRequest, +} from './types'; + +export class UnauthorizedError extends Error {} + +export const splunkOnCallApiRef = createApiRef({ + id: 'plugin.splunk-on-call.api', + description: 'Used to fetch data from Splunk On-Call API', +}); + +export class SplunkOnCallClient implements SplunkOnCallApi { + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const usernameFromConfig: string | null = + configApi.getOptionalString('splunkOnCall.username') || null; + return new SplunkOnCallClient({ + username: usernameFromConfig, + discoveryApi, + }); + } + constructor(private readonly config: ClientApiConfig) {} + + async getIncidents(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents`; + + const { incidents } = await this.getByUrl(url); + + return incidents; + } + + async getOnCallUsers(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/oncall/current`; + const { teamsOnCall } = await this.getByUrl(url); + + return teamsOnCall; + } + + async getTeams(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/team`; + const teams = await this.getByUrl(url); + + return teams; + } + + async acknowledgeIncident({ + incidentNames, + }: PatchIncidentRequest): Promise { + const options = { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userName: this.config.username, + incidentNames, + }), + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents/ack`; + + return this.request(url, options); + } + + async resolveIncident({ + incidentNames, + }: PatchIncidentRequest): Promise { + const options = { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userName: this.config.username, + incidentNames, + }), + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents/resolve`; + + return this.request(url, options); + } + + async getUsers(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v2/user`; + const { users } = await this.getByUrl(url); + + return users; + } + + async getEscalationPolicies(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/policies`; + const { policies } = await this.getByUrl(url); + + return policies; + } + + async triggerAlarm({ + summary, + details, + userName, + targets, + isMultiResponder, + }: TriggerAlarmRequest): Promise { + const body = JSON.stringify({ + summary, + details, + userName: this.config.username || userName, + targets, + isMultiResponder, + }); + + const options = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents`; + + return this.request(url, options); + } + + private async getByUrl(url: string): Promise { + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }; + const response = await this.request(url, options); + + return response.json(); + } + + private async request( + url: string, + options: RequestOptions, + ): Promise { + const response = await fetch(url, options); + if (response.status === 403) { + throw new UnauthorizedError(); + } + if (!response.ok) { + const payload = await response.json(); + const errors = payload.errors.map((error: string) => error).join(' '); + const message = `Request failed with ${response.status}, ${errors}`; + throw new Error(message); + } + return response; + } +} diff --git a/plugins/splunk-on-call/src/api/index.ts b/plugins/splunk-on-call/src/api/index.ts new file mode 100644 index 0000000000..1e4056fbc0 --- /dev/null +++ b/plugins/splunk-on-call/src/api/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + SplunkOnCallClient, + splunkOnCallApiRef, + UnauthorizedError, +} from './client'; +export type { SplunkOnCallApi } from './types'; diff --git a/plugins/splunk-on-call/src/api/mocks.ts b/plugins/splunk-on-call/src/api/mocks.ts new file mode 100644 index 0000000000..86df04275d --- /dev/null +++ b/plugins/splunk-on-call/src/api/mocks.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EscalationPolicyInfo, + Incident, + Team, + User, +} from '../components/types'; + +export const MOCKED_USER: User = { + createdAt: '2021-02-01T23:38:38Z', + displayName: 'Test User', + email: 'test@example.com', + firstName: 'FirstNameTest', + lastName: 'LastNameTest', + passwordLastUpdated: '2021-02-01T23:38:38Z', + username: 'test_user', + verified: true, + _selfUrl: '/api-public/v1/user/test_user', +}; + +export const MOCKED_ON_CALL = [ + { + team: { name: 'team_example', slug: 'team-zEalMCgwYSA0Lt40' }, + oncallNow: [ + { + escalationPolicy: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' }, + users: [{ onCalluser: { username: 'test_user' } }], + }, + ], + }, +]; + +export const MOCK_INCIDENT: Incident = { + alertCount: 1, + currentPhase: 'ACKED', + entityDisplayName: 'test-incident', + entityId: 'entityId', + entityState: 'CRITICAL', + entityType: 'SERVICE', + incidentNumber: '1', + lastAlertId: 'lastAlertId', + lastAlertTime: '2021-02-03T00:13:11Z', + routingKey: 'routingdefault', + service: 'test', + startTime: '2021-02-03T00:13:11Z', + pagedTeams: ['team-O9SqT13fsnCstjMi'], + pagedUsers: [], + pagedPolicies: [ + { + policy: { + name: 'Generated Direct User Policy for test_user', + slug: 'directUserPolicySlug-test', + _selfUrl: '/test', + }, + }, + ], + transitions: [{ name: 'ACKED', at: '2021-02-03T01:20:00Z', by: 'test' }], + monitorName: 'vouser-user', + monitorType: 'Manual', + firstAlertUuid: 'firstAlertUuid', + incidentLink: 'https://portal.victorops.com/example', +}; + +export const MOCK_TEAM: Team = { + _selfUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi', + _membersUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/members', + _policiesUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/policies', + _adminsUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/admins', + name: 'test', + slug: 'team-O9SqT13fsnCstjMi', + memberCount: 1, + version: 1, + isDefaultTeam: false, +}; + +export const ESCALATION_POLICIES: EscalationPolicyInfo[] = [ + { + policy: { + name: 'Example', + slug: 'team-zEalMCgwYSA0Lt40', + _selfUrl: '/api-public/v1/policies/team-zEalMCgwYSA0Lt40', + }, + team: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' }, + }, +]; diff --git a/plugins/splunk-on-call/src/api/types.ts b/plugins/splunk-on-call/src/api/types.ts new file mode 100644 index 0000000000..500f2a6291 --- /dev/null +++ b/plugins/splunk-on-call/src/api/types.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EscalationPolicyInfo, + Incident, + OnCall, + Team, + User, +} from '../components/types'; +import { DiscoveryApi } from '@backstage/core'; + +export enum TargetType { + UserValue = 'User', + EscalationPolicyValue = 'EscalationPolicy', +} + +export type IncidentTarget = { + type: TargetType; + slug: string; +}; + +export type TriggerAlarmRequest = { + targets: IncidentTarget[]; + details: string; + summary: string; + userName: string; + isMultiResponder?: boolean; +}; + +export interface SplunkOnCallApi { + /** + * Fetches a list of incidents + */ + getIncidents(): Promise; + + /** + * Fetches the list of users in an escalation policy. + */ + getOnCallUsers(): Promise; + + /** + * Triggers an incident to specific users and/or specific teams. + */ + triggerAlarm(request: TriggerAlarmRequest): Promise; + + /** + * Resolves an incident. + */ + resolveIncident(request: PatchIncidentRequest): Promise; + + /** + * Acknowledge an incident. + */ + acknowledgeIncident(request: PatchIncidentRequest): Promise; + + /** + * Get a list of users for your organization. + */ + getUsers(): Promise; + + /** + * Get a list of teams for your organization. + */ + getTeams(): Promise; + + /** + * Get a list of escalation policies for your organization. + */ + getEscalationPolicies(): Promise; +} + +export type PatchIncidentRequest = { + incidentNames: string[]; + message?: string; +}; + +export type EscalationPolicyResponse = { + policies: EscalationPolicyInfo[]; +}; + +export type ListUserResponse = { + users: User[]; + _selfUrl?: string; +}; + +export type IncidentsResponse = { + incidents: Incident[]; +}; + +export type OnCallsResponse = { + teamsOnCall: OnCall[]; +}; + +export type ClientApiConfig = { + username: string | null; + discoveryApi: DiscoveryApi; +}; + +export type RequestOptions = { + method: string; + headers: HeadersInit; + body?: BodyInit; +}; diff --git a/plugins/splunk-on-call/src/assets/emptystate.svg b/plugins/splunk-on-call/src/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/splunk-on-call/src/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx b/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx new file mode 100644 index 0000000000..ea72b6ca51 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { EmptyState } from '@backstage/core'; +import { Button } from '@material-ui/core'; + +export const MissingApiKeyOrApiIdError = () => ( + + Read More + + } + /> +); diff --git a/plugins/splunk-on-call/src/components/Errors/index.ts b/plugins/splunk-on-call/src/components/Errors/index.ts new file mode 100644 index 0000000000..9698b0bd14 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Errors/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MissingApiKeyOrApiIdError } from './MissingApiKeyOrApiIdError'; diff --git a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx new file mode 100644 index 0000000000..41157a2b5e --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { EscalationPolicy } from './EscalationPolicy'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks'; + +const mockSplunkOnCallApi = { + getOnCallUsers: () => [], +}; +const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]); + +describe('Escalation', () => { + it('Handles an empty response', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled(); + }); + + it('Render a list of users', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockImplementationOnce(async () => MOCKED_ON_CALL); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('FirstNameTest LastNameTest')).toBeInTheDocument(); + expect(getByText('test@example.com')).toBeInTheDocument(); + expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled(); + }); + + it('Handles errors', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockRejectedValueOnce(new Error('Error message')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText('Error encountered while fetching information. Error message'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx new file mode 100644 index 0000000000..9538a0e73b --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { EscalationUsersEmptyState } from './EscalationUsersEmptyState'; +import { EscalationUser } from './EscalationUser'; +import { useAsync } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; +import { User } from '../types'; + +type Props = { + users: { [key: string]: User }; + team: string; +}; + +export const EscalationPolicy = ({ users, team }: Props) => { + const api = useApi(splunkOnCallApiRef); + + const { value: userNames, loading, error } = useAsync(async () => { + const oncalls = await api.getOnCallUsers(); + const teamUsernames = oncalls + .filter(oncall => oncall.team?.name === team) + .flatMap(oncall => { + return oncall.oncallNow?.flatMap(oncallNow => { + return oncallNow.users?.flatMap(user => { + return user?.onCalluser?.username; + }); + }); + }); + return teamUsernames; + }); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!userNames?.length) { + return ; + } + + return ( + ON CALL}> + {userNames && + userNames.map( + (userName, index) => + userName && + userName in users && ( + + ), + )} + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationUser.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationUser.tsx new file mode 100644 index 0000000000..4df0719696 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationUser.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import Avatar from '@material-ui/core/Avatar'; +import EmailIcon from '@material-ui/icons/Email'; +import { User } from '../types'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, +}); + +type Props = { + user: User; +}; + +export const EscalationUser = ({ user }: Props) => { + const classes = useStyles(); + + return ( + + + + + + {user.firstName} {user.lastName} + + } + secondary={user.email} + /> + + + + + + + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx new file mode 100644 index 0000000000..d587011601 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { StatusWarning } from '@backstage/core'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const EscalationUsersEmptyState = () => { + const classes = useStyles(); + return ( + + +
+ +
+
+ +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/index.ts b/plugins/splunk-on-call/src/components/Escalation/index.ts new file mode 100644 index 0000000000..ac2db62cd9 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EscalationPolicy } from './EscalationPolicy'; diff --git a/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx b/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx new file mode 100644 index 0000000000..f7a0398c55 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import EmptyStateImage from '../../assets/emptystate.svg'; + +export const IncidentsEmptyState = () => { + return ( + + + Nice! No incidents found! + + + EmptyState + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx new file mode 100644 index 0000000000..1bf233de86 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx @@ -0,0 +1,241 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import DoneIcon from '@material-ui/icons/Done'; +import DoneAllIcon from '@material-ui/icons/DoneAll'; +import { + StatusError, + StatusWarning, + StatusOK, + useApi, + alertApiRef, +} from '@backstage/core'; +import { DateTime, Duration } from 'luxon'; +import { Incident, IncidentPhase } from '../types'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { splunkOnCallApiRef } from '../../api/client'; +import { useAsyncFn } from 'react-use'; +import { PatchIncidentRequest } from '../../api/types'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + listItemPrimary: { + fontWeight: 'bold', + }, + listItemIcon: { + minWidth: '1em', + }, + secondaryAction: { + paddingRight: 48, + }, +}); + +type Props = { + incident: Incident; + onIncidentAction: () => void; +}; + +const IncidentPhaseStatus = ({ + currentPhase, +}: { + currentPhase: IncidentPhase; +}) => { + switch (currentPhase) { + case 'UNACKED': + return ; + case 'ACKED': + return ; + default: + return ; + } +}; + +const incidentPhaseTooltip = (currentPhase: IncidentPhase) => { + switch (currentPhase) { + case 'UNACKED': + return 'Triggered'; + case 'ACKED': + return 'Acknowledged'; + default: + return 'Resolved'; + } +}; + +const IncidentAction = ({ + currentPhase, + incidentNames, + resolveAction, + acknowledgeAction, +}: { + currentPhase: string; + incidentNames: string[]; + resolveAction: (args: PatchIncidentRequest) => void; + acknowledgeAction: (args: PatchIncidentRequest) => void; +}) => { + switch (currentPhase) { + case 'UNACKED': + return ( + + acknowledgeAction({ incidentNames })}> + + + + ); + case 'ACKED': + return ( + + resolveAction({ incidentNames })}> + + + + ); + default: + return <>; + } +}; + +export const IncidentListItem = ({ incident, onIncidentAction }: Props) => { + const classes = useStyles(); + const duration = + new Date().getTime() - new Date(incident.startTime!).getTime(); + const createdAt = DateTime.local() + .minus(Duration.fromMillis(duration)) + .toRelative({ locale: 'en' }); + const alertApi = useApi(alertApiRef); + const api = useApi(splunkOnCallApiRef); + + const hasBeenManuallyTriggered = incident.monitorName?.includes('vouser-'); + + const user = hasBeenManuallyTriggered + ? incident.monitorName?.replace('vouser-', '') + : incident.monitorName; + + const [ + { value: resolveValue, error: resolveError }, + handleResolveIncident, + ] = useAsyncFn( + async ({ incidentNames }: PatchIncidentRequest) => + await api.resolveIncident({ + incidentNames, + }), + ); + + const [ + { value: acknowledgeValue, error: acknowledgeError }, + handleAcknowledgeIncident, + ] = useAsyncFn( + async ({ incidentNames }: PatchIncidentRequest) => + await api.acknowledgeIncident({ + incidentNames, + }), + ); + + useEffect(() => { + if (acknowledgeValue) { + alertApi.post({ + message: `Incident successfully acknowledged`, + }); + } + + if (resolveValue) { + alertApi.post({ + message: `Incident successfully resolved`, + }); + } + if (resolveValue || acknowledgeValue) { + onIncidentAction(); + } + }, [acknowledgeValue, resolveValue, alertApi, onIncidentAction]); + + if (acknowledgeError) { + alertApi.post({ + message: `Failed to acknowledge incident. ${acknowledgeError.message}`, + severity: 'error', + }); + } + + if (resolveError) { + alertApi.post({ + message: `Failed to resolve incident. ${resolveError.message}`, + severity: 'error', + }); + } + + return ( + + + +
+ +
+
+
+ + Created {createdAt} {user && `by ${user}`} + + } + /> + + {incident.incidentLink && incident.incidentNumber && ( + + + + + + + + + )} +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx new file mode 100644 index 0000000000..d1f973735c --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Incidents } from './Incidents'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + alertApiRef, + ApiProvider, + ApiRegistry, + createApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks'; + +const mockIdentityApi: Partial = { + getUserId: () => 'test', +}; + +const mockSplunkOnCallApi = { + getIncidents: () => [], + getTeams: () => [], +}; +const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [identityApiRef, mockIdentityApi], + [splunkOnCallApiRef, mockSplunkOnCallApi], +]); + +describe('Incidents', () => { + it('Renders an empty state when there are no incidents', async () => { + mockSplunkOnCallApi.getTeams = jest + .fn() + .mockImplementationOnce(async () => [MOCK_TEAM]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + }); + + it('Renders all incidents', async () => { + mockSplunkOnCallApi.getIncidents = jest + .fn() + .mockImplementationOnce(async () => [MOCK_INCIDENT]); + + mockSplunkOnCallApi.getTeams = jest + .fn() + .mockImplementationOnce(async () => [MOCK_TEAM]); + const { + getByText, + getByTitle, + getAllByTitle, + getByLabelText, + queryByTestId, + } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('user', { + exact: false, + }), + ).toBeInTheDocument(); + expect(getByText('test-incident')).toBeInTheDocument(); + expect(getByTitle('Acknowledged')).toBeInTheDocument(); + expect(getByLabelText('Status warning')).toBeInTheDocument(); + + // assert links, mailto and hrefs, date calculation + expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1); + }); + + it('Handle errors', async () => { + mockSplunkOnCallApi.getIncidents = jest + .fn() + .mockRejectedValueOnce(new Error('Error occurred')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx new file mode 100644 index 0000000000..e1a79314b9 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { IncidentListItem } from './IncidentListItem'; +import { IncidentsEmptyState } from './IncidentEmptyState'; +import { useAsyncFn } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + refreshIncidents: boolean; + team: string; +}; + +export const Incidents = ({ refreshIncidents, team }: Props) => { + const api = useApi(splunkOnCallApiRef); + + const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn( + async () => { + const allIncidents = await api.getIncidents(); + const teams = await api.getTeams(); + const teamSlug = teams.find(teamValue => teamValue.name === team)?.slug; + const filteredIncidents = teamSlug + ? allIncidents.filter(incident => + incident.pagedTeams?.includes(teamSlug), + ) + : []; + return filteredIncidents; + }, + ); + + useEffect(() => { + getIncidents(); + }, [refreshIncidents, getIncidents]); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!incidents?.length) { + return ; + } + + return ( + INCIDENTS}> + {incidents!.map((incident, index) => ( + getIncidents()} + key={index} + incident={incident} + /> + ))} + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/index.ts b/plugins/splunk-on-call/src/components/Incident/index.ts new file mode 100644 index 0000000000..fb2702602b --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Incidents } from './Incidents'; diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx new file mode 100644 index 0000000000..b1280ab21c --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor, fireEvent, act } from '@testing-library/react'; +import { SplunkOnCallCard } from './SplunkOnCallCard'; +import { Entity } from '@backstage/catalog-model'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + alertApiRef, + ApiProvider, + ApiRegistry, + ConfigApi, + configApiRef, + ConfigReader, + createApiRef, +} from '@backstage/core'; +import { + splunkOnCallApiRef, + UnauthorizedError, + SplunkOnCallClient, +} from '../api'; +import { + ESCALATION_POLICIES, + MOCKED_ON_CALL, + MOCKED_USER, + MOCK_INCIDENT, + MOCK_TEAM, +} from '../api/mocks'; + +const mockSplunkOnCallApi: Partial = { + getUsers: async () => [], + getIncidents: async () => [MOCK_INCIDENT], + getOnCallUsers: async () => MOCKED_ON_CALL, + getTeams: async () => [MOCK_TEAM], + getEscalationPolicies: async () => ESCALATION_POLICIES, +}; + +const configApi: ConfigApi = new ConfigReader({ + splunkOnCall: { + username: MOCKED_USER.username, + }, +}); + +const apis = ApiRegistry.from([ + [splunkOnCallApiRef, mockSplunkOnCallApi], + [configApiRef, configApi], + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], +]); +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'splunkoncall-test', + annotations: { + 'splunk.com/on-call-team': 'Example', + }, + }, +}; + +describe('SplunkOnCallCard', () => { + it('Render splunkoncall', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockImplementationOnce(async () => [MOCKED_USER]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Missing or invalid Splunk On-Call API key and/or API id'), + ).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + it('opens the dialog when trigger button is clicked', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockImplementationOnce(async () => [MOCKED_USER]); + + const { getByText, queryByTestId, getByTestId, getByRole } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Create Incident')).toBeInTheDocument(); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx new file mode 100644 index 0000000000..84d94d60d3 --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useCallback } from 'react'; +import { + useApi, + Progress, + HeaderIconLinkRow, + MissingAnnotationEmptyState, + configApiRef, + EmptyState, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { + Button, + makeStyles, + Card, + CardHeader, + Divider, + CardContent, + Typography, +} from '@material-ui/core'; +import { Incidents } from './Incident'; +import { EscalationPolicy } from './Escalation'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { splunkOnCallApiRef, UnauthorizedError } from '../api'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import { TriggerDialog } from './TriggerDialog'; +import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError'; +import { User } from './types'; + +const useStyles = makeStyles({ + triggerAlarm: { + paddingTop: 0, + paddingBottom: 0, + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + lineHeight: 1.5, + '&:hover, &:focus, &.focus': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }, +}); + +export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team'; + +export const MissingTeamAnnotation = () => ( + +); + +export const MissingUsername = () => ( + + + +); + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]); + +type Props = { + entity: Entity; +}; + +export const SplunkOnCallCard = ({ entity }: Props) => { + const classes = useStyles(); + const config = useApi(configApiRef); + const api = useApi(splunkOnCallApiRef); + const [showDialog, setShowDialog] = useState(false); + const [refreshIncidents, setRefreshIncidents] = useState(false); + const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM]; + + const username = config.getOptionalString('splunkOnCall.username'); + + const handleRefresh = useCallback(() => { + setRefreshIncidents(x => !x); + }, []); + + const handleDialog = useCallback(() => { + setShowDialog(x => !x); + }, []); + + const { value: users, loading, error } = useAsync(async () => { + const allUsers = await api.getUsers(); + const usersHashMap = allUsers.reduce( + (map: Record, obj: User) => { + if (obj.username) { + map[obj.username] = obj; + } + return map; + }, + {}, + ); + return { usersHashMap, userList: allUsers }; + }); + + const incidentCreator = + username && users?.userList.find(user => user.username === username); + + if (error instanceof UnauthorizedError) { + return ; + } + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + const Content = () => { + if (!team) { + return ; + } + if (!username || !incidentCreator) { + return ; + } + + return ( + <> + + {users?.usersHashMap && team && ( + + )} + {users && incidentCreator && ( + + )} + + ); + }; + + const triggerLink = { + label: 'Create Incident', + action: ( + + ), + icon: , + }; + + return ( + + Team: {team}, + username && ( + + ), + ]} + /> + + + + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx new file mode 100644 index 0000000000..4222cbfbeb --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, makeStyles } from '@material-ui/core'; +import { + Content, + ContentHeader, + Page, + Header, + SupportButton, +} from '@backstage/core'; +import { SplunkOnCallCard } from './SplunkOnCallCard'; +import { useEntity } from '@backstage/plugin-catalog-react'; + +const useStyles = makeStyles(() => ({ + overflowXScroll: { + overflowX: 'scroll', + }, +})); + +export type SplunkOnCallPageProps = { + title?: string; + subtitle?: string; + pageTitle?: string; +}; + +export const SplunkOnCallPage = ({ + title, + subtitle, + pageTitle, +}: SplunkOnCallPageProps): JSX.Element => { + const classes = useStyles(); + const { entity } = useEntity(); + + return ( + +
+ + + + This is used to help you automate incident management. + + + + + + + + + + ); +}; + +SplunkOnCallPage.defaultProps = { + title: 'Splunk On-Call', + subtitle: 'Automate incident management', + pageTitle: 'Dashboard', +}; diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx new file mode 100644 index 0000000000..741a15fb41 --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + ApiRegistry, + alertApiRef, + createApiRef, + ApiProvider, +} from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { TriggerDialog } from './TriggerDialog'; +import { ESCALATION_POLICIES, MOCKED_USER } from '../../api/mocks'; + +describe('TriggerDialog', () => { + const mockTriggerAlarmFn = jest.fn(); + const mockSplunkOnCallApi = { + triggerAlarm: mockTriggerAlarmFn, + getEscalationPolicies: async () => ESCALATION_POLICIES, + }; + + const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [splunkOnCallApiRef, mockSplunkOnCallApi], + ]); + + it('open the dialog and trigger an alarm', async () => { + const { getByText, getByRole, getAllByRole, getByTestId } = render( + wrapInTestApp( + + {}} + users={[MOCKED_USER]} + onIncidentCreated={() => {}} + /> + , + ), + ); + + expect(getByRole('dialog')).toBeInTheDocument(); + expect( + getByText('This action will trigger an incident', { + exact: false, + }), + ).toBeInTheDocument(); + const summary = getByTestId('trigger-summary-input'); + const body = getByTestId('trigger-body-input'); + const behavior = getByTestId('trigger-select-behavior'); + const description = 'Test Trigger Alarm'; + await act(async () => { + fireEvent.change(summary, { target: { value: description } }); + fireEvent.change(body, { target: { value: description } }); + fireEvent.change(behavior, { target: { value: '0' } }); + fireEvent.mouseDown(getAllByRole('button')[0]); + }); + + // Trigger user targets select + const options = getAllByRole('option'); + await act(async () => { + fireEvent.click(options[0]); + fireEvent.keyDown(options[0], { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + }); + + // Trigger policy targets select + await act(async () => { + fireEvent.mouseDown(getAllByRole('button')[1]); + }); + const policiesOptions = getAllByRole('option'); + await act(async () => { + fireEvent.click(policiesOptions[0]); + }); + + // Trigger incident creation button + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(mockTriggerAlarmFn).toHaveBeenCalled(); + expect(mockTriggerAlarmFn).toHaveBeenCalledWith({ + summary: description, + details: description, + userName: 'test_user', + targets: [ + { slug: 'test_user', type: 'User' }, + { slug: 'team-zEalMCgwYSA0Lt40', type: 'EscalationPolicy' }, + ], + isMultiResponder: false, + }); + }); +}); diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx new file mode 100644 index 0000000000..8132db985b --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx @@ -0,0 +1,365 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + TextField, + DialogActions, + Button, + DialogContent, + Typography, + CircularProgress, + Select, + MenuItem, + Input, + Chip, + createStyles, + makeStyles, + Theme, + FormControl, + InputLabel, +} from '@material-ui/core'; +import { useApi, alertApiRef } from '@backstage/core'; +import { useAsync, useAsyncFn } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { User } from '../types'; +import { IncidentTarget, TargetType } from '../../api/types'; + +const MenuProps = { + PaperProps: { + style: { + width: 250, + }, + }, +}; + +type Props = { + users: User[]; + incidentCreator: User; + showDialog: boolean; + handleDialog: () => void; + onIncidentCreated: () => void; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: 2, + }, + formControl: { + margin: theme.spacing(1), + minWidth: `calc(100% - ${theme.spacing(2)}px)`, + }, + targets: { + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + }), +); + +export const TriggerDialog = ({ + users, + incidentCreator, + showDialog, + handleDialog, + onIncidentCreated: onIncidentCreated, +}: Props) => { + const alertApi = useApi(alertApiRef); + const api = useApi(splunkOnCallApiRef); + const classes = useStyles(); + + const [userTargets, setUserTargets] = useState([]); + const [policyTargets, setPolicyTargets] = useState([]); + const [detailsValue, setDetails] = useState(''); + const [summaryValue, setSummary] = useState(''); + const [isMultiResponderValue, setIsMultiResponder] = useState('1'); + + const [ + { value, loading: triggerLoading, error: triggerError }, + handleTriggerAlarm, + ] = useAsyncFn( + async ( + summary: string, + details: string, + userName: string, + targets: IncidentTarget[], + isMultiResponder: boolean, + ) => + await api.triggerAlarm({ + summary, + details, + userName, + targets, + isMultiResponder, + }), + ); + + const { + value: policies, + loading: policiesLoaading, + error: policiesError, + } = useAsync(async () => { + const allPolicies = await api.getEscalationPolicies(); + return allPolicies; + }); + + const handleUserTargets = (event: React.ChangeEvent<{ value: unknown }>) => { + setUserTargets(event.target.value as string[]); + }; + + const handlePolicyTargets = ( + event: React.ChangeEvent<{ value: unknown }>, + ) => { + setPolicyTargets(event.target.value as string[]); + }; + + const detailsChanged = (event: React.ChangeEvent) => { + setDetails(event.target.value); + }; + + const summaryChanged = (event: React.ChangeEvent) => { + setSummary(event.target.value); + }; + + const isMultiResponderChanged = ( + event: React.ChangeEvent<{ value: unknown }>, + ) => { + setIsMultiResponder(event.target.value as string); + }; + + const targets = (): IncidentTarget[] => [ + ...userTargets.map(user => ({ slug: user, type: TargetType.UserValue })), + ...policyTargets.map(user => ({ + slug: user, + type: TargetType.EscalationPolicyValue, + })), + ]; + + useEffect(() => { + if (value) { + alertApi.post({ + message: `Alarm successfully triggered`, + }); + onIncidentCreated(); + handleDialog(); + } + }, [value, alertApi, handleDialog, onIncidentCreated]); + + if (triggerError) { + alertApi.post({ + message: `Failed to trigger alarm. ${triggerError.message}`, + severity: 'error', + }); + } + + return ( + + This action will trigger an incident + + + Created by:{' '} + + {incidentCreator?.firstName} {incidentCreator?.lastName} + + + + + If the issue you are seeing does not need urgent attention, please + get in touch with the responsible team using their preferred + communications channel. You can find information about the owner of + this entity in the "About" card. If the issue is urgent, please + don't hesitate to trigger the alert. + + + + Please describe the problem you want to report. Be as descriptive as + possible. Your signed in user and a reference to the current page will + automatically be amended to the alarm so that the receiver can reach + out to you if necessary. + +
+ + Select the targets + +
+ + Select Users + + + + Select Teams / Policies + + +
+
+ + Acknowledge Behavior + + + + + + +
+ + + + +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/index.ts b/plugins/splunk-on-call/src/components/TriggerDialog/index.ts new file mode 100644 index 0000000000..655cef8504 --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TriggerDialog } from './TriggerDialog'; diff --git a/plugins/splunk-on-call/src/components/types.ts b/plugins/splunk-on-call/src/components/types.ts new file mode 100644 index 0000000000..de5718cb28 --- /dev/null +++ b/plugins/splunk-on-call/src/components/types.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IncidentTarget } from '../api/types'; + +export type Team = { + name?: string; + slug?: string; + memberCount?: number; + version?: number; + isDefaultTeam?: boolean; + _selfUrl?: string; + _policiesUrl?: string; + _membersUrl?: string; + _adminsUrl?: string; +}; + +export type OnCall = { + team?: OnCallTeamResource; + oncallNow?: OnCallNowResource[]; +}; + +export type OnCallTeamResource = { + name?: string; + slug?: string; +}; + +export type OnCallNowResource = { + escalationPolicy?: OnCallEscalationPolicyResource; + users?: OnCallUsersResource[]; +}; + +export type OnCallEscalationPolicyResource = { + name?: string; + slug?: string; +}; + +export type OnCallUsersResource = { + onCalluser?: OnCallUser; +}; + +export type OnCallUser = { + username?: string; +}; + +export type User = { + firstName?: string; + lastName?: string; + displayName?: string; + username?: string; + email?: string; + createdAt?: string; + passwordLastUpdated?: string; + verified?: boolean; + _selfUrl?: string; +}; + +export type CreateIncidentRequest = { + summary: string; + details: string; + userName: string; + targets: IncidentTarget; + isMultiResponder: boolean; +}; + +export type IncidentPhase = 'UNACKED' | 'ACKED' | 'RESOLVED'; + +export type Incident = { + incidentNumber?: string; + startTime?: string; + currentPhase: IncidentPhase; + entityState?: string; + entityType?: string; + routingKey?: string; + alertCount?: number; + lastAlertTime?: string; + lastAlertId?: string; + entityId?: string; + host?: string; + service?: string; + pagedUsers?: string[]; + pagedTeams?: string[]; + entityDisplayName?: string; + pagedPolicies?: EscalationPolicyInfo[]; + transitions?: IncidentTransition[]; + firstAlertUuid?: string; + monitorName?: string; + monitorType?: string; + incidentLink?: string; +}; + +export type EscalationPolicyInfo = { + policy: EscalationPolicySummary; + team?: EscalationPolicyTeam; +}; + +export type IncidentTransition = { + name?: string; + at?: string; + by?: string; + message?: string; + manually?: boolean; + alertId?: string; + alertUrl?: string; +}; + +export type EscalationPolicySummary = { + name: string; + slug: string; + _selfUrl: string; +}; + +export type EscalationPolicyTeam = { + name: string; + slug: string; +}; diff --git a/plugins/splunk-on-call/src/index.ts b/plugins/splunk-on-call/src/index.ts new file mode 100644 index 0000000000..c8de96f8ea --- /dev/null +++ b/plugins/splunk-on-call/src/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + splunkOnCallPlugin, + splunkOnCallPlugin as plugin, + SplunkOnCallPage, +} from './plugin'; +export { + isPluginApplicableToEntity, + SplunkOnCallCard, +} from './components/SplunkOnCallCard'; +export { + SplunkOnCallClient, + splunkOnCallApiRef, + UnauthorizedError, +} from './api/client'; diff --git a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx b/plugins/splunk-on-call/src/plugin.test.ts similarity index 58% rename from plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx rename to plugins/splunk-on-call/src/plugin.test.ts index b539753a95..b846eba706 100644 --- a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx +++ b/plugins/splunk-on-call/src/plugin.test.ts @@ -13,16 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { splunkOnCallPlugin } from './plugin'; -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/providesApis:/i)).toBeInTheDocument(); +describe('splunk-on-call', () => { + it('should export plugin', () => { + expect(splunkOnCallPlugin).toBeDefined(); }); }); diff --git a/plugins/splunk-on-call/src/plugin.ts b/plugins/splunk-on-call/src/plugin.ts new file mode 100644 index 0000000000..835cda48df --- /dev/null +++ b/plugins/splunk-on-call/src/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + configApiRef, + createRoutableExtension, +} from '@backstage/core'; +import { splunkOnCallApiRef, SplunkOnCallClient } from './api'; + +export const rootRouteRef = createRouteRef({ + title: 'splunk-on-call', +}); + +export const splunkOnCallPlugin = createPlugin({ + id: 'splunk-on-call', + apis: [ + createApiFactory({ + api: splunkOnCallApiRef, + deps: { discoveryApi: discoveryApiRef, configApi: configApiRef }, + factory: ({ configApi, discoveryApi }) => + SplunkOnCallClient.fromConfig(configApi, discoveryApi), + }), + ], + routes: { + root: rootRouteRef, + }, +}); + +export const SplunkOnCallPage = splunkOnCallPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SplunkOnCallPage').then(m => m.SplunkOnCallPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/splunk-on-call/src/setupTests.ts b/plugins/splunk-on-call/src/setupTests.ts new file mode 100644 index 0000000000..0bfa67b49a --- /dev/null +++ b/plugins/splunk-on-call/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 879c3582d0..0fa9ea44b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,6 +1865,21 @@ remark-gfm "^1.0.0" zen-observable "^0.8.15" +"@backstage/plugin-catalog-react@^0.0.2": + version "0.0.2" + resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.0.2.tgz#e50da2dac9fab3a0d5973f8d1083ee2c368e5e52" + integrity sha512-O6aujFPRaEFTk4XlwOoswbnoHIOqMtj6ycUj6R1mNKOM4plUgGDKKhO3be69FHMJEMbiSvVe6AW+1kXaK+1LqA== + dependencies: + "@backstage/catalog-client" "^0.3.5" + "@backstage/catalog-model" "^0.7.1" + "@backstage/core" "^0.6.0" + "@material-ui/core" "^4.11.0" + "@types/react" "^16.9" + react "^16.13.1" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + "@backstage/plugin-catalog@^0.2.0": version "0.3.1" dependencies: @@ -2470,31 +2485,31 @@ upper-case "2.0.1" "@graphql-codegen/typescript-resolvers@^1.17.7": - version "1.17.12" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.17.12.tgz#1f8f608d68b46780351c8224a61750b9b9307497" - integrity sha512-qICKxl06R1yCBh03cdyiOYlHTebQ1n/FOiQPCxo4bsiF6HfbrgpMS8fdGEZ8v+VBPxQFZZ7HQ2KcuPNsuw5R6g== + version "1.18.2" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.18.2.tgz#7513b92df7c5a0d3c27342c591ada7340696cf8f" + integrity sha512-aWfRR5y1gXCPUNK7zaUiSbmceqidvZ38mNHIBvXmZArRigyz1QZgN26kKpXjJjtFbPvROPnlGsYcZBReybvZXA== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/typescript" "^1.18.1" - "@graphql-codegen/visitor-plugin-common" "^1.17.20" - "@graphql-tools/utils" "^6" - auto-bind "~4.0.0" - tslib "~2.0.1" - -"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.18.1": - version "1.20.2" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.20.2.tgz#223327a8aea929e772f481e264c5aa3a36707fcb" - integrity sha512-D/DMUz4O2UEoFucUVu2K2xoaMPAn68BwYGnCAKnSDqtFKsOEqmTjHFwcgyEnpucQ5xFeG0pKGMb1SlS2Go9J8Q== - dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/visitor-plugin-common" "^1.18.2" + "@graphql-codegen/typescript" "^1.21.0" + "@graphql-codegen/visitor-plugin-common" "^1.18.3" + "@graphql-tools/utils" "^7.0.0" auto-bind "~4.0.0" tslib "~2.1.0" -"@graphql-codegen/visitor-plugin-common@^1.17.20", "@graphql-codegen/visitor-plugin-common@^1.18.2": - version "1.18.2" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.18.2.tgz#1a30bd33f011b4fb976e0f8462d160126db875ea" - integrity sha512-A8yBJGq7A7gxaVVXK4QXwV1ZpzZ64fH7U7JTGeq86o3jA7QNV2rmCRXCY0JttS2fu+olV18NjsWRwAXuAb1g9A== +"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.21.0": + version "1.21.0" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.21.0.tgz#301b1851cd278bedd1f49e1b3d654f4dc0af2943" + integrity sha512-23YttnZ+87dA/3lbCvPKdsrpEOx142dCT9xSh6XkSeyCvn+vUtETN2MhamCYB87G7Nu2EcLDFKDZjgXH73f4fg== + dependencies: + "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-codegen/visitor-plugin-common" "^1.18.3" + auto-bind "~4.0.0" + tslib "~2.1.0" + +"@graphql-codegen/visitor-plugin-common@^1.18.3": + version "1.18.3" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.18.3.tgz#9d2c4449c3bdaffe3e782e2321fe0cb998b8a91d" + integrity sha512-6xJzt8hszCTKt3rTlcCURpuiAFuaiaZgStlVeRE1OrKEDiY1T3vwF3/7TonhfnEjqBWtZdMmXvNx3ArXkRUV4w== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" "@graphql-tools/optimize" "^1.0.1" @@ -2791,14 +2806,14 @@ camel-case "4.1.1" tslib "~2.0.1" -"@graphql-tools/utils@^7.1.0": - version "7.1.0" - resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.1.0.tgz#0cbcfa7b6e7741650f78e8bde4823780ed9e8c57" - integrity sha512-lMTgy08BdqQ31zyyCRrxcKZ6gAKQB9amGKJGg0mb3b4I3iJQKeaw9a7T96yGe1frGak1UK9y7OoYqx8RG7KitA== +"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.0": + version "7.2.6" + resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.2.6.tgz#5d974cbdec5ddf4d7fdc593816335512ee5fe4de" + integrity sha512-/kY7Nb+cCHi/MvU3tjz3KrXzuJNWMlnn7EoWazLmpDvl6b2Qt69hlVoPd5zQtKlGib35zZw9NZ5zs5qTAw8Y9g== dependencies: "@ardatan/aggregate-error" "0.0.6" - camel-case "4.1.1" - tslib "~2.0.1" + camel-case "4.1.2" + tslib "~2.1.0" "@graphql-tools/wrap@^6.2.4": version "6.2.4" @@ -4117,10 +4132,10 @@ resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.2.0.tgz#123e0438a0bc718ccdac3b5a2e69b3dd00daa85b" integrity sha512-274lNUDonw10kT8wHg8fCcUc1ZjZHbWv0/TbAwb0ojhBQqZYc1cQ/4yqTVTtPMDeZ//g7xVEYe/s3vURkRghPg== -"@octokit/openapi-types@^4.0.2": - version "4.0.2" - resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-4.0.2.tgz#4b2bb553a16ab9e0fdeb29bd453b1c88cf129929" - integrity sha512-quqmeGTjcVks8YaatVGCpt7QpUTs2PK0D3mW5aEQqmFKOuIZ/CxwWrgnggPjqP3CNp6eALdQRgf0jUpcG8X1/Q== +"@octokit/openapi-types@^4.0.3": + version "4.0.4" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-4.0.4.tgz#96fcce11e929802898646205ac567e5df592f82b" + integrity sha512-31zY8JIuz3h6RAFOnyA8FbOwhILILiBu1qD81RyZZWY7oMBhIdBn6MaAmnnptLhB4jk0g50nkQkUVP4kUzppcA== "@octokit/plugin-enterprise-rest@^6.0.1": version "6.0.1" @@ -4139,12 +4154,12 @@ resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44" integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg== -"@octokit/plugin-rest-endpoint-methods@4.10.1": - version "4.10.1" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.10.1.tgz#b7a9181d1f52fef70a13945c5b49cffa51862da1" - integrity sha512-YGMiEidTORzgUmYZu0eH4q2k8kgQSHQMuBOBYiKxUYs/nXea4q/Ze6tDzjcRAPmHNJYXrENs1bEMlcdGKT+8ug== +"@octokit/plugin-rest-endpoint-methods@4.10.3": + version "4.10.3" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.10.3.tgz#d78ddf926bca3b81a4d9b79a463d32b3750a4485" + integrity sha512-CsNQeVY34Vs9iea2Z9/TCPlebxv6KpjO9f1BUPz+14qundTSYT9kgf8j5wA1k37VstfBQ4xnuURYdnbGzJBJXw== dependencies: - "@octokit/types" "^6.8.2" + "@octokit/types" "^6.8.3" deprecation "^2.3.1" "@octokit/plugin-rest-endpoint-methods@4.4.1": @@ -4189,14 +4204,14 @@ "@octokit/plugin-rest-endpoint-methods" "4.4.1" "@octokit/rest@^18.1.0": - version "18.1.0" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.1.0.tgz#9bf72604911a3433165bcc924263c9a706d32804" - integrity sha512-YQfpTzWV3jdzDPyXQVO54f5I2t1zxk/S53Vbe+Aa5vQj6MdTx6sNEWzmUzUO8lSVowbGOnjcQHzW1A8ATr+/7g== + version "18.1.1" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.1.1.tgz#bd7053c28db3577c936029e9da6bfbd046474a2f" + integrity sha512-ZcCHMyfGT1qtJD72usigAfUQ6jU89ZUPFb2AOubR6WZ7/RRFVZUENVm1I2yvJBUicqTujezPW9cY1+o3Mb4rNA== dependencies: "@octokit/core" "^3.2.3" "@octokit/plugin-paginate-rest" "^2.6.2" "@octokit/plugin-request-log" "^1.0.2" - "@octokit/plugin-rest-endpoint-methods" "4.10.1" + "@octokit/plugin-rest-endpoint-methods" "4.10.3" "@octokit/types@^5.0.0", "@octokit/types@^5.0.1": version "5.5.0" @@ -4213,13 +4228,12 @@ "@octokit/openapi-types" "^2.2.0" "@types/node" ">= 8" -"@octokit/types@^6.8.2": - version "6.8.3" - resolved "https://registry.npmjs.org/@octokit/types/-/types-6.8.3.tgz#1960951103c836ab2e55fe47a8da2bf76402824f" - integrity sha512-ZNAy8z77ewKZ5LCX0KaUm4tWdgloWQ6FWJCh06qgahq/MH13sQefIPKSo0dBdPU3bcioltyZUcC0k8oHHfjvnQ== +"@octokit/types@^6.8.3": + version "6.8.5" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.8.5.tgz#797dfdad8c75718e97dc687d4c9fc49200ca8d17" + integrity sha512-ZsQawftZoi0kSF2pCsdgLURbOjtVcHnBOXiSxBKSNF56CRjARt5rb/g8WJgqB8vv4lgUEHrv06EdDKYQ22vA9Q== dependencies: - "@octokit/openapi-types" "^4.0.2" - "@types/node" ">= 8" + "@octokit/openapi-types" "^4.0.3" "@open-draft/until@^1.0.3": version "1.0.3" @@ -7766,9 +7780,9 @@ archiver-utils@^2.1.0: readable-stream "^2.0.0" archiver@^5.0.2: - version "5.1.0" - resolved "https://registry.npmjs.org/archiver/-/archiver-5.1.0.tgz#05b0f6f7836f3e6356a0532763d2bb91017a7e37" - integrity sha512-iKuQUP1nuKzBC2PFlGet5twENzCfyODmvkxwDV0cEFXavwcLrIW5ssTuHi9dyTPvpWr6Faweo2eQaQiLIwyXTA== + version "5.2.0" + resolved "https://registry.npmjs.org/archiver/-/archiver-5.2.0.tgz#25aa1b3d9febf7aec5b0f296e77e69960c26db94" + integrity sha512-QEAKlgQuAtUxKeZB9w5/ggKXh21bZS+dzzuQ0RPBC20qtDCbTyzqmisoeJP46MP39fg4B4IcyvR+yeyEBdblsQ== dependencies: archiver-utils "^2.1.0" async "^3.2.0" @@ -9214,6 +9228,14 @@ camel-case@4.1.1, camel-case@^4.1.1: pascal-case "^3.1.1" tslib "^1.10.0" +camel-case@4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" + integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== + dependencies: + pascal-case "^3.1.2" + tslib "^2.0.3" + camelcase-keys@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" @@ -10121,15 +10143,15 @@ conventional-commits-filter@^2.0.7: modify-values "^1.0.0" conventional-commits-parser@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.0.tgz#9e261b139ca4b7b29bcebbc54460da36894004ca" - integrity sha512-XmJiXPxsF0JhAKyfA2Nn+rZwYKJ60nanlbSWwwkGwLQFbugsc0gv1rzc7VbbUWAzJfR1qR87/pNgv9NgmxtBMQ== + version "3.2.1" + resolved "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.1.tgz#ba44f0b3b6588da2ee9fd8da508ebff50d116ce2" + integrity sha512-OG9kQtmMZBJD/32NEw5IhN5+HnBqVjy03eC+I71I0oQRFA5rOgA4OtPOYG7mz1GkCfCNxn3gKIX8EiHJYuf1cA== dependencies: JSONStream "^1.0.4" is-text-path "^1.0.1" lodash "^4.17.15" meow "^8.0.0" - split2 "^2.0.0" + split2 "^3.0.0" through2 "^4.0.0" trim-off-newlines "^1.0.0" @@ -15892,14 +15914,6 @@ jest-environment-node@^26.6.2: jest-mock "^26.6.2" jest-util "^26.6.2" -jest-esm-transformer@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/jest-esm-transformer/-/jest-esm-transformer-1.0.0.tgz#b6c58f496aa48194f96361a52f5c578fd2209726" - integrity sha512-FoPgeMMwy1/CEsc8tBI41i83CEO3x85RJuZi5iAMmWoARXhfgk6Jd7y+4d+z+HCkTKNVDvSWKGRhwjzU9PUbrw== - dependencies: - "@babel/core" "^7.4.4" - "@babel/plugin-transform-modules-commonjs" "^7.4.4" - jest-get-type@^24.9.0: version "24.9.0" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" @@ -17449,6 +17463,13 @@ lower-case@2.0.1, lower-case@^2.0.1: dependencies: tslib "^1.10.0" +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -18560,6 +18581,14 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + nock@^13.0.5: version "13.0.5" resolved "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414" @@ -19761,6 +19790,14 @@ pascal-case@3.1.1, pascal-case@^3.1.1: no-case "^3.0.3" tslib "^1.10.0" +pascal-case@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" + integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" @@ -23399,13 +23436,6 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" -split2@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" - integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw== - dependencies: - through2 "^2.0.2" - split2@^3.0.0: version "3.2.2" resolved "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" @@ -24452,7 +24482,7 @@ throttleit@^1.0.0: resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= -through2@^2.0.0, through2@^2.0.2: +through2@^2.0.0: version "2.0.5" resolved "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== @@ -24831,7 +24861,7 @@ tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1 resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@~2.1.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@~2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
{emptyContent}