Merge branch 'master' of github.com:backstage/backstage into mob/scaffolder-frontend
* 'master' of github.com:backstage/backstage: (118 commits) cli: Fix handling of dynamic imports in esm.js files minor typo in migration chore(deps): bump archiver from 5.1.0 to 5.2.0 dockerfile: mention build-image command Apply suggestions from code review update backend Dockerfile to use config example and fix comment docs: add full docker deployment docs chore: fix code review chore: fixing syntax docs: fixing custom implementations of utitiy apis a small start to the integrations section of the config TechDocs: Add changeset about Docker permission fix Updated unit tests for the new UI TechDocs: Pass user and group ID when invoking docker container Replace logging erro and return undefined for a throw new Error @types/react 16 not 17 Use a more strict type for `variant` of cards docs(TechDocs): Add more context with AWS docs hyperlinks Added missing dep on @types/react Removed unused import ...
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-sonarqube': patch
|
||||
---
|
||||
|
||||
Fix bug retrieving current theme
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Updated transform of `.esm.js` files to be able to handle dynamic imports.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-splunk-on-call': patch
|
||||
---
|
||||
|
||||
Added splunk-on-call plugin.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Added githubApp authentication to the scaffolder-backend plugin
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Minor typo in migration
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
- Fixes padding in `MembersListCard`
|
||||
- Fixes email icon size in `GroupProfileCard`
|
||||
- Uniform sizing across `GroupProfileCard` and `UserProfileCard`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-pagerduty': minor
|
||||
---
|
||||
|
||||
Improved the UI of the pagerduty plugin, and added a standalone TriggerButton
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/techdocs-common': patch
|
||||
---
|
||||
|
||||
Improved error reporting in AzureBlobStorage to surface errors when fetching metadata and uploading files fails.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
.git
|
||||
docs
|
||||
cypress
|
||||
microsite
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
plugins/*/node_modules
|
||||
plugins/*/dist
|
||||
packages
|
||||
!packages/backend/dist
|
||||
plugins
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!**
|
||||
|
||||
|
||||
+39
-7
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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://<host>/api/v3`.
|
||||
@@ -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: <nothing, left out>
|
||||
# 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: <nothing, left out>
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.picture field of the entity.
|
||||
picture: <nothing, left out>
|
||||
# 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
|
||||
```
|
||||
+11
-11
@@ -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.
|
||||
|
||||
@@ -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
|
||||
+14
-1
@@ -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",
|
||||
|
||||
+7
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
</Grid>
|
||||
{isPagerDutyAvailable(entity) && (
|
||||
<Grid item md={6}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={4} sm={6}>
|
||||
@@ -206,6 +214,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<PullRequestsStatsCard entity={entity} />
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={6}>
|
||||
<EntityHasSubcomponentsCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -425,6 +436,51 @@ const GroupEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasComponentsCard variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasApisCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const SystemEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<SystemOverviewContent entity={entity as SystemEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasSystemsCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const DomainEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<DomainOverviewContent entity={entity as DomainEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
export const EntityPage = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
@@ -437,6 +493,10 @@ export const EntityPage = () => {
|
||||
return <GroupEntityPage entity={entity} />;
|
||||
case 'user':
|
||||
return <UserEntityPage entity={entity} />;
|
||||
case 'system':
|
||||
return <SystemEntityPage entity={entity} />;
|
||||
case 'domain':
|
||||
return <DomainEntityPage entity={entity} />;
|
||||
default:
|
||||
return <DefaultEntityPage entity={entity} />;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={testData10}
|
||||
@@ -77,7 +89,8 @@ export const DefaultTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleTable = () => {
|
||||
export const EmptyTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -101,7 +114,49 @@ export const SubtitleTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={[]}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<div className={classes.empty}>
|
||||
No data was added yet,
|
||||
<Link to="http://backstage.io/">learn how to add data</Link>.
|
||||
</div>
|
||||
}
|
||||
title="Backstage Table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={testData10}
|
||||
@@ -114,6 +169,7 @@ export const SubtitleTable = () => {
|
||||
};
|
||||
|
||||
export const HiddenSearchTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -137,7 +193,7 @@ export const HiddenSearchTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, search: false }}
|
||||
data={testData10}
|
||||
@@ -148,6 +204,7 @@ export const HiddenSearchTable = () => {
|
||||
};
|
||||
|
||||
export const SubvalueTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -181,13 +238,14 @@ export const SubvalueTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table options={{ paging: false }} data={testData10} columns={columns} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DenseTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -211,7 +269,7 @@ export const DenseTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
@@ -223,6 +281,7 @@ export const DenseTable = () => {
|
||||
};
|
||||
|
||||
export const FilterTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -261,7 +320,7 @@ export const FilterTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
|
||||
@@ -53,4 +53,16 @@ describe('<Table />', () => {
|
||||
);
|
||||
expect(rendered.getByText('subtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom empty component if empty', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<Table
|
||||
subtitle="subtitle"
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={minProps.columns}
|
||||
data={[]}
|
||||
/>,
|
||||
);
|
||||
expect(rendered.getByText('EMPTY')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T extends object = {}>
|
||||
subtitle?: string;
|
||||
filters?: TableFilter[];
|
||||
initialState?: TableState;
|
||||
emptyContent?: ReactNode;
|
||||
onStateChange?: (state: TableState) => any;
|
||||
}
|
||||
|
||||
@@ -212,6 +215,7 @@ export function Table<T extends object = {}>({
|
||||
subtitle,
|
||||
filters,
|
||||
initialState,
|
||||
emptyContent,
|
||||
onStateChange,
|
||||
...props
|
||||
}: TableProps<T>) {
|
||||
@@ -423,6 +427,23 @@ export function Table<T extends object = {}>({
|
||||
],
|
||||
);
|
||||
|
||||
const Body = useCallback(
|
||||
bodyProps => {
|
||||
if (emptyContent && data.length === 0) {
|
||||
return (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={columns.length}>{emptyContent}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return <MTableBody {...bodyProps} />;
|
||||
},
|
||||
[data, emptyContent, columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tableClasses.root}>
|
||||
{filtersOpen && data && filters?.length && (
|
||||
@@ -438,6 +459,7 @@ export function Table<T extends object = {}>({
|
||||
<MTableHeader classes={headerClasses} {...headerProps} />
|
||||
),
|
||||
Toolbar,
|
||||
Body,
|
||||
}}
|
||||
options={{ ...defaultOptions, ...options }}
|
||||
columns={MTColumns}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { InfoCard } from './InfoCard';
|
||||
export type { InfoCardVariants } from './InfoCard';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
node_modules
|
||||
packages
|
||||
!packages/backend/dist
|
||||
plugins
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<BlobUploadCommonResponse> {
|
||||
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<BlobUploadCommonResponse> {
|
||||
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<ContainerGetPropertiesResponse> {
|
||||
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<ContainerGetPropertiesResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Promise<BlobUploadCommonResponse>> = [];
|
||||
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
return this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(`${entityRootDir}/index.html`)
|
||||
.exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EntityRow>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'entity.metadata.name',
|
||||
highlight: true,
|
||||
render: ({ entity }) => (
|
||||
<EntityRefLink entityRef={entity}>{entity.metadata.name}</EntityRefLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
field: 'resolved.partOfSystemRelationTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.partOfSystemRelations}
|
||||
defaultKind="system"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'resolved.ownedByRelationsTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Lifecycle',
|
||||
field: 'entity.spec.lifecycle',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
field: 'entity.spec.type',
|
||||
render: ({ entity }) => <ApiTypeTitle apiEntity={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 (
|
||||
<Table<EntityRow>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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('<ConsumedApisCard />', () => {
|
||||
);
|
||||
|
||||
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('<ConsumedApisCard />', () => {
|
||||
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: () => <div />,
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -147,12 +115,8 @@ describe('<ConsumedApisCard />', () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Consumed APIs">
|
||||
{children}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<ApisCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Consumed APIs">
|
||||
<Progress />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the consumed APIs."
|
||||
<InfoCard variant={variant} title="Consumed APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ApisCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<MissingConsumesApisEmptyState />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ApisTable
|
||||
<EntityTable
|
||||
title="Consumed APIs"
|
||||
variant={variant}
|
||||
entities={entities as (ApiEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No Component consumes this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional">
|
||||
Learn how to consume APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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('<HasApisCard />', () => {
|
||||
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
|
||||
getApiDefinitionWidget: jest.fn(),
|
||||
} as any;
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
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 }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('APIs')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="APIs">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="APIs"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No API is part of this system.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api">
|
||||
Learn how to add APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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('<ProvidedApisCard />', () => {
|
||||
);
|
||||
|
||||
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('<ProvidedApisCard />', () => {
|
||||
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: () => <div />,
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -149,10 +117,6 @@ describe('<ProvidedApisCard />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Provided APIs">
|
||||
{children}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<ApisCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Provided APIs">
|
||||
<Progress />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the provided APIs."
|
||||
<InfoCard variant={variant} title="Provided APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ApisCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<MissingProvidesApisEmptyState />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ApisTable
|
||||
<EntityTable
|
||||
title="Provided APIs"
|
||||
variant={variant}
|
||||
entities={entities as (ApiEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component provides this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional">
|
||||
Learn how to provide APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { ConsumedApisCard } from './ConsumedApisCard';
|
||||
export { HasApisCard } from './HasApisCard';
|
||||
export { ProvidedApisCard } from './ProvidedApisCard';
|
||||
|
||||
@@ -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<ApiEntity> {
|
||||
return {
|
||||
title: 'Type',
|
||||
field: 'spec.type',
|
||||
render: entity => <ApiTypeTitle apiEntity={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<ApiEntity>[] = [
|
||||
EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }),
|
||||
EntityTable.columns.createSystemColumn(),
|
||||
EntityTable.columns.createOwnerColumn(),
|
||||
EntityTable.columns.createSpecLifecycleColumn(),
|
||||
createSpecApiTypeColumn(),
|
||||
EntityTable.columns.createMetadataDescriptionColumn(),
|
||||
];
|
||||
@@ -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<EntityRow>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'entity.metadata.name',
|
||||
highlight: true,
|
||||
render: ({ entity }) => (
|
||||
<EntityRefLink entityRef={entity}>{entity.metadata.name}</EntityRefLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
field: 'resolved.partOfSystemRelationTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.partOfSystemRelations}
|
||||
defaultKind="system"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'resolved.ownedByRelationsTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Table<EntityRow>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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('<ConsumingComponentsCard />', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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('<ConsumingComponentsCard />', () => {
|
||||
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('<ConsumingComponentsCard />', () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Consumers">
|
||||
{children}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<ComponentsCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Consumers">
|
||||
<Progress />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the consumers."
|
||||
<InfoCard variant={variant} title="Consumers">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ComponentsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<MissingConsumesApisEmptyState />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComponentsTable
|
||||
<EntityTable
|
||||
title="Consumers"
|
||||
variant={variant}
|
||||
entities={entities as (ComponentEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component consumes this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional">
|
||||
Learn how to consume APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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('<ProvidingComponentsCard />', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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('<ProvidingComponentsCard />', () => {
|
||||
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('<ProvidingComponentsCard />', () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Providers">
|
||||
{children}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<ComponentsCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Providers">
|
||||
<Progress />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the providers."
|
||||
<InfoCard variant={variant} title="Providers">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ComponentsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<MissingProvidesApisEmptyState />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComponentsTable
|
||||
<EntityTable
|
||||
title="Providers"
|
||||
variant={variant}
|
||||
entities={entities as (ComponentEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component provides this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional">
|
||||
Learn how to provide APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<BackstageTheme>(theme => ({
|
||||
code: {
|
||||
borderRadius: 6,
|
||||
margin: `${theme.spacing(2)}px 0px`,
|
||||
background: theme.palette.type === 'dark' ? '#444' : '#fff',
|
||||
},
|
||||
}));
|
||||
|
||||
export const MissingConsumesApisEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="No APIs consumed by this entity"
|
||||
description={
|
||||
<>
|
||||
Components can consume APIs that are displayed on this page. You need
|
||||
to fill the <code>consumesApis</code> field to enable this tool.
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
<Typography variant="body1">
|
||||
Link an API to your component as shown in the highlighted example
|
||||
below:
|
||||
</Typography>
|
||||
<div className={classes.code}>
|
||||
<CodeSnippet
|
||||
text={COMPONENT_YAML}
|
||||
language="yaml"
|
||||
showLineNumbers
|
||||
highlightedNumbers={[10, 11]}
|
||||
customStyle={{ background: 'inherit', fontSize: '115%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<BackstageTheme>(theme => ({
|
||||
code: {
|
||||
borderRadius: 6,
|
||||
margin: `${theme.spacing(2)}px 0px`,
|
||||
background: theme.palette.type === 'dark' ? '#444' : '#fff',
|
||||
},
|
||||
}));
|
||||
|
||||
export const MissingProvidesApisEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="No APIs provided by this entity"
|
||||
description={
|
||||
<>
|
||||
Components can implement APIs that are displayed on this page. You
|
||||
need to fill the <code>providesApis</code> field to enable this tool.
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
<Typography variant="body1">
|
||||
Link an API to your component as shown in the highlighted example
|
||||
below:
|
||||
</Typography>
|
||||
<div className={classes.code}>
|
||||
<CodeSnippet
|
||||
text={COMPONENT_YAML}
|
||||
language="yaml"
|
||||
showLineNumbers
|
||||
highlightedNumbers={[10, 11]}
|
||||
customStyle={{ background: 'inherit', fontSize: '115%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -24,4 +24,5 @@ export {
|
||||
EntityConsumingComponentsCard,
|
||||
EntityProvidedApisCard,
|
||||
EntityProvidingComponentsCard,
|
||||
EntityHasApisCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -106,3 +106,11 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityHasApisCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/ApisCards').then(m => m.HasApisCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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('<EntityTable />', () => {
|
||||
it('shows empty table', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityTable
|
||||
title="Entities"
|
||||
entities={[]}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
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(
|
||||
<EntityTable
|
||||
title="Entities"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'metadata.name',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('my-entity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T extends Entity> = {
|
||||
title: string;
|
||||
variant?: 'gridItem';
|
||||
entities: T[];
|
||||
emptyContent?: ReactNode;
|
||||
columns: TableColumn<T>[];
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
empty: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export function EntityTable<T extends Entity>({
|
||||
entities,
|
||||
title,
|
||||
emptyContent,
|
||||
variant = 'gridItem',
|
||||
columns,
|
||||
}: Props<T>) {
|
||||
const classes = useStyles();
|
||||
const tableStyle: React.CSSProperties = {
|
||||
minWidth: '0',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
if (variant === 'gridItem') {
|
||||
tableStyle.height = 'calc(100% - 10px)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<T>
|
||||
columns={columns}
|
||||
title={title}
|
||||
style={tableStyle}
|
||||
emptyContent={
|
||||
emptyContent && <div className={classes.empty}>{emptyContent}</div>
|
||||
}
|
||||
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;
|
||||
@@ -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<T extends Entity>({
|
||||
defaultKind,
|
||||
}: {
|
||||
defaultKind?: string;
|
||||
}): TableColumn<T> {
|
||||
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 => (
|
||||
<EntityRefLink entityRef={entity} defaultKind={defaultKind} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEntityRelationColumn<T extends Entity>({
|
||||
title,
|
||||
relation,
|
||||
defaultKind,
|
||||
filter: entityFilter,
|
||||
}: {
|
||||
title: string;
|
||||
relation: string;
|
||||
defaultKind?: string;
|
||||
filter?: { kind: string };
|
||||
}): TableColumn<T> {
|
||||
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 (
|
||||
<EntityRefLinks
|
||||
entityRefs={getRelations(entity)}
|
||||
defaultKind={defaultKind}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOwnerColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'Owner',
|
||||
relation: RELATION_OWNED_BY,
|
||||
defaultKind: 'group',
|
||||
});
|
||||
}
|
||||
|
||||
export function createDomainColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'Domain',
|
||||
relation: RELATION_PART_OF,
|
||||
defaultKind: 'domain',
|
||||
filter: {
|
||||
kind: 'domain',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'System',
|
||||
relation: RELATION_PART_OF,
|
||||
defaultKind: 'system',
|
||||
filter: {
|
||||
kind: 'system',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createMetadataDescriptionColumn<
|
||||
T extends Entity
|
||||
>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Description',
|
||||
field: 'metadata.description',
|
||||
width: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpecLifecycleColumn<T extends Entity>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Lifecycle',
|
||||
field: 'spec.lifecycle',
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpecTypeColumn<T extends Entity>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Type',
|
||||
field: 'spec.type',
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
<EntityTable
|
||||
title="My Systems"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
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(
|
||||
<EntityTable
|
||||
title="My Components"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SystemEntity>[] = [
|
||||
createEntityRefColumn({ defaultKind: 'system' }),
|
||||
createDomainColumn(),
|
||||
createOwnerColumn(),
|
||||
createMetadataDescriptionColumn(),
|
||||
];
|
||||
|
||||
export const componentEntityColumns: TableColumn<ComponentEntity>[] = [
|
||||
createEntityRefColumn({ defaultKind: 'component' }),
|
||||
createSystemColumn(),
|
||||
createOwnerColumn(),
|
||||
createSpecTypeColumn(),
|
||||
createSpecLifecycleColumn(),
|
||||
createMetadataDescriptionColumn(),
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
export { EntityContext, useEntity, useEntityFromUrl } from './useEntity';
|
||||
export { useEntityCompoundName } from './useEntityCompoundName';
|
||||
export { useRelatedEntities } from './useRelatedEntities';
|
||||
|
||||
+20
-11
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('<HasComponentsCard />', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
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 }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Components')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Components">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Components">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Components"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component is part of this system.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component">
|
||||
Learn how to add components.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-2
@@ -14,5 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
|
||||
export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
|
||||
export { HasComponentsCard } from './HasComponentsCard';
|
||||
@@ -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('<HasSubcomponentsCard />', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
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 }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSubcomponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSubcomponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Subcomponents')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Subcomponents">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Subcomponents">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load subcomponents"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Subcomponents"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No subcomponent is part of this component.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specsubcomponentof-optional">
|
||||
Learn how to add subcomponents.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-12
@@ -14,15 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
|
||||
|
||||
describe('<MissingConsumesApisEmptyState />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<MissingConsumesApisEmptyState />,
|
||||
);
|
||||
expect(getByText(/consumesApis:/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
export { HasSubcomponentsCard } from './HasSubcomponentsCard';
|
||||
@@ -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('<HasSystemsCard />', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
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 }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSystemsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSystemsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Systems')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<InfoCard variant={variant} title="Systems">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Systems">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load systems"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Systems"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No system is part of this domain.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-system">
|
||||
Learn how to add systems.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.systemEntityColumns}
|
||||
entities={entities as SystemEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Theme>({
|
||||
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 = ({
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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<Theme>({
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user