Merge branch 'backstage:master' into master

This commit is contained in:
Daniel Johansson
2021-06-17 18:14:23 +02:00
committed by GitHub
153 changed files with 8868 additions and 1354 deletions
+54
View File
@@ -0,0 +1,54 @@
---
'@backstage/plugin-explore': patch
---
Refactors the explore plugin to be more customizable. This includes the following non-breaking changes:
- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage`
- Refactor `ExplorePage` to use a new `ExploreLayout` component
- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components
- Allows `title` props to be customized
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
```tsx
import {
DomainExplorerContent,
ExploreLayout,
} from '@backstage/plugin-explore';
import React from 'react';
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
export const ExplorePage = () => {
return (
<ExploreLayout
title="Explore the ACME corp ecosystem"
subtitle="Browse our ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="inner-source" title="InnerSource">
<AcmeInnserSourceExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
export const explorePage = <ExplorePage />;
```
Now register the new explore page in `packages/app/src/App.tsx`.
```diff
+ import { explorePage } from './components/explore/ExplorePage';
const routes = (
<FlatRoutes>
- <Route path="/explore" element={<ExplorePage />} />
+ <Route path="/explore" element={<ExplorePage />}>
+ {explorePage}
+ </Route>
</FlatRoutes>
);
```
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/core-app-api': patch
'@backstage/core-plugin-api': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-scaffolder': patch
---
Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Make the `create-github-app` command disable webhooks by default.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-scaffolder-backend': patch
---
Moved installation instructions from the main [backstage.io](https://backstage.io) documentation to the package README file. These instructions are not generally needed, since the plugin comes installed by default with `npx @backstage/create-app`.
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/plugin-auth-backend': patch
---
Adds support for custom sign-in resolvers and profile transformations for the
Google auth provider.
Adds an `ent` claim in Backstage tokens, with a list of
[entity references](https://backstage.io/docs/features/software-catalog/references)
related to your signed-in user's identities and groups across multiple systems.
Adds an optional `providerFactories` argument to the `createRouter` exported by
the `auth-backend` plugin.
Updates `BackstageIdentity` so that
- `idToken` is deprecated in favor of `token`
- An optional `entity` field is added which represents the entity that the user is represented by within Backstage.
More information:
- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver)
explains the concepts and shows how to implement your own.
- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089)
RFC contains details about how this affects ownership in the catalog
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Refactor the implicit logic from `<Reader />` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built. This enables the caller to know whether a refresh of the docs page will return updated content (-> `201 Created`) or not (-> `304 Not Modified`).
+150
View File
@@ -0,0 +1,150 @@
---
id: identity-resolver
title: Identity resolver
description: Identity resolvers of Backstage users after they sign-in
---
This guide explains how the identity of a Backstage user is stored inside their
Backstage Identity Token and how you can customize the Sign-In resolvers to
include identity and group membership information of the user from other
external systems. This ultimately helps with determining the ownership of a
Backstage entity by a user. The ideas here were originally proposed in the RFC
[#4089](https://github.com/backstage/backstage/issues/4089).
When a user signs in to Backstage, inside the `claims` field of their Backstage
Token (which are standard JWT tokens) a special `ent` claim is set. `ent`
contains a list of
[entity references](../features/software-catalog/references.md), each of which
denotes an identity or a membership that is relevant to the user. There is no
guarantee that these correspond to actual existing catalog entities.
Let's take an example sign-in resolver for the Google auth provider and explore
how the `ent` field inside `claims` can be set.
Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom
sign-in resolvers and set them for any of the Authentication providers inside
`providerFactories` of the `createRouter` imported from the
`@backstage/plugin-auth-backend` plugin.
```ts
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
// Call a custom validator function that checks that the email is
// valid and on our own company's domain, and throws an Error if it
// isn't
validateEmail(email);
// List of entity references that denote the identity and
// membership of the user
const ent = [];
// Let's use the username in the email ID as the user's default
// unique identifier inside Backstage.
const [id] = email.split('@');
ent.push(`User:default/${id}`)
// Let's call the internal LDAP provider to get a list of groups
// that the user belongs to, and add those to the list as well
const ldapGroups = await getLdapGroups(email);
ldapGroups.forEach(group => ent.push(`Group:default/${group}`))
// Issue the token containing the entity claims
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent },
});
return { id, token };
},
},
}),
},
});
}
```
As you can see, the generated Backstage Token now contains all the claims about
the identity and membership of the user. Once the sign-in process is complete,
and we need to find out if a user owns an Entity in the Software Catalog, these
`ent` claims can be used to determine the ownership.
According to the RFC, the definition of the ownership of an entity E, for a user
U, is as follows:
- Get all the `ownedBy` relations of E, and call them O
- Get all the claims of the user U and call them C
- If any C matches any O, return `true`
- Get all Group entities that U is a member of, using the regular
`memberOf`/`hasMember` relation mechanism, and call them G
- If any G matches any O, return `true`
- Otherwise, return `false`
## Default sign-in resolvers
Of course you don't have to customize the sign-in resolver if you don't need to.
The Auth backend plugin comes with a set of default sign-in resolvers which you
can use. For example - the Google provider has a default email-based sign-in
resolver, which will search the catalog for a single user entity that has a
matching `google.com/email` annotation.
It can be enabled like this
```tsx
// File: packages/backend/src/plugins/auth.ts
import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend';
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: googleEmailSignInResolver
}
...
```
## AuthHandler
Similar to a custom sign-in resolver, you can also write a custom auth handler
function which is used to verify and convert the auth response into the profile
that will be presented to the user. This is where you can customize things like
display name and profile picture.
This is also the place where you can do authorization and validation of the user
and throw errors if the user should not be allowed access in Backstage.
```tsx
// File: packages/backend/src/plugins/auth.ts
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
authHandler: async ({
fullProfile // Type: passport.Profile,
idToken // Type: (Optional) string,
}) => {
// Custom validation code goes here
return {
profile: {
email,
picture,
displayName,
}
};
}
})
}
})
}
```
+2 -3
View File
@@ -34,9 +34,8 @@ More specifically, the Service Catalog enables two main use-cases:
## Getting Started
The Software Catalog is available to browse at `/catalog`. If you've followed
[Installing in your Backstage App](./installation.md) in your separate App or
[Getting Started with Backstage](../../getting-started) for this repo, you
should be able to browse the catalog at `http://localhost:3000`.
[Getting Started with Backstage](../../getting-started), you should be able to
browse the catalog at `http://localhost:3000`.
![](../../assets/software-catalog/service-catalog-home.png)
@@ -1,177 +0,0 @@
---
id: installation
title: Installing in your Backstage App
description: Documentation on How to install Backstage Plugin
---
The catalog plugin comes in two packages, `@backstage/plugin-catalog` and
`@backstage/plugin-catalog-backend`. Each has their own installation steps,
outlined below.
## Installing @backstage/plugin-catalog
> **Note that if you used `npx @backstage/create-app`, the plugin is already
> installed and you can skip to
> [adding entries to the catalog](#adding-entries-to-the-catalog)**
The catalog frontend plugin should be installed in your `app` package, which is
created as a part of `@backstage/create-app`. To install the package, run:
```bash
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-catalog
```
### Adding the Plugin to your `packages/app`
Add the two pages that the catalog plugin provides to your app. You can choose
any name for these routes, but we recommend the following:
```tsx
// packages/app/src/App.tsx
import {
catalogPlugin,
CatalogIndexPage,
CatalogEntityPage,
} from '@backstage/plugin-catalog';
// Add to the top-level routes, directly within <FlatRoutes>
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />}>
{/*
This is the root of the custom entity pages for your app, refer to the example app
in the main repo or the output of @backstage/create-app for an example
*/}
<EntityPage />
</Route>
```
The catalog plugin also has one external route that needs to be bound for it to
function: the `createComponent` route which should link to the page where the
user can create components. In a typical setup the create component route will
be linked to the Scaffolder plugin's template index page:
```ts
// packages/app/src/App.tsx
import { catalogPlugin } from '@backstage/plugin-catalog';
import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
const app = createApp({
// ...
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
});
},
});
```
You may also want to add a link to the catalog index page to your sidebar:
```tsx
// packages/app/src/components/Root.tsx
import HomeIcon from '@material-ui/icons/Home';
// Somewhere within the <Sidebar>
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />;
```
This is all that is needed for the frontend part of the Catalog plugin to work!
## Gotchas that we will fix
Since the catalog plugin currently ships with a sentry plugin `InfoCard`
installed by default, you'll need to set `sentry.organization` in your
`app-config.yaml`. For example:
```yaml
sentry:
organization: Acme Corporation
```
If you've created an app with an older version of `@backstage/create-app` or
`@backstage/cli create-app`, be sure to remove the Welcome plugin from the app,
as that will conflict with the catalog routes.
## Installing @backstage/plugin-catalog-backend
> **Note that if you used `npx @backstage/create-app`, the plugin is already
> installed and you can skip to
> [adding entries to the catalog](#adding-entries-to-the-catalog)**
The catalog backend should be installed in your `backend` package, which is
created as a part of `@backstage/create-app`. To install the package, run:
```bash
# From your Backstage root directory
cd packages/backend
yarn add @backstage/plugin-catalog-backend
```
### Adding the Plugin to your `packages/backend`
You'll need to add the plugin to the `backend`'s router. You can do this by
creating a file called `packages/backend/src/plugins/catalog.ts` with contents
matching
[catalog.ts in the create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts).
Once the `catalog.ts` router setup file is in place, add the router to
`packages/backend/src/index.ts`:
```ts
import catalog from './plugins/catalog';
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const apiRouter = Router();
/** several different routers */
apiRouter.use('/catalog', await catalog(catalogEnv));
```
### Adding Entries to the Catalog
At this point the catalog backend is installed in your backend package, but you
will not have any entities loaded.
To get up and running and try out some templates quickly, you can add some of
our example templates through static configuration. Add the following to the
`catalog.locations` section in your `app-config.yaml`:
```yaml
catalog:
locations:
# Backstage Example Components
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml
```
### Running the Backend
Finally, start up Backstage with the new configuration:
```bash
# Run from the root to start both backend and frontend
yarn dev
# Alternatively, run only the backend from its own package
cd packages/backend
yarn start
```
If you've also set up the frontend plugin, you should be ready to go browse the
catalog at [localhost:3000](http://localhost:3000) now!
@@ -0,0 +1,52 @@
---
id: configuration
title: Software Template Configuration
sidebar_label: Configuration
description: Configuration options for Backstage Software Templates
---
Backstage software templates create source code, so your Backstage application
needs to be set up to allow repository creation.
This is done in your `app-config.yaml` by adding
[Backstage integrations](https://backstage.io/docs/integrations/) for the
appropriate source code repository for your organization.
> Note: Integrations may already be set up as part of your `app-config.yaml`.
The next step is to add
[add templates](http://backstage.io/docs/features/software-templates/adding-templates)
to your Backstage app.
### GitHub
For GitHub, you can configure who can see the new repositories that are created
by specifying `visibility` option. Valid options are `public`, `private` and
`internal`. The `internal` option is for GitHub Enterprise clients, which means
public within the enterprise.
```yaml
scaffolder:
github:
visibility: public # or 'internal' or 'private'
```
### Disabling Docker in Docker situation (Optional)
Software Templates use
[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as a templating
library. By default it will use the
[scaffolder-backend/Cookiecutter](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)
docker image.
If you are running Backstage from a Docker container and you want to avoid
calling a container inside a container, you can set up Cookiecutter in your own
image, this will use the local installation instead.
You can do so by including the following lines in the last step of your
`Dockerfile`:
```Dockerfile
RUN apt-get update && apt-get install -y python3 python3-pip
RUN pip3 install cookiecutter
```
+2 -4
View File
@@ -17,10 +17,8 @@ locations like GitHub or GitLab.
### Getting Started
> Be sure to have covered [Installing in your Backstage App](./installation.md)
> for your separate App or
> [Getting Started with Backstage](../../getting-started) for this repo before
> proceeding.
> Be sure to have covered
> [Getting Started with Backstage](../../getting-started) before proceeding.
The Software Templates are available under `/create`. For local development you
should be able to reach them at `http://localhost:3000/create`.
@@ -1,280 +0,0 @@
---
id: installation
title: Installing in your Backstage App
description: Documentation on How to install Backstage App
---
The scaffolder plugin comes in two packages, `@backstage/plugin-scaffolder` and
`@backstage/plugin-scaffolder-backend`. Each has their own installation steps,
outlined below.
The Scaffolder plugin also depends on the Software Catalog. Instructions for how
to set that up can be found [here](../software-catalog/installation.md).
## Installing @backstage/plugin-scaffolder
> **Note that if you used `npx @backstage/create-app`, the plugin may already be
> present**
The scaffolder frontend plugin should be installed in your `app` package, which
is created as a part of `@backstage/create-app`. To install the package, run:
```bash
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-scaffolder
```
### Adding the Plugin to your `packages/app`
Add the root page that the Scaffolder plugin provides to your app. You can
choose any path for the route, but we recommend the following:
```tsx
import { ScaffolderPage } from '@backstage/plugin-scaffolder';
// Add to the top-level routes, directly within <FlatRoutes>
<Route path="/create" element={<ScaffolderPage />} />;
```
You may also want to add a link to the template index page to your sidebar:
```tsx
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
// Somewhere within the <Sidebar>
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />;
```
This is all that is needed for the frontend part of the Scaffolder plugin to
work!
## Installing @backstage/plugin-scaffolder-backend
> **Note that if you used `npx @backstage/create-app`, the plugin may already be
> present**
The scaffolder backend should be installed in your `backend` package, which is
created as a part of `@backstage/create-app`. To install the package, run:
```bash
# From your Backstage root directory
cd packages/backend
yarn add @backstage/plugin-scaffolder-backend
```
### Adding the Plugin to your `packages/backend`
You'll need to add the plugin to the `backend`'s router. You can do this by
creating a file called `packages/backend/src/plugins/scaffolder.ts` with the
following contents to get you up and running quickly.
```ts
import {
DockerContainerRunner,
SingleHostDiscovery,
} from '@backstage/backend-common';
import {
CookieCutter,
createRouter,
Preparers,
Publishers,
CreateReactAppTemplater,
Templaters,
} from '@backstage/plugin-scaffolder-backend';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
import { CatalogClient } from '@backstage/catalog-client';
export default async function createPlugin({
logger,
config,
database,
reader,
}: PluginEnvironment) {
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
const cookiecutterTemplater = new CookieCutter({ containerRunner });
const craTemplater = new CreateReactAppTemplater({ containerRunner });
const templaters = new Templaters();
templaters.register('cookiecutter', cookiecutterTemplater);
templaters.register('cra', craTemplater);
const preparers = await Preparers.fromConfig(config, { logger });
const publishers = await Publishers.fromConfig(config, { logger });
const discovery = SingleHostDiscovery.fromConfig(config);
const catalogClient = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
preparers,
templaters,
publishers,
logger,
config,
database,
catalogClient,
reader,
});
}
```
Once the `scaffolder.ts` router setup file is in place, add the router to
`packages/backend/src/index.ts`:
```ts
import scaffolder from './plugins/scaffolder';
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const apiRouter = Router();
/* several router .use calls */
/* add this line */
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
```
### Adding Templates
At this point the scaffolder backend is installed in your backend package, but
you will not have any templates available to use. These need to be added to the
software catalog, as they are represented as entities of kind
[Template](../software-catalog/descriptor-format.md#kind-template). You can find
out more about adding templates [here](./adding-templates.md).
To get up and running and try out some templates quickly, you can add some of
our example templates through static configuration. Add the following to the
`catalog.locations` section in your `app-config.yaml`:
```yaml
catalog:
locations:
# Backstage Example Templates
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml
- type: url
target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
```
### Runtime Dependencies / Configuration
For the scaffolder backend plugin to function, you'll need to setup the
integrations config in your `app-config.yaml`.
You can find help for different providers below.
> Note: Some of this configuration may already be set up as part of your
> `app-config.yaml`. We're moving away from the duplicated config for
> authentication in the `scaffolder` section and using `integrations` instead.
#### GitHub
The GitHub access token is retrieved from environment variables via the config.
The config file needs to specify what environment variable the token is
retrieved from. Your config should have the following objects.
You can configure who can see the new repositories that the scaffolder creates
by specifying `visibility` option. Valid options are `public`, `private` and
`internal`. The `internal` option is for GitHub Enterprise clients, which means
public within the enterprise.
```yaml
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
scaffolder:
github:
visibility: public # or 'internal' or 'private'
```
#### GitLab
For GitLab, we currently support the configuration of the GitLab publisher and
allows to configure the private access token and the base URL of a GitLab
instance:
```yaml
integrations:
gitlab:
- host: gitlab.com
token: ${GITLAB_TOKEN}
```
#### Bitbucket
For Bitbucket there are two authentication methods supported. Either `token` or
a combination of `appPassword` and `username`. It looks like either of the
following:
```yaml
integrations:
bitbucket:
- host: bitbucket.org
token: ${BITBUCKET_TOKEN}
```
or
```yaml
integrations:
bitbucket:
- host: bitbucket.org
appPassword: ${BITBUCKET_APP_PASSWORD}
username: ${BITBUCKET_USERNAME}
```
#### Azure DevOps
For Azure DevOps we support both the preparer and publisher stage with the
configuration of a private access token (PAT). For the publisher it's also
required to define the base URL for the client to connect to the service. This
will hopefully support on-prem installations as well but that has not been
verified.
```yaml
integrations:
azure:
- host: dev.azure.com
token: ${AZURE_TOKEN}
```
### Running the Backend
Finally, make sure you have a local Docker daemon running, and start up the
backend with the new configuration:
```bash
cd packages/backend
GITHUB_TOKEN=<token> yarn start
```
If you've also set up the frontend plugin, so you should be ready to go browse
the templates at [localhost:3000/create](http://localhost:3000/create) now!
### Disabling Docker in Docker situation (Optional)
Software Templates use
[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating
library. By default it will use the
[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)
docker image.
If you are running Backstage from a Docker container and you want to avoid
calling a container inside a container, you can set up Cookiecutter in your own
image, this will use the local installation instead.
You can do so by including the following lines in the last step of your
`Dockerfile`:
```Dockerfile
RUN apt-get update && apt-get install -y python3 python3-pip
RUN pip3 install cookiecutter
```
@@ -28,10 +28,10 @@ scratch.
### Use the documentation template
Your working Backstage instance should by default have a documentation template
added. If not, follow these
[instructions](../software-templates/installation.md#adding-templates) to add
the documentation template. The template creates a component with only TechDocs
configuration and default markdown files as below mentioned in manual
added. If not, copy the catalog locations from the
[create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs)
to add the documentation template. The template creates a component with only
TechDocs configuration and default markdown files as below mentioned in manual
documentation setup, and is otherwise empty.
![Documentation Template](../../assets/techdocs/documentation-template.png)
+13
View File
@@ -43,6 +43,10 @@ root of the project which you can then use as an `include` in your
`app-config.yaml`. You can go ahead and
[skip ahead](#including-in-integrations-config) if you've already got an app.
Note that the created app will have a webhook that is disabled by default and
points to `smee.io`, which is intended for local development. There's also
currently no part of Backstage that makes use of the webhook.
### GitHub Enterprise
You have to create the GitHub Application manually using these
@@ -84,3 +88,12 @@ integrations:
apps:
- $include: example-backstage-app-credentials.yaml
```
### Permissions for pull requests
These are the minimum permissions required for creating a pull request with
Backstage software templates:
- Read and Write permissions for `Contents`.
- Read and write permissions for `Pull Requests` and `Issues`.
- Read permissions on `Metadata`.
+2 -2
View File
@@ -33,7 +33,6 @@
"label": "Software Catalog",
"ids": [
"features/software-catalog/software-catalog-overview",
"features/software-catalog/installation",
"features/software-catalog/configuration",
"features/software-catalog/system-model",
"features/software-catalog/descriptor-format",
@@ -62,7 +61,7 @@
"label": "Software Templates",
"ids": [
"features/software-templates/software-templates-index",
"features/software-templates/installation",
"features/software-templates/configuration",
"features/software-templates/adding-templates",
"features/software-templates/writing-templates",
"features/software-templates/builtin-actions",
@@ -206,6 +205,7 @@
},
"auth/add-auth-provider",
"auth/using-auth",
"auth/identity-resolver",
"auth/auth-backend",
"auth/oauth",
"auth/auth-backend-classes",
+2 -2
View File
@@ -30,7 +30,6 @@ nav:
- Core Features:
- Software Catalog:
- Overview: 'features/software-catalog/index.md'
- Installing in your Backstage App: 'features/software-catalog/installation.md'
- Catalog Configuration: 'features/software-catalog/configuration.md'
- System Model: 'features/software-catalog/system-model.md'
- YAML File Format: 'features/software-catalog/descriptor-format.md'
@@ -49,7 +48,7 @@ nav:
- Troubleshooting: 'features/kubernetes/troubleshooting.md'
- Software Templates:
- Overview: 'features/software-templates/index.md'
- Installing in your Backstage App: 'features/software-templates/installation.md'
- Configuration: 'features/software-templates/configuration.md'
- Adding your own Templates: 'features/software-templates/adding-templates.md'
- Writing Templates: 'features/software-templates/writing-templates.md'
- Builtin Actions: 'features/software-templates/builtin-actions.md'
@@ -133,6 +132,7 @@ nav:
- OneLogin: 'auth/onelogin/provider.md'
- Adding authentication providers: 'auth/add-auth-provider.md'
- Using authentication and identity: 'auth/using-auth.md'
- Sign in resolvers: 'auth/identity-resolver.md'
- Auth backend: 'auth/auth-backend.md'
- OAuth and OpenID Connect: 'auth/oauth.md'
- Auth backend classes: 'auth/auth-backend-classes.md'
+2
View File
@@ -8,6 +8,8 @@
"@backstage/cli": "^0.7.1",
"@backstage/core": "^0.7.13",
"@backstage/integration-react": "^0.1.3",
"@backstage/core-app-api": "^0.1.2",
"@backstage/core-components": "^0.1.1",
"@backstage/plugin-api-docs": "^0.5.0",
"@backstage/plugin-badges": "^0.2.2",
"@backstage/plugin-catalog": "^0.6.3",
+4 -3
View File
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { createApp, FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
createApp,
FlatRoutes,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core';
} from '@backstage/core-components';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
@@ -65,6 +64,7 @@ const app = createApp({
// Custom icon example
alert: AlarmIcon,
},
components: {
SignInPage: props => {
return (
@@ -116,6 +116,7 @@ const routes = (
/>
<Route path="/graphiql" element={<GraphiQLPage />} />
<Route path="/lighthouse" element={<LighthousePage />} />
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route path="/gcp-projects" element={<GcpProjectsPage />} />
<Route path="/newrelic" element={<NewRelicPage />} />
+3 -3
View File
@@ -10,7 +10,7 @@ import { JsonValue } from '@backstage/config';
import { SerializedError } from '@backstage/errors';
import * as yup from 'yup';
// @public (undocumented)
// @public @deprecated (undocumented)
export const analyzeLocationSchema: yup.ObjectSchema<{
location: LocationSpec;
}, object>;
@@ -316,7 +316,7 @@ export { LocationEntityV1alpha1 }
// @public (undocumented)
export const locationEntityV1alpha1Validator: KindValidator;
// @public (undocumented)
// @public @deprecated (undocumented)
export const locationSchema: yup.ObjectSchema<Location_2, object>;
// @public (undocumented)
@@ -326,7 +326,7 @@ export type LocationSpec = {
presence?: 'optional' | 'required';
};
// @public (undocumented)
// @public @deprecated (undocumented)
export const locationSpecSchema: yup.ObjectSchema<LocationSpec, object>;
// @public (undocumented)
@@ -17,6 +17,7 @@
import * as yup from 'yup';
import { LocationSpec, Location } from './types';
/** @deprecated */
export const locationSpecSchema = yup
.object<LocationSpec>({
type: yup.string().required(),
@@ -26,6 +27,7 @@ export const locationSpecSchema = yup
.noUnknown()
.required();
/** @deprecated */
export const locationSchema = yup
.object<Location>({
id: yup.string().required(),
@@ -35,6 +37,7 @@ export const locationSchema = yup
.noUnknown()
.required();
/** @deprecated */
export const analyzeLocationSchema = yup
.object<{ location: LocationSpec }>({
location: locationSpecSchema,
@@ -120,6 +120,7 @@ export class GithubCreateAppServer {
redirect_url: `${baseUrl}/callback`,
hook_attributes: {
url: this.webhookUrl,
active: false,
},
};
const manifestJson = JSON.stringify(manifest).replace(/\"/g, '&quot;');
+12
View File
@@ -234,6 +234,18 @@ export type ErrorBoundaryFallbackProps = {
resetError: () => void;
};
// @public (undocumented)
export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element;
// @public (undocumented)
export type FeatureFlaggedProps = {
children: ReactNode;
} & ({
with: string;
} | {
without: string;
});
// @public (undocumented)
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
+15 -3
View File
@@ -57,6 +57,7 @@ import {
} from '../extensions/traversal';
import { pluginCollector } from '../plugins/collectors';
import {
featureFlagCollector,
routeObjectCollector,
routeParentCollector,
routePathCollector,
@@ -215,7 +216,12 @@ export class PrivateAppImpl implements BackstageApp {
[],
);
const { routePaths, routeParents, routeObjects } = useMemo(() => {
const {
routePaths,
routeParents,
routeObjects,
featureFlags,
} = useMemo(() => {
const result = traverseElementTree({
root: children,
discoverers: [childDiscoverer, routeElementDiscoverer],
@@ -224,6 +230,7 @@ export class PrivateAppImpl implements BackstageApp {
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
collectedPlugins: pluginCollector,
featureFlags: featureFlagCollector,
},
});
@@ -238,7 +245,6 @@ export class PrivateAppImpl implements BackstageApp {
// Initialize APIs once all plugins are available
this.getApiHolder();
return result;
}, [children]);
@@ -273,8 +279,14 @@ export class PrivateAppImpl implements BackstageApp {
}
}
}
// Go through the featureFlags returned from the traversal and
// register those now the configApi has been loaded
for (const name of featureFlags) {
featureFlagsApi.registerFlag({ name, pluginId: '' });
}
}
}, [hasConfigApi, loadedConfig]);
}, [hasConfigApi, loadedConfig, featureFlags]);
if ('node' in loadedConfig) {
// Loading or error
+1
View File
@@ -37,6 +37,7 @@ describe('index', () => {
ConfigReader: expect.any(Function),
ErrorAlerter: expect.any(Function),
ErrorApiForwarder: expect.any(Function),
FeatureFlagged: expect.any(Function),
GithubAuth: expect.any(Function),
GitlabAuth: expect.any(Function),
GoogleAuth: expect.any(Function),
@@ -0,0 +1,102 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { FeatureFlagged } from './FeatureFlagged';
import { render } from '@testing-library/react';
import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
describe('FeatureFlagged', () => {
describe('with', () => {
it('should render contents when the feature flag is enabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged with="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
});
it('should not render contents when the feature flag is disabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged with="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
});
});
describe('without', () => {
it('should not render contents when the feature flag is enabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged without="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
});
it('should render contents when the feature flag is disabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged without="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
featureFlagsApiRef,
useApi,
attachComponentData,
} from '@backstage/core-plugin-api';
import React, { ReactNode } from 'react';
export type FeatureFlaggedProps = { children: ReactNode } & (
| { with: string }
| { without: string }
);
export const FeatureFlagged = (props: FeatureFlaggedProps) => {
const { children } = props;
const featureFlagApi = useApi(featureFlagsApiRef);
const isEnabled =
'with' in props
? featureFlagApi.isActive(props.with)
: !featureFlagApi.isActive(props.without);
return <>{isEnabled ? children : null}</>;
};
attachComponentData(FeatureFlagged, 'core.featureFlagged', true);
@@ -17,25 +17,36 @@
import { render, RenderResult } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom';
import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { AppContext } from '../app';
import { AppContextProvider } from '../app/AppContext';
import { FlatRoutes } from './FlatRoutes';
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
function makeRouteRenderer(node: ReactNode) {
let rendered: RenderResult | undefined = undefined;
return (path: string) => {
const content = (
<AppContextProvider
appContext={
({
getComponents: () => ({
NotFoundErrorPage: () => <>Not Found</>,
}),
} as unknown) as AppContext
}
>
<MemoryRouter initialEntries={[path]} children={node} />
</AppContextProvider>
<Wrapper>
<AppContextProvider
appContext={
({
getComponents: () => ({
NotFoundErrorPage: () => <>Not Found</>,
}),
} as unknown) as AppContext
}
>
<MemoryRouter initialEntries={[path]} children={node} />
</AppContextProvider>
</Wrapper>
);
if (rendered) {
rendered.unmount();
@@ -14,53 +14,16 @@
* limitations under the License.
*/
import React, { ReactNode, Children, isValidElement, Fragment } from 'react';
import React, { ReactNode } from 'react';
import { useRoutes } from 'react-router-dom';
import { useApp } from '@backstage/core-plugin-api';
import { useApp, useElementFilter } from '@backstage/core-plugin-api';
type RouteObject = {
path: string;
element: JSX.Element;
element: ReactNode;
children?: RouteObject[];
};
// Similar to the same function from react-router, this collects routes from the
// children, but only the first level of routes
function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] {
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
const { children } = child.props;
if (child.type === Fragment) {
return createRoutesFromChildren(children);
}
let path = child.props.path as string | undefined;
// TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
if (path === '') {
return [];
}
path = path?.replace(/\/\*$/, '') ?? '/';
return [
{
path,
element: child,
children: children && [
{
path: '/*',
element: children,
},
],
},
];
});
}
type FlatRoutesProps = {
children: ReactNode;
};
@@ -68,14 +31,41 @@ type FlatRoutesProps = {
export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
const routes = createRoutesFromChildren(props.children)
// Routes are sorted to work around a bug where prefixes are unexpectedly matched
.sort((a, b) => b.path.localeCompare(a.path))
// We make sure all routes have '/*' appended, except '/'
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
});
const routes = useElementFilter(props.children, elements =>
elements
.getElements<{ path?: string; children: ReactNode }>()
.flatMap<RouteObject>(child => {
let path = child.props.path;
// TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
if (path === '') {
return [];
}
path = path?.replace(/\/\*$/, '') ?? '/';
return [
{
path,
element: child,
children: child.props.children
? [
{
path: '/*',
element: child.props.children,
},
]
: undefined,
},
];
})
// Routes are sorted to work around a bug where prefixes are unexpectedly matched
.sort((a, b) => b.path.localeCompare(a.path))
// We make sure all routes have '/*' appended, except '/'
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
}),
);
// TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop
routes.push({
@@ -19,6 +19,7 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
function getMountPoint(node: ReactElement): RouteRef | undefined {
const element: ReactNode = node.props?.element;
@@ -171,3 +172,13 @@ export const routeObjectCollector = createCollector(
return parentObj;
},
);
export const featureFlagCollector = createCollector(
() => new Set<string>(),
(acc, node) => {
if (node.type === FeatureFlagged) {
const props = node.props as FeatureFlaggedProps;
acc.add('with' in props ? props.with : props.without);
}
},
);
@@ -15,3 +15,5 @@
*/
export { FlatRoutes } from './FlatRoutes';
export { FeatureFlagged } from './FeatureFlagged';
export type { FeatureFlaggedProps } from './FeatureFlagged';
+18
View File
@@ -8,6 +8,7 @@ import { BackstageTheme } from '@backstage/theme';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SvgIconProps } from '@material-ui/core';
@@ -227,6 +228,20 @@ export type DiscoveryApi = {
// @public (undocumented)
export const discoveryApiRef: ApiRef<DiscoveryApi>;
// @public
export interface ElementCollection {
findComponentData<T>(query: {
key: string;
}): T[];
getElements<Props extends {
[name: string]: unknown;
}>(): Array<ReactElement<Props>>;
selectByComponentData(query: {
key: string;
withStrictError?: string;
}): ElementCollection;
}
// @public
export type ErrorApi = {
post(error: Error_2, context?: ErrorContext): void;
@@ -514,6 +529,9 @@ export function useApiHolder(): ApiHolder;
// @public (undocumented)
export const useApp: () => AppContext;
// @public
export function useElementFilter<T>(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T;
// @public (undocumented)
export type UserFlags = {};
+1
View File
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.0",
"@backstage/core-app-api": "^0.1.2",
"@backstage/test-utils": "^0.1.13",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
@@ -20,3 +20,5 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
export { useElementFilter } from './useElementFilter';
export type { ElementCollection } from './useElementFilter';
@@ -0,0 +1,377 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import { useElementFilter } from './useElementFilter';
import { renderHook } from '@testing-library/react-hooks';
import { attachComponentData } from './componentData';
import { featureFlagsApiRef } from '../apis';
import {
ApiProvider,
ApiRegistry,
LocalStorageFeatureFlags,
} from '@backstage/core-app-api';
const WRAPPING_COMPONENT_KEY = 'core.blob.testing';
const INNER_COMPONENT_KEY = 'core.blob2.testing';
const WrappingComponent = (_props: { children: ReactNode }) => null;
attachComponentData(WrappingComponent, WRAPPING_COMPONENT_KEY, {
message: 'hey! im wrapping component data',
});
const InnerComponent = () => null;
attachComponentData(InnerComponent, INNER_COMPONENT_KEY, {
message: 'hey! im the inner component',
});
const MockComponent = (_props: { children: ReactNode }) => null;
const FeatureFlagComponent = (_props: {
children: ReactNode;
with?: string;
without?: string;
}) => null;
attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true);
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
describe('useElementFilter', () => {
it('should select elements based on a component data key', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<WrappingComponent key="third">
<InnerComponent />
</WrappingComponent>
</WrappingComponent>
</MockComponent>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0].key).toBe('.$.$first');
expect(result.current[1].key).toBe('.$.$second');
});
it('should find componentData', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements.findComponentData({ key: WRAPPING_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0]).toEqual({
message: 'hey! im wrapping component data',
});
expect(result.current[1]).toEqual({
message: 'hey! im wrapping component data',
});
});
it('can be combined to together to filter the selection', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.findComponentData({ key: INNER_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0]).toEqual({
message: 'hey! im the inner component',
});
expect(result.current[1]).toEqual({
message: 'hey! im the inner component',
});
});
describe('FeatureFlags', () => {
describe('with', () => {
it('should not discover deeper than the feature gate if the feature flag is disabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const tree = (
<MockComponent>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(1);
expect(result.current[0].key).toContain('second');
});
it('should discover components behind a feature flag if the flag is enabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const tree = (
<MockComponent>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
});
describe('without', () => {
it('should discover deeper than the feature gate if the feature flag is disabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const tree = (
<MockComponent>
<FeatureFlagComponent without="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
it('should not discover components behind a feature flag if the flag is enabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const tree = (
<MockComponent>
<FeatureFlagComponent without="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(1);
});
});
});
it('should reject when strict mode is enabled with the correct string', () => {
const tree = (
<MockComponent>
<h1>Hello</h1>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({
key: WRAPPING_COMPONENT_KEY,
withStrictError: 'Could not find component',
})
.findComponentData({ key: INNER_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.error.message).toEqual('Could not find component');
});
it('should support fragments and text node iteration', () => {
jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true);
const tree = (
<>
<MockComponent>
<>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
</>
<MockComponent>
hello my name
<>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</>
</MockComponent>
is text
<InnerComponent />
</MockComponent>
</>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
});
@@ -0,0 +1,188 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Children,
Fragment,
isValidElement,
ReactNode,
ReactElement,
useMemo,
} from 'react';
import { getComponentData } from './componentData';
import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis';
function selectChildren(
rootNode: ReactNode,
featureFlagsApi: FeatureFlagsApi,
selector?: (element: ReactElement<unknown>) => boolean,
strictError?: string,
): Array<ReactElement<unknown>> {
return Children.toArray(rootNode).flatMap(node => {
if (!isValidElement(node)) {
return [];
}
if (node.type === Fragment) {
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
}
if (getComponentData(node, 'core.featureFlagged')) {
const props = node.props as { with: string } | { without: string };
const isEnabled =
'with' in props
? featureFlagsApi.isActive(props.with)
: !featureFlagsApi.isActive(props.without);
if (isEnabled) {
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
}
return [];
}
if (selector === undefined || selector(node)) {
return [node];
}
if (strictError) {
throw new Error(strictError);
}
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
});
}
/**
* A querying interface tailored to traversing a set of selected React elements
* and extracting data.
*
* Methods prefixed with `selectBy` are used to narrow the set of selected elements.
*
* Methods prefixed with `find` return concrete data using a deep traversal of the set.
*
* Methods prefixed with `get` return concrete data using a shallow traversal of the set.
*/
export interface ElementCollection {
/**
* Narrows the set of selected components by doing a deep traversal and
* only including those that have defined component data for the given `key`.
*
* Whether an element in the tree has component data set for the given key
* is determined by whether `getComponentData` returns undefined.
*
* The traversal does not continue deeper past elements that match the criteria,
* and it also includes the root children in the selection, meaning that if the,
* of all the currently selected elements contain data for the given key, this
* method is a no-op.
*
* If `withStrictError` is set, the resulting selection must be a full match, meaning
* there may be no elements that were excluded in the selection. If the selection
* is not a clean match, an error will be throw with `withStrictError` as the message.
*/
selectByComponentData(query: {
key: string;
withStrictError?: string;
}): ElementCollection;
/**
* Finds all elements using the same criteria as `selectByComponentData`, but
* returns the actual component data of each of those elements instead.
*/
findComponentData<T>(query: { key: string }): T[];
/**
* Returns all of the elements currently selected by this collection.
*/
getElements<Props extends { [name: string]: unknown }>(): Array<
ReactElement<Props>
>;
}
class Collection implements ElementCollection {
constructor(
private readonly node: ReactNode,
private readonly featureFlagsApi: FeatureFlagsApi,
) {}
selectByComponentData(query: { key: string; withStrictError?: string }) {
const selection = selectChildren(
this.node,
this.featureFlagsApi,
node => getComponentData(node, query.key) !== undefined,
query.withStrictError,
);
return new Collection(selection, this.featureFlagsApi);
}
findComponentData<T>(query: { key: string }): T[] {
const selection = selectChildren(
this.node,
this.featureFlagsApi,
node => getComponentData(node, query.key) !== undefined,
);
return selection
.map(node => getComponentData<T>(node, query.key))
.filter((data: T | undefined): data is T => data !== undefined);
}
getElements<Props extends { [name: string]: unknown }>(): Array<
ReactElement<Props>
> {
return selectChildren(this.node, this.featureFlagsApi) as Array<
ReactElement<Props>
>;
}
}
/**
* useElementFilter is a utility that helps you narrow down and retrieve data
* from a React element tree, typically operating on the `children` property
* passed in to a component. A common use-case is to construct declarative APIs
* where a React component defines its behavior based on its children, such as
* the relationship between `Routes` and `Route` in `react-router`.
*
* The purpose of this hook is similar to `React.Children.map`, and it expands upon
* it to also handle traversal of fragments and Backstage specific things like the
* `FeatureFlagged` component.
*
* The return value of the hook is computed by the provided filter function, but
* with added memoization based on the input `node`. If further memoization
* dependencies are used in the filter function, they should be added to the
* third `dependencies` argument, just like `useMemo`, `useEffect`, etc.
*/
export function useElementFilter<T>(
node: ReactNode,
filterFn: (arg: ElementCollection) => T,
dependencies: any[] = [],
) {
const featureFlagsApi = useApi(featureFlagsApiRef);
const elements = new Collection(node, featureFlagsApi);
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => filterFn(elements), [node, ...dependencies]);
}
@@ -40,6 +40,7 @@ describe('index', () => {
useApi: expect.any(Function),
useApiHolder: expect.any(Function),
useApp: expect.any(Function),
useElementFilter: expect.any(Function),
useRouteRef: expect.any(Function),
useRouteRefParams: expect.any(Function),
withApis: expect.any(Function),
+7
View File
@@ -1,5 +1,12 @@
# @backstage/create-app
## 0.3.27
### Patch Changes
- Updated dependencies
- @backstage/plugin-scaffolder-backend@0.12.2
## 0.3.26
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/create-app",
"description": "Create app package for Backstage",
"version": "0.3.26",
"version": "0.3.27",
"private": false,
"publishConfig": {
"access": "public"
+115
View File
@@ -0,0 +1,115 @@
## API Report File for "@backstage/plugin-api-docs"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiEntity } from '@backstage/catalog-model';
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { CatalogTableRow } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { ExternalRouteRef } from '@backstage/core';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core';
import { TableColumn } from '@backstage/core';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// @public (undocumented)
export const ApiDefinitionCard: (_: Props) => JSX.Element;
// @public (undocumented)
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React_2.ReactElement;
rawLanguage?: string;
};
// @public (undocumented)
export const apiDocsConfigRef: ApiRef<ApiDocsConfig>;
// @public (undocumented)
const apiDocsPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {
createComponent: ExternalRouteRef<undefined, true>;
}>;
export { apiDocsPlugin }
export { apiDocsPlugin as plugin }
// @public (undocumented)
export const ApiExplorerPage: ({ initiallySelectedFilter, columns, }: ApiExplorerPageProps) => JSX.Element;
// @public (undocumented)
export const ApiTypeTitle: ({ apiEntity }: {
apiEntity: ApiEntity;
}) => JSX.Element;
// @public (undocumented)
export const AsyncApiDefinitionWidget: ({ definition }: Props_5) => JSX.Element;
// @public (undocumented)
export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element;
// @public (undocumented)
export const ConsumingComponentsCard: ({ variant }: Props_6) => JSX.Element;
// @public (undocumented)
export function defaultDefinitionWidgets(): ApiDefinitionWidget[];
// @public (undocumented)
export const EntityApiDefinitionCard: (_: {
apiEntity?: ApiEntity | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityConsumedApisCard: ({ variant }: {
entity?: Entity| undefined;
variant?: "gridItem" | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityConsumingComponentsCard: ({ variant }: {
entity?: Entity| undefined;
variant?: "gridItem" | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityHasApisCard: ({ variant }: {
variant?: "gridItem" | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityProvidedApisCard: ({ variant }: {
entity?: Entity| undefined;
variant?: "gridItem" | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityProvidingComponentsCard: ({ variant }: {
entity?: Entity| undefined;
variant?: "gridItem" | undefined;
}) => JSX.Element;
// @public (undocumented)
export const HasApisCard: ({ variant }: Props_3) => JSX.Element;
// @public (undocumented)
export const OpenApiDefinitionWidget: ({ definition }: Props_8) => JSX.Element;
// @public (undocumented)
export const PlainApiDefinitionWidget: ({ definition, language }: Props_9) => JSX.Element;
// @public (undocumented)
export const ProvidedApisCard: ({ variant }: Props_4) => JSX.Element;
// @public (undocumented)
export const ProvidingComponentsCard: ({ variant }: Props_7) => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+28
View File
@@ -0,0 +1,28 @@
## API Report File for "@backstage/plugin-app-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
appPackageName: string;
// (undocumented)
config: Config;
disableConfigInjection?: boolean;
// (undocumented)
logger: Logger;
staticFallbackHandler?: express.Handler;
}
// (No @packageDocumentation comment for this package)
```
+211
View File
@@ -0,0 +1,211 @@
## API Report File for "@backstage/plugin-auth-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import express from 'express';
import { JSONWebKey } from 'jose';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Profile } from 'passport';
// @public (undocumented)
export type AuthProviderFactory = (options: AuthProviderFactoryOptions) => AuthProviderRouteHandlers;
// @public (undocumented)
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
// @public
export interface AuthProviderRouteHandlers {
frameHandler(req: express.Request, res: express.Response): Promise<void>;
logout?(req: express.Request, res: express.Response): Promise<void>;
refresh?(req: express.Request, res: express.Response): Promise<void>;
start(req: express.Request, res: express.Response): Promise<void>;
}
// @public (undocumented)
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentity;
};
// @public (undocumented)
export type BackstageIdentity = {
id: string;
idToken?: string;
};
// @public (undocumented)
export function createRouter({ logger, config, discovery, database, providerFactories, }: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export const defaultAuthProviderFactories: {
[providerId: string]: AuthProviderFactory;
};
// @public (undocumented)
export const encodeState: (state: OAuthState) => string;
// @public (undocumented)
export const ensuresXRequestedWith: (req: express.Request) => boolean;
// @public
export class IdentityClient {
constructor(options: {
discovery: PluginEndpointDiscovery;
issuer: string;
});
authenticate(token: string | undefined): Promise<BackstageIdentity>;
static getBearerToken(authorizationHeader: string | undefined): string | undefined;
listPublicKeys(): Promise<{
keys: JSONWebKey[];
}>;
}
// @public (undocumented)
export class OAuthAdapter implements AuthProviderRouteHandlers {
constructor(handlers: OAuthHandlers, options: Options);
// (undocumented)
frameHandler(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
static fromConfig(config: AuthProviderConfig, handlers: OAuthHandlers, options: Pick<Options, 'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'>): OAuthAdapter;
// (undocumented)
logout(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
refresh(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
start(req: express.Request, res: express.Response): Promise<void>;
}
// @public (undocumented)
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
constructor(handlers: Map<string, AuthProviderRouteHandlers>);
// (undocumented)
frameHandler(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
logout(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
static mapConfig(config: Config, factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers): OAuthEnvironmentHandler;
// (undocumented)
refresh(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
start(req: express.Request, res: express.Response): Promise<void>;
}
// @public
export interface OAuthHandlers {
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
logout?(): Promise<void>;
refresh?(req: OAuthRefreshRequest): Promise<AuthResponse<OAuthProviderInfo>>;
start(req: OAuthStartRequest): Promise<RedirectInfo>;
}
// @public (undocumented)
export type OAuthProviderInfo = {
accessToken: string;
idToken?: string;
expiresInSeconds?: number;
scope: string;
refreshToken?: string;
};
// @public
export type OAuthProviderOptions = {
clientId: string;
clientSecret: string;
callbackUrl: string;
};
// @public (undocumented)
export type OAuthRefreshRequest = express.Request<{}> & {
scope: string;
refreshToken: string;
};
// @public (undocumented)
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
// @public (undocumented)
export type OAuthResult = {
fullProfile: Profile;
params: {
id_token?: string;
scope: string;
expires_in: number;
};
accessToken: string;
refreshToken?: string;
};
// @public (undocumented)
export type OAuthStartRequest = express.Request<{}> & {
scope: string;
state: OAuthState;
};
// @public (undocumented)
export type OAuthState = {
nonce: string;
env: string;
};
// @public (undocumented)
export const postMessageResponse: (res: express.Response, appOrigin: string, response: WebMessageResponse) => void;
// @public
export type ProfileInfo = {
email?: string;
displayName?: string;
picture?: string;
};
// @public (undocumented)
export const readState: (stateString: string) => OAuthState;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
// (undocumented)
providerFactories?: ProviderFactories;
}
// @public (undocumented)
export const verifyNonce: (req: express.Request, providerId: string) => void;
// @public
export type WebMessageResponse = {
type: 'authorization_response';
response: AuthResponse<unknown>;
} | {
type: 'authorization_response';
error: Error;
};
// (No @packageDocumentation comment for this package)
```
@@ -67,13 +67,14 @@ export class TokenFactory implements TokenIssuer {
const iss = this.issuer;
const sub = params.claims.sub;
const ent = params.claims.ent;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub}`);
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
@@ -28,6 +28,8 @@ export type TokenParams = {
claims: {
/** The token subject, i.e. User ID */
sub: string;
/** A list of entity references that the user claims ownership through */
ent?: string[];
};
};
@@ -16,6 +16,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
describe('CatalogIdentityClient', () => {
@@ -29,25 +30,36 @@ describe('CatalogIdentityClient', () => {
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
const tokenIssuer: jest.Mocked<TokenIssuer> = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
catalogApi: catalogApi as CatalogApi,
catalogApi: catalogApi,
tokenIssuer: tokenIssuer,
});
client.findUser({ annotations: { key: 'value' } });
await client.findUser({ annotations: { key: 'value' } });
expect(catalogApi.getEntities).toBeCalledWith(
expect(catalogApi.getEntities).toHaveBeenCalledWith(
{
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
},
},
undefined,
{ token: 'my-token' },
);
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
claims: {
sub: 'backstage.io/auth-backend',
},
});
});
});
@@ -17,6 +17,7 @@
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
@@ -27,9 +28,11 @@ type UserQuery = {
*/
export class CatalogIdentityClient {
private readonly catalogApi: CatalogApi;
private readonly tokenIssuer: TokenIssuer;
constructor(options: { catalogApi: CatalogApi }) {
constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }) {
this.catalogApi = options.catalogApi;
this.tokenIssuer = options.tokenIssuer;
}
/**
@@ -37,10 +40,7 @@ export class CatalogIdentityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
*/
async findUser(
query: UserQuery,
options?: { token?: string },
): Promise<UserEntity> {
async findUser(query: UserQuery): Promise<UserEntity> {
const filter: Record<string, string> = {
kind: 'user',
};
@@ -48,7 +48,11 @@ export class CatalogIdentityClient {
filter[`metadata.annotations.${key}`] = value;
}
const { items } = await this.catalogApi.getEntities({ filter }, options);
// TODO(Rugvip): cache the token
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const { items } = await this.catalogApi.getEntities({ filter }, { token });
if (items.length !== 1) {
if (items.length > 1) {
@@ -0,0 +1,40 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenParams } from '../../identity';
export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
const userRef = stringifyEntityRef(entity);
const membershipRefs =
entity.relations
?.filter(
r =>
r.type === RELATION_MEMBER_OF &&
r.target.kind.toLocaleLowerCase('en-US') === 'group',
)
.map(r => stringifyEntityRef(r.target)) ?? [];
return {
sub: userRef,
ent: [userRef, ...membershipRefs],
};
}
@@ -15,3 +15,4 @@
*/
export { CatalogIdentityClient } from './CatalogIdentityClient';
export { getEntityClaims } from './helpers';
@@ -14,5 +14,9 @@
* limitations under the License.
*/
export { createGoogleProvider } from './provider';
export {
createGoogleProvider,
googleDefaultSignInResolver,
googleEmailSignInResolver,
} from './provider';
export type { GoogleProviderOptions } from './provider';
@@ -0,0 +1,91 @@
/*
* 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 { GoogleAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createGoogleProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GoogleAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://google.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [{ value: 'conrad@example.com' }],
displayName: 'Conrad',
id: 'conrad',
provider: 'google',
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
});
});
});
@@ -17,8 +17,8 @@
import express from 'express';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Logger } from 'winston';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import {
encodeState,
OAuthAdapter,
@@ -27,8 +27,8 @@ import {
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -38,30 +38,40 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { AuthProviderFactory, RedirectInfo } from '../types';
import { TokenIssuer } from '../../identity/types';
import {
AuthProviderFactory,
AuthHandler,
RedirectInfo,
SignInResolver,
} from '../types';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
logger: Logger;
identityClient: CatalogIdentityClient;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
private readonly logger: Logger;
private readonly identityClient: CatalogIdentityClient;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Options) {
this.logger = options.logger;
this.identityClient = options.identityClient;
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
// TODO: throw error if env variables not set?
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new GoogleStrategy(
{
clientID: options.clientId,
@@ -111,18 +121,8 @@ export class GoogleAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -133,89 +133,170 @@ export class GoogleAuthProvider implements OAuthHandlers {
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
try {
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const user = await this.identityClient.findUser(
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
annotations: {
'google.com/email': profile.email,
},
result,
profile,
},
{ token },
);
return {
...response,
backstageIdentity: {
id: user.metadata.name,
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
};
} catch (error) {
this.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
return {
...response,
backstageIdentity: { id: profile.email.split('@')[0] },
};
}
return response;
}
}
export type GoogleProviderOptions = {};
export const googleEmailSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
const claims = getEntityClaims(entity);
const token = await ctx.tokenIssuer.issueToken({ claims });
return { id: entity.metadata.name, entity, token };
};
export const googleDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
let userId: string;
try {
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
userId = entity.metadata.name;
} catch (error) {
ctx.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
userId = profile.email.split('@')[0];
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type GoogleProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGoogleProvider = (
_options?: GoogleProviderOptions,
options?: GoogleProviderOptions,
): AuthProviderFactory => {
return ({
providerId,
globalConfig,
config,
logger,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? googleDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
logger,
signInResolver,
authHandler,
tokenIssuer,
identityClient: new CatalogIdentityClient({ catalogApi }),
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './google';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
+55 -2
View File
@@ -16,10 +16,12 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity/types';
import { CatalogIdentityClient } from '../lib/catalog';
export type AuthProviderConfig = {
/**
@@ -148,14 +150,30 @@ export type AuthResponse<ProviderInfo> = {
export type BackstageIdentity = {
/**
* The backstage user ID.
* An opaque ID that uniquely identifies the user within Backstage.
*
* This is typically the same as the user entity `metadata.name`.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
* This is deprecated, use `token` instead.
* @deprecated
*/
idToken?: string;
/**
* The token used to authenticate the user within Backstage.
*/
token?: string;
/**
* The entity that the user is represented by within Backstage.
*
* This entity may or may not exist within the Catalog, and it can be used
* to read and store additional metadata about the user.
*/
entity?: Entity;
};
/**
@@ -179,3 +197,38 @@ export type ProfileInfo = {
*/
picture?: string;
};
export type SignInInfo<AuthResult> = {
/**
* The simple profile passed down for use in the frontend.
*/
profile: ProfileInfo;
/**
* The authentication result that was received from the authentication provider.
*/
result: AuthResult;
};
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
},
) => Promise<BackstageIdentity>;
export type AuthHandlerResult = { profile: ProfileInfo };
/**
* The AuthHandler function is called every time the user authenticates using the provider.
*
* The handler should return a profile that represents the session for the user in the frontend.
*
* Throwing an error in the function will cause the authentication to fail, making it
* possible to use this function as a way to limit access to a certain group of users.
*/
export type AuthHandler<AuthResult> = (
input: AuthResult,
) => Promise<AuthHandlerResult>;
+113
View File
@@ -0,0 +1,113 @@
## API Report File for "@backstage/plugin-badges-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
// @public (undocumented)
export interface Badge {
color?: string;
description?: string;
kind?: 'entity';
label: string;
labelColor?: string;
link?: string;
message: string;
style?: BadgeStyle;
}
// @public (undocumented)
export const BADGE_STYLES: readonly ["plastic", "flat", "flat-square", "for-the-badge", "social"];
// @public (undocumented)
export type BadgeBuilder = {
getBadges(): Promise<BadgeInfo[]>;
createBadgeJson(options: BadgeOptions): Promise<BadgeSpec>;
createBadgeSvg(options: BadgeOptions): Promise<string>;
};
// @public (undocumented)
export interface BadgeContext {
// (undocumented)
badgeUrl: string;
// (undocumented)
config: Config;
// (undocumented)
entity?: Entity;
}
// @public (undocumented)
export interface BadgeFactories {
// (undocumented)
[id: string]: BadgeFactory;
}
// @public (undocumented)
export interface BadgeFactory {
// (undocumented)
createBadge(context: BadgeContext): Badge;
}
// @public (undocumented)
export type BadgeInfo = {
id: string;
};
// @public (undocumented)
export type BadgeOptions = {
badgeInfo: BadgeInfo;
context: BadgeContext;
};
// @public (undocumented)
export type BadgeSpec = {
id: string;
badge: Badge;
url: string;
markdown: string;
};
// @public (undocumented)
export type BadgeStyle = typeof BADGE_STYLES[number];
// @public (undocumented)
export const createDefaultBadgeFactories: () => BadgeFactories;
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export class DefaultBadgeBuilder implements BadgeBuilder {
constructor(factories: BadgeFactories);
// (undocumented)
createBadgeJson(options: BadgeOptions): Promise<BadgeSpec>;
// (undocumented)
createBadgeSvg(options: BadgeOptions): Promise<string>;
// (undocumented)
getBadges(): Promise<BadgeInfo[]>;
}
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
badgeBuilder?: BadgeBuilder;
// (undocumented)
badgeFactories?: BadgeFactories;
// (undocumented)
catalog?: CatalogApi;
// (undocumented)
config: Config;
// (undocumented)
discovery: PluginEndpointDiscovery;
}
// (No @packageDocumentation comment for this package)
```
+21
View File
@@ -0,0 +1,21 @@
## API Report File for "@backstage/plugin-badges"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core';
// @public (undocumented)
export const badgesPlugin: BackstagePlugin<{}, {}>;
// @public (undocumented)
export const EntityBadgesDialog: ({ open, onClose }: {
open: boolean;
onClose?: (() => any) | undefined;
}) => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+22
View File
@@ -0,0 +1,22 @@
## API Report File for "@backstage/plugin-bitrise"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
// @public (undocumented)
export const bitrisePlugin: BackstagePlugin<{}, {}>;
// @public (undocumented)
export const EntityBitriseContent: () => JSX.Element;
// @public (undocumented)
export const isBitriseAvailable: (entity: Entity) => boolean;
// (No @packageDocumentation comment for this package)
```
+74 -14
View File
@@ -1,21 +1,80 @@
# Catalog Backend
This is the backend part of the default catalog plugin.
This is the backend for the default Backstage [software
catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview).
This provides an API for consumers such as the frontend [catalog
plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog).
It comes with a builtin database backed implementation of the catalog, that can store
and serve your catalog for you.
It comes with a builtin database-backed implementation of the catalog that can
store and serve your catalog for you.
It can also act as a bridge to your existing catalog solutions, either ingesting their
data to store in the database, or by effectively proxying calls to an external catalog
service.
It can also act as a bridge to your existing catalog solutions, either ingesting
data to store in the database, or by effectively proxying calls to an
external catalog service.
## Getting Started
## Installation
This backend plugin can be started in a standalone mode from directly in this package
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the catalog backend plugin itself.
This `@backstage/plugin-catalog-backend` package comes installed by default in
any Backstage application created with `npx @backstage/create-app`, so
installation is not usually required.
To evaluate the catalog and have a greater amount of functionality available, instead do
To check if you already have the package, look under
`packages/backend/package.json`, in the `dependencies` block, for
`@backstage/plugin-catalog-backend`. The instructions below walk through
restoring the plugin, if you previously removed it.
### Install the package
```bash
# From your Backstage root directory
cd packages/backend
yarn add @backstage/plugin-catalog-backend
```
### Adding the plugin to your `packages/backend`
You'll need to add the plugin to the router in your `backend` package. You can
do this by creating a file called `packages/backend/src/plugins/catalog.ts` with
contents matching [catalog.ts in the create-app
template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts).
With the `catalog.ts` router setup in place, add the router to
`packages/backend/src/index.ts`:
```diff
+import catalog from './plugins/catalog';
async function main() {
...
const createEnv = makeCreateEnv(config);
+ const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const apiRouter = Router();
+ apiRouter.use('/catalog', await catalog(catalogEnv));
...
apiRouter.use(notFoundHandler());
```
### Adding catalog entities
At this point the `catalog-backend` is installed in your backend package, but
you will not have any catalog entities loaded. See [Catalog
Configuration](https://backstage.io/docs/features/software-catalog/configuration)
for how to add locations, or copy the catalog locations from the [create-app
template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs)
to get up and running quickly.
## Development
This backend plugin can be started in a standalone mode from directly in this
package with `yarn start`. However, it will have limited functionality and that
process is most convenient when developing the catalog backend plugin itself.
To evaluate the catalog and have a greater amount of functionality available,
run the entire Backstage example application from the root folder:
```bash
# in one terminal window, run this from from the very root of the Backstage project
@@ -23,9 +82,10 @@ cd packages/backend
yarn start
```
This will launch the full example backend, populated some example entities.
This will launch both frontend and backend in the same window, populated with
some example entities.
## Links
- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog)
- [The Backstage homepage](https://backstage.io)
- [catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
is the frontend interface for this plugin.
+25
View File
@@ -0,0 +1,25 @@
## API Report File for "@backstage/plugin-catalog-graphql"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import { GraphQLModule } from '@graphql-modules/core';
import { Logger } from 'winston';
// @public (undocumented)
export function createModule(options: ModuleOptions): Promise<GraphQLModule>;
// @public (undocumented)
export interface ModuleOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
}
// (No @packageDocumentation comment for this package)
```
+130
View File
@@ -0,0 +1,130 @@
## API Report File for "@backstage/plugin-catalog-import"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { CatalogApi } from '@backstage/catalog-client';
import { ConfigApi } from '@backstage/core';
import { Control } from 'react-hook-form';
import { DiscoveryApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { FieldErrors } from 'react-hook-form';
import { IdentityApi } from '@backstage/core';
import { InfoCardVariants } from '@backstage/core';
import { OAuthApi } from '@backstage/core';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { SubmitHandler } from 'react-hook-form';
import { TextFieldProps } from '@material-ui/core/TextField/TextField';
import { UnpackNestedValue } from 'react-hook-form';
import { UseControllerOptions } from 'react-hook-form';
import { UseFormMethods } from 'react-hook-form';
import { UseFormOptions } from 'react-hook-form';
// @public (undocumented)
export type AnalyzeResult = {
type: 'locations';
locations: Array<{
target: string;
entities: EntityName[];
}>;
} | {
type: 'repository';
url: string;
integrationType: string;
generatedEntities: PartialEntity[];
};
// @public (undocumented)
export const AutocompleteTextField: <TFieldValue extends string>({ name, options, required, control, errors, rules, loading, loadingText, helperText, errorHelperText, textFieldProps, }: Props_4<TFieldValue>) => JSX.Element;
// @public (undocumented)
export interface CatalogImportApi {
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
title: string;
body: string;
}): Promise<{
link: string;
location: string;
}>;
}
// @public (undocumented)
export const catalogImportApiRef: ApiRef<CatalogImportApi>;
// @public (undocumented)
export class CatalogImportClient implements CatalogImportApi {
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
identityApi: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
catalogApi: CatalogApi;
});
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
submitPullRequest({ repositoryUrl, fileContent, title, body, }: {
repositoryUrl: string;
fileContent: string;
title: string;
body: string;
}): Promise<{
link: string;
location: string;
}>;
}
// @public (undocumented)
export const CatalogImportPage: (opts: StepperProviderOpts) => JSX.Element;
// @public (undocumented)
const catalogImportPlugin: BackstagePlugin<{
importPage: RouteRef<undefined>;
}, {}>;
export { catalogImportPlugin }
export { catalogImportPlugin as plugin }
// @public
export function defaultGenerateStepper(flow: ImportFlows, defaults: StepperProvider): StepperProvider;
// @public (undocumented)
export const EntityListComponent: ({ locations, collapsed, locationListItemIcon, onItemClick, firstListItem, withLinks, }: Props_2) => JSX.Element;
// @public (undocumented)
export const ImportStepper: ({ initialUrl, generateStepper, variant, opts, }: Props) => JSX.Element;
// @public
export const PreparePullRequestForm: <TFieldValues extends Record<string, any>>({ defaultValues, onSubmit, render, }: Props_5<TFieldValues>) => JSX.Element;
// @public (undocumented)
export const PreviewCatalogInfoComponent: ({ repositoryUrl, entities, classes, }: Props_6) => JSX.Element;
// @public (undocumented)
export const PreviewPullRequestComponent: ({ title, description, classes, }: Props_7) => JSX.Element;
// @public (undocumented)
export const Router: (opts: StepperProviderOpts) => JSX.Element;
// @public
export const StepInitAnalyzeUrl: ({ onAnalysis, analysisUrl, disablePullRequest, }: Props_3) => JSX.Element;
// @public (undocumented)
export const StepPrepareCreatePullRequest: ({ analyzeResult, onPrepare, onGoBack, renderFormFields, defaultTitle, defaultBody, }: Props_8) => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+93 -12
View File
@@ -1,26 +1,107 @@
# Backstage Catalog Frontend
This is the frontend part of the default catalog plugin.
This is the React frontend for the default Backstage [software
catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview).
This package supplies interfaces related to listing catalog entities or showing
more information about them on entity pages.
It will implement the core API for handling your catalog of software, and
supply the base views to show and manage them.
## Installation
## Getting Started
This `@backstage/plugin-catalog` package comes installed by default in any
Backstage application created with `npx @backstage/create-app`, so installation
is not usually required.
This frontend plugin can be started in a standalone mode from directly in this package
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the catalog frontend plugin itself.
To check if you already have the package, look under
`packages/app/package.json`, in the `dependencies` block, for
`@backstage/plugin-catalog`. The instructions below walk through restoring the
plugin, if you previously removed it.
To evaluate the catalog and have a greater amount of functionality available, from the main
Backstage root folder, instead do:
### Install the package
```bash
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-catalog
```
### Add the plugin to your `packages/app`
Add the two pages that the catalog plugin provides to your app. You can choose
any name for these routes, but we recommend the following:
```diff
// packages/app/src/App.tsx
import {
CatalogIndexPage,
CatalogEntityPage,
} from '@backstage/plugin-catalog';
import { entityPage } from './components/catalog/EntityPage';
<FlatRoutes>
+ <Route path="/catalog" element={<CatalogIndexPage />} />
+ <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />}>
+ {/*
+ This is the root of the custom entity pages for your app, refer to the example app
+ in the main repo or the output of @backstage/create-app for an example
+ */}
+ {entityPage}
+ </Route>
...
</FlatRoutes>
```
The catalog plugin also has one external route that needs to be bound for it to
function: the `createComponent` route which should link to the page where the
user can create components. In a typical setup the create component route will
be linked to the scaffolder plugin's template index page:
```diff
// packages/app/src/App.tsx
+import { catalogPlugin } from '@backstage/plugin-catalog';
+import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
const app = createApp({
// ...
bindRoutes({ bind }) {
+ bind(catalogPlugin.externalRoutes, {
+ createComponent: scaffolderPlugin.routes.root,
+ });
},
});
```
You may also want to add a link to the catalog index page to your application
sidebar:
```diff
// packages/app/src/components/Root/Root.tsx
+import HomeIcon from '@material-ui/icons/Home';
export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
+ <SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
...
</Sidebar>
```
## Development
This frontend plugin can be started in a standalone mode from directly in this
package with `yarn start`. However, it will have limited functionality and that
process is most convenient when developing the catalog frontend plugin itself.
To evaluate the catalog and have a greater amount of functionality available,
run the entire Backstage example application from the root folder:
```bash
yarn dev
```
This will launch both frontend and backend in the same window, populated with some example entities.
This will launch both frontend and backend in the same window, populated with
some example entities.
## Links
- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
- [The Backstage homepage](https://backstage.io)
- [catalog-backend](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
provides the backend API for this frontend.
+2
View File
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.13",
"@backstage/catalog-model": "^0.8.3",
"@backstage/core": "^0.7.13",
"@backstage/core-plugin-api": "^0.1.2",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.6",
"@backstage/integration-react": "^0.1.3",
@@ -54,6 +55,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.1",
"@backstage/core-app-api": "^0.1.2",
"@backstage/dev-utils": "^0.1.17",
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
@@ -29,6 +29,7 @@ import {
Progress,
RoutedTabs,
} from '@backstage/core';
import { useElementFilter } from '@backstage/core-plugin-api';
import {
EntityContext,
EntityRefLinks,
@@ -37,14 +38,7 @@ import {
} from '@backstage/plugin-catalog-react';
import { Box, TabProps } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import {
Children,
default as React,
Fragment,
isValidElement,
useContext,
useState,
} from 'react';
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
@@ -58,46 +52,14 @@ type SubRoute = {
tabProps?: TabProps<React.ElementType, { component?: React.ElementType }>;
};
const dataKey = 'plugin.catalog.entityLayoutRoute';
const Route: (props: SubRoute) => null = () => null;
attachComponentData(Route, dataKey, true);
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
function createSubRoutesFromChildren(
childrenProps: React.ReactNode,
entity: Entity | undefined,
): SubRoute[] {
// Directly comparing child.type with Route will not work with in
// combination with react-hot-loader in storybook
// https://github.com/gaearon/react-hot-loader/issues/304
const routeType = (
<Route path="" title="">
<div />
</Route>
).type;
return Children.toArray(childrenProps).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children, entity);
}
if (child.type !== routeType) {
throw new Error('Child of EntityLayout must be an EntityLayout.Route');
}
const { path, title, children, if: condition, tabProps } = child.props;
if (condition && entity && !condition(entity)) {
return [];
}
return [{ path, title, children, tabProps }];
});
}
const EntityLayoutTitle = ({
entity,
title,
@@ -195,7 +157,29 @@ export const EntityLayout = ({
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
const routes = createSubRoutesFromChildren(children, entity);
const routes = useElementFilter(children, elements =>
elements
.selectByComponentData({
key: dataKey,
withStrictError: 'Child of EntityLayout must be an EntityLayout.Route',
})
.getElements<SubRoute>() // all nodes, element data, maintain structure or not?
.flatMap(({ props }) => {
if (props.if && entity && !props.if(entity)) {
return [];
}
return [
{
path: props.path,
title: props.title,
children: props.children,
tabProps: props.tabProps,
},
];
}),
);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
@@ -20,6 +20,19 @@ import { render } from '@testing-library/react';
import React from 'react';
import { isKind } from './conditions';
import { EntitySwitch } from './EntitySwitch';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import {
LocalStorageFeatureFlags,
ApiProvider,
ApiRegistry,
} from '@backstage/core-app-api';
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
describe('EntitySwitch', () => {
it('should switch child when entity switches', () => {
@@ -32,15 +45,17 @@ describe('EntitySwitch', () => {
);
const rendered = render(
<EntityContext.Provider
value={{
entity: { kind: 'component' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>,
<Wrapper>
<EntityContext.Provider
value={{
entity: { kind: 'component' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>
</Wrapper>,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
@@ -48,15 +63,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
<EntityContext.Provider
value={{
entity: { kind: 'template' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>,
<Wrapper>
<EntityContext.Provider
value={{
entity: { kind: 'template' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>
</Wrapper>,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -64,15 +81,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
<EntityContext.Provider
value={{
entity: { kind: 'derp' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>,
<Wrapper>
<EntityContext.Provider
value={{
entity: { kind: 'derp' } as Entity,
loading: false,
error: undefined,
}}
>
{content}
</EntityContext.Provider>
</Wrapper>,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -88,24 +107,28 @@ describe('EntitySwitch', () => {
};
const rendered = render(
<EntityContext.Provider value={entityContextValue}>
<EntitySwitch>
<EntitySwitch.Case if={isKind('component')} children="A" />
<EntitySwitch.Case children="B" />
</EntitySwitch>
</EntityContext.Provider>,
<Wrapper>
<EntityContext.Provider value={entityContextValue}>
<EntitySwitch>
<EntitySwitch.Case if={isKind('component')} children="A" />
<EntitySwitch.Case children="B" />
</EntitySwitch>
</EntityContext.Provider>
</Wrapper>,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
expect(rendered.queryByText('B')).not.toBeInTheDocument();
rendered.rerender(
<EntityContext.Provider value={entityContextValue}>
<EntitySwitch>
<EntitySwitch.Case if={isKind('template')} children="A" />
<EntitySwitch.Case children="B" />
</EntitySwitch>
</EntityContext.Provider>,
<Wrapper>
<EntityContext.Provider value={entityContextValue}>
<EntitySwitch>
<EntitySwitch.Case if={isKind('template')} children="A" />
<EntitySwitch.Case children="B" />
</EntitySwitch>
</EntityContext.Provider>
</Wrapper>,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -16,49 +16,40 @@
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { PropsWithChildren, ReactNode } from 'react';
import {
Children,
Fragment,
isValidElement,
PropsWithChildren,
ReactNode,
useMemo,
} from 'react';
attachComponentData,
useElementFilter,
} from '@backstage/core-plugin-api';
const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch';
const EntitySwitchCase = (_: {
if?: (entity: Entity) => boolean;
children: ReactNode;
}) => null;
attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true);
type SwitchCase = {
if?: (entity: Entity) => boolean;
children: JSX.Element;
};
function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] {
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSwitchCasesFromChildren(child.props.children);
}
if (child.type !== EntitySwitchCase) {
throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`);
}
const { if: condition, children } = child.props;
return [{ if: condition, children }];
});
}
export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => {
const { entity } = useEntity();
const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [
children,
]);
const switchCases = useElementFilter(children, collection =>
collection
.selectByComponentData({
key: ENTITY_SWITCH_KEY,
withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case',
})
.getElements()
.flatMap<SwitchCase>((element: React.ReactElement) => {
const { if: condition, children: elementsChildren } = element.props;
return [{ if: condition, children: elementsChildren }];
}),
);
const matchingCase = switchCases.find(switchCase =>
switchCase.if ? switchCase.if(entity) : true,
+81
View File
@@ -0,0 +1,81 @@
## API Report File for "@backstage/plugin-circleci"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { BuildStepAction } from 'circleci-api';
import { BuildSummary } from 'circleci-api';
import { BuildSummaryResponse } from 'circleci-api';
import { BuildWithSteps } from 'circleci-api';
import { CircleCIOptions } from 'circleci-api';
import { DiscoveryApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { GitType } from 'circleci-api';
import { Me } from 'circleci-api';
import { RouteRef } from '@backstage/core';
export { BuildStepAction }
export { BuildSummary }
export { BuildWithSteps }
// @public (undocumented)
export const CIRCLECI_ANNOTATION = "circleci.com/project-slug";
// @public (undocumented)
export class CircleCIApi {
constructor(options: Options);
// (undocumented)
getBuild(buildNumber: number, options: Partial<CircleCIOptions>): Promise<BuildWithSteps>;
// (undocumented)
getBuilds({ limit, offset }: {
limit: number;
offset: number;
}, options: Partial<CircleCIOptions>): Promise<BuildSummaryResponse>;
// (undocumented)
getUser(options: Partial<CircleCIOptions>): Promise<Me>;
// (undocumented)
retry(buildNumber: number, options: Partial<CircleCIOptions>): Promise<BuildSummary>;
}
// @public (undocumented)
export const circleCIApiRef: ApiRef<CircleCIApi>;
// @public (undocumented)
export const circleCIBuildRouteRef: RouteRef<undefined>;
// @public (undocumented)
const circleCIPlugin: BackstagePlugin<{}, {}>;
export { circleCIPlugin }
export { circleCIPlugin as plugin }
// @public (undocumented)
export const circleCIRouteRef: RouteRef<undefined>;
// @public (undocumented)
export const EntityCircleCIContent: (_props: {
entity?: Entity| undefined;
}) => JSX.Element;
export { GitType }
// @public (undocumented)
const isCircleCIAvailable: (entity: Entity) => boolean;
export { isCircleCIAvailable }
export { isCircleCIAvailable as isPluginApplicableToEntity }
// @public (undocumented)
export const Router: (_props: Props) => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+283
View File
@@ -0,0 +1,283 @@
## API Report File for "@backstage/plugin-cloudbuild"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { OAuthApi } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export type ActionsGetWorkflowResponseData = {
id: string;
status: string;
source: Source;
createTime: string;
startTime: string;
steps: Step[];
timeout: string;
projectId: string;
logsBucket: string;
sourceProvenance: SourceProvenance;
buildTriggerId: string;
options: Options;
logUrl: string;
substitutions: Substitutions;
tags: string[];
queueTtl: string;
name: string;
finishTime: any;
results: Results;
timing: Timing2;
};
// @public (undocumented)
export interface ActionsListWorkflowRunsForRepoResponseData {
// (undocumented)
builds: ActionsGetWorkflowResponseData[];
}
// @public (undocumented)
export interface BUILD {
// (undocumented)
endTime: string;
// (undocumented)
startTime: string;
}
// @public (undocumented)
export const CLOUDBUILD_ANNOTATION = "google.com/cloudbuild-project-slug";
// @public (undocumented)
export type CloudbuildApi = {
listWorkflowRuns: (request: {
projectId: string;
}) => Promise<ActionsListWorkflowRunsForRepoResponseData>;
getWorkflow: ({ projectId, id, }: {
projectId: string;
id: string;
}) => Promise<ActionsGetWorkflowResponseData>;
getWorkflowRun: ({ projectId, id, }: {
projectId: string;
id: string;
}) => Promise<ActionsGetWorkflowResponseData>;
reRunWorkflow: ({ projectId, runId, }: {
projectId: string;
runId: string;
}) => Promise<any>;
};
// @public (undocumented)
export const cloudbuildApiRef: ApiRef<CloudbuildApi>;
// @public (undocumented)
export class CloudbuildClient implements CloudbuildApi {
constructor(googleAuthApi: OAuthApi);
// (undocumented)
getToken(): Promise<string>;
// (undocumented)
getWorkflow({ projectId, id, }: {
projectId: string;
id: string;
}): Promise<ActionsGetWorkflowResponseData>;
// (undocumented)
getWorkflowRun({ projectId, id, }: {
projectId: string;
id: string;
}): Promise<ActionsGetWorkflowResponseData>;
// (undocumented)
listWorkflowRuns({ projectId, }: {
projectId: string;
}): Promise<ActionsListWorkflowRunsForRepoResponseData>;
// (undocumented)
reRunWorkflow({ projectId, runId, }: {
projectId: string;
runId: string;
}): Promise<void>;
}
// @public (undocumented)
const cloudbuildPlugin: BackstagePlugin<{
entityContent: RouteRef<undefined>;
}, {}>;
export { cloudbuildPlugin }
export { cloudbuildPlugin as plugin }
// @public (undocumented)
export const EntityCloudbuildContent: (_props: {
entity?: Entity| undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityLatestCloudbuildRunCard: ({ branch, }: {
entity?: Entity| undefined;
branch: string;
}) => JSX.Element;
// @public (undocumented)
export const EntityLatestCloudbuildsForBranchCard: ({ branch, }: {
entity?: Entity| undefined;
branch: string;
}) => JSX.Element;
// @public (undocumented)
export interface FETCHSOURCE {
// (undocumented)
endTime: string;
// (undocumented)
startTime: string;
}
// @public (undocumented)
const isCloudbuildAvailable: (entity: Entity) => boolean;
export { isCloudbuildAvailable }
export { isCloudbuildAvailable as isPluginApplicableToEntity }
// @public (undocumented)
export const LatestWorkflowRunCard: ({ branch, }: {
entity?: Entity | undefined;
branch: string;
}) => JSX.Element;
// @public (undocumented)
export const LatestWorkflowsForBranchCard: ({ branch, }: {
entity?: Entity | undefined;
branch: string;
}) => JSX.Element;
// @public (undocumented)
export interface Options {
// (undocumented)
dynamicSubstitutions: boolean;
// (undocumented)
logging: string;
// (undocumented)
machineType: string;
// (undocumented)
substitutionOption: string;
}
// @public (undocumented)
export interface PullTiming {
// (undocumented)
endTime: string;
// (undocumented)
startTime: string;
}
// @public (undocumented)
export interface ResolvedStorageSource {
// (undocumented)
bucket: string;
// (undocumented)
generation: string;
// (undocumented)
object: string;
}
// @public (undocumented)
export interface Results {
// (undocumented)
buildStepImages: string[];
// (undocumented)
buildStepOutputs: string[];
}
// @public (undocumented)
export const Router: (_props: Props) => JSX.Element;
// @public (undocumented)
export interface Source {
// (undocumented)
storageSource: StorageSource;
}
// @public (undocumented)
export interface SourceProvenance {
// (undocumented)
fileHashes: {};
// (undocumented)
resolvedStorageSource: {};
}
// @public (undocumented)
export interface Step {
// (undocumented)
args: string[];
// (undocumented)
dir: string;
// (undocumented)
entrypoint: string;
// (undocumented)
id: string;
// (undocumented)
name: string;
// (undocumented)
pullTiming: PullTiming;
// (undocumented)
status: string;
// (undocumented)
timing: Timing;
// (undocumented)
volumes: Volume[];
// (undocumented)
waitFor: string[];
}
// @public (undocumented)
export interface StorageSource {
// (undocumented)
bucket: string;
// (undocumented)
object: string;
}
// @public (undocumented)
export interface Substitutions {
// (undocumented)
BRANCH_NAME: string;
// (undocumented)
COMMIT_SHA: string;
// (undocumented)
REPO_NAME: string;
// (undocumented)
REVISION_ID: string;
// (undocumented)
SHORT_SHA: string;
}
// @public (undocumented)
export interface Timing {
// (undocumented)
endTime: string;
// (undocumented)
startTime: string;
}
// @public (undocumented)
export interface Timing2 {
// (undocumented)
BUILD: BUILD;
// (undocumented)
FETCHSOURCE: FETCHSOURCE;
}
// @public (undocumented)
export interface Volume {
// (undocumented)
name: string;
// (undocumented)
path: string;
}
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,43 @@
## API Report File for "@backstage/plugin-code-coverage-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export interface CodeCoverageApi {
// (undocumented)
name: string;
}
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export const makeRouter: (options: RouterOptions) => Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
// (undocumented)
urlReader: UrlReader;
}
// (No @packageDocumentation comment for this package)
```
+32
View File
@@ -0,0 +1,32 @@
## API Report File for "@backstage/plugin-code-coverage"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export const codeCoveragePlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {}>;
// @public (undocumented)
export const EntityCodeCoverageContent: () => JSX.Element;
// @public (undocumented)
const isCodeCoverageAvailable: (entity: Entity) => boolean;
export { isCodeCoverageAvailable }
export { isCodeCoverageAvailable as isPluginApplicableToEntity }
// @public (undocumented)
export const Router: () => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+42
View File
@@ -0,0 +1,42 @@
## API Report File for "@backstage/plugin-config-schema"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { Observable } from '@backstage/core';
import { RouteRef } from '@backstage/core';
import { Schema } from 'jsonschema';
// @public (undocumented)
export interface ConfigSchemaApi {
// (undocumented)
schema$(): Observable<ConfigSchemaResult>;
}
// @public (undocumented)
export const configSchemaApiRef: ApiRef<ConfigSchemaApi>;
// @public (undocumented)
export const ConfigSchemaPage: () => JSX.Element;
// @public (undocumented)
export const configSchemaPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {}>;
// @public
export class StaticSchemaLoader implements ConfigSchemaApi {
constructor({ url }?: {
url?: string;
});
// (undocumented)
schema$(): Observable<ConfigSchemaResult>;
}
// (No @packageDocumentation comment for this package)
```
+621
View File
@@ -0,0 +1,621 @@
## API Report File for "@backstage/plugin-cost-insights"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePalette } from '@backstage/theme';
import { BackstagePlugin } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import { ContentRenderer } from 'recharts';
import { Dispatch } from 'react';
import { ForwardRefExoticComponent } from 'react';
import { PaletteOptions } from '@material-ui/core/styles/createPalette';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { RechartsFunction } from 'recharts';
import { RefAttributes } from 'react';
import { RouteRef } from '@backstage/core';
import { SetStateAction } from 'react';
import { TooltipProps } from 'recharts';
import { TypographyProps } from '@material-ui/core';
// @public
export type Alert = {
title: string | JSX.Element;
subtitle: string | JSX.Element;
element?: JSX.Element;
status?: AlertStatus;
url?: string;
buttonText?: string;
SnoozeForm?: Maybe<AlertForm>;
AcceptForm?: Maybe<AlertForm>;
DismissForm?: Maybe<AlertForm>;
onSnoozed?(options: AlertOptions): Promise<Alert[]>;
onAccepted?(options: AlertOptions): Promise<Alert[]>;
onDismissed?(options: AlertOptions): Promise<Alert[]>;
};
// @public (undocumented)
export interface AlertCost {
// (undocumented)
aggregation: [number, number];
// (undocumented)
id: string;
}
// @public (undocumented)
export interface AlertDismissFormData {
// (undocumented)
feedback: Maybe<string>;
// (undocumented)
other: Maybe<string>;
// (undocumented)
reason: AlertDismissReason;
}
// @public (undocumented)
export interface AlertDismissOption {
// (undocumented)
label: string;
// (undocumented)
reason: string;
}
// @public (undocumented)
export const AlertDismissOptions: AlertDismissOption[];
// @public (undocumented)
export enum AlertDismissReason {
// (undocumented)
Expected = "expected",
// (undocumented)
Migration = "migration",
// (undocumented)
NotApplicable = "not-applicable",
// (undocumented)
Other = "other",
// (undocumented)
Resolved = "resolved",
// (undocumented)
Seasonal = "seasonal"
}
// @public (undocumented)
export type AlertForm<A extends Alert = any, Data = any> = ForwardRefExoticComponent<AlertFormProps<A, Data> & RefAttributes<HTMLFormElement>>;
// @public (undocumented)
export type AlertFormProps<A extends Alert, FormData = {}> = {
alert: A;
onSubmit: (data: FormData) => void;
disableSubmit: (isDisabled: boolean) => void;
};
// @public (undocumented)
export interface AlertOptions<T = any> {
// (undocumented)
data: T;
// (undocumented)
group: string;
}
// @public
export interface AlertSnoozeFormData {
// (undocumented)
intervals: string;
}
// @public (undocumented)
export type AlertSnoozeOption = {
label: string;
duration: Duration;
};
// @public (undocumented)
export const AlertSnoozeOptions: AlertSnoozeOption[];
// @public (undocumented)
export enum AlertStatus {
// (undocumented)
Accepted = "accepted",
// (undocumented)
Dismissed = "dismissed",
// (undocumented)
Snoozed = "snoozed"
}
// @public (undocumented)
export const BarChart: ({ resources, responsive, displayAmount, options, tooltip, onClick, onMouseMove, }: BarChartProps) => JSX.Element;
// @public
export interface BarChartData extends BarChartOptions {
}
// @public (undocumented)
export const BarChartLegend: ({ costStart, costEnd, options, children, }: PropsWithChildren<BarChartLegendProps>) => JSX.Element;
// @public (undocumented)
export type BarChartLegendOptions = {
previousName: string;
previousFill: string;
currentName: string;
currentFill: string;
hideMarker?: boolean;
};
// @public (undocumented)
export type BarChartLegendProps = {
costStart: number;
costEnd: number;
options?: Partial<BarChartLegendOptions>;
};
// @public (undocumented)
export interface BarChartOptions {
// (undocumented)
currentFill: string;
// (undocumented)
currentName: string;
// (undocumented)
previousFill: string;
// (undocumented)
previousName: string;
}
// @public (undocumented)
export type BarChartProps = {
resources: ResourceData[];
responsive?: boolean;
displayAmount?: number;
options?: Partial<BarChartData>;
tooltip?: ContentRenderer<TooltipProps>;
onClick?: RechartsFunction;
onMouseMove?: RechartsFunction;
};
// @public (undocumented)
export const BarChartTooltip: ({ title, content, subtitle, topRight, actions, children, }: PropsWithChildren<BarChartTooltipProps>) => JSX.Element;
// @public (undocumented)
export const BarChartTooltipItem: ({ item }: BarChartTooltipItemProps) => JSX.Element;
// @public (undocumented)
export type BarChartTooltipItemProps = {
item: TooltipItem;
};
// @public (undocumented)
export type BarChartTooltipProps = {
title: string;
content?: ReactNode | string;
subtitle?: ReactNode;
topRight?: ReactNode;
actions?: ReactNode;
};
// @public (undocumented)
export interface ChangeStatistic {
// (undocumented)
amount: number;
// (undocumented)
ratio?: number;
}
// @public (undocumented)
export enum ChangeThreshold {
// (undocumented)
lower = -0.05,
// (undocumented)
upper = 0.05
}
// @public (undocumented)
export type ChartData = {
date: number;
trend: number;
dailyCost: number;
[key: string]: number;
};
// @public (undocumented)
export interface Cost {
// (undocumented)
aggregation: DateAggregation[];
// (undocumented)
change?: ChangeStatistic;
// (undocumented)
groupedCosts?: Record<string, Cost[]>;
// (undocumented)
id: string;
// (undocumented)
trendline?: Trendline;
}
// @public (undocumented)
export const CostGrowth: ({ change, duration }: CostGrowthProps) => JSX.Element;
// @public (undocumented)
export const CostGrowthIndicator: ({ change, formatter, className, ...props }: CostGrowthIndicatorProps) => JSX.Element;
// @public (undocumented)
export type CostGrowthIndicatorProps = TypographyProps & {
change: ChangeStatistic;
formatter?: (change: ChangeStatistic) => Maybe<string>;
};
// @public (undocumented)
export type CostGrowthProps = {
change: ChangeStatistic;
duration: Duration;
};
// @public (undocumented)
export type CostInsightsApi = {
getLastCompleteBillingDate(): Promise<string>;
getUserGroups(userId: string): Promise<Group[]>;
getGroupProjects(group: string): Promise<Project[]>;
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
getAlerts(group: string): Promise<Alert[]>;
};
// @public (undocumented)
export const costInsightsApiRef: ApiRef<CostInsightsApi>;
// @public (undocumented)
export const CostInsightsLabelDataflowInstructionsPage: () => JSX.Element;
// @public (undocumented)
export const CostInsightsPage: () => JSX.Element;
// @public (undocumented)
export type CostInsightsPalette = BackstagePalette & CostInsightsPaletteAdditions;
// @public (undocumented)
export type CostInsightsPaletteOptions = PaletteOptions & CostInsightsPaletteAdditions;
// @public (undocumented)
const costInsightsPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
growthAlerts: RouteRef<undefined>;
unlabeledDataflowAlerts: RouteRef<undefined>;
}, {}>;
export { costInsightsPlugin }
export { costInsightsPlugin as plugin }
// @public (undocumented)
export const CostInsightsProjectGrowthInstructionsPage: () => JSX.Element;
// @public (undocumented)
export interface CostInsightsTheme extends BackstageTheme {
// (undocumented)
palette: CostInsightsPalette;
}
// @public (undocumented)
export interface CostInsightsThemeOptions extends PaletteOptions {
// (undocumented)
palette: CostInsightsPaletteOptions;
}
// @public (undocumented)
export interface Currency {
// (undocumented)
kind: string | null;
// (undocumented)
label: string;
// (undocumented)
prefix?: string;
// (undocumented)
rate?: number;
// (undocumented)
unit: string;
}
// @public (undocumented)
export enum CurrencyType {
// (undocumented)
Beers = "BEERS",
// (undocumented)
CarbonOffsetTons = "CARBON_OFFSET_TONS",
// (undocumented)
IceCream = "PINTS_OF_ICE_CREAM",
// (undocumented)
USD = "USD"
}
// @public (undocumented)
export enum DataKey {
// (undocumented)
Current = "current",
// (undocumented)
Name = "name",
// (undocumented)
Previous = "previous"
}
// @public (undocumented)
export type DateAggregation = {
date: string;
amount: number;
};
// @public (undocumented)
export const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
// @public
export enum Duration {
// (undocumented)
P30D = "P30D",
// (undocumented)
P3M = "P3M",
// (undocumented)
P7D = "P7D",
// (undocumented)
P90D = "P90D"
}
// @public (undocumented)
export const EngineerThreshold = 0.5;
// @public (undocumented)
export interface Entity {
// (undocumented)
aggregation: [number, number];
// (undocumented)
change: ChangeStatistic;
// (undocumented)
entities: Record<string, Entity[]>;
// (undocumented)
id: Maybe<string>;
}
// @public (undocumented)
export class ExampleCostInsightsClient implements CostInsightsApi {
// (undocumented)
getAlerts(group: string): Promise<Alert[]>;
// (undocumented)
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
// (undocumented)
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
// (undocumented)
getGroupProjects(group: string): Promise<Project[]>;
// (undocumented)
getLastCompleteBillingDate(): Promise<string>;
// (undocumented)
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
// (undocumented)
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
// (undocumented)
getUserGroups(userId: string): Promise<Group[]>;
}
// @public (undocumented)
export type Group = {
id: string;
};
// @public (undocumented)
export enum GrowthType {
// (undocumented)
Excess = 2,
// (undocumented)
Negligible = 0,
// (undocumented)
Savings = 1
}
// @public (undocumented)
export type Icon = {
kind: string;
component: JSX.Element;
};
// @public (undocumented)
export enum IconType {
// (undocumented)
Compute = "compute",
// (undocumented)
Data = "data",
// (undocumented)
Database = "database",
// (undocumented)
ML = "ml",
// (undocumented)
Search = "search",
// (undocumented)
Storage = "storage"
}
// @public (undocumented)
export const LegendItem: ({ title, tooltipText, markerColor, children, }: PropsWithChildren<LegendItemProps>) => JSX.Element;
// @public (undocumented)
export type LegendItemProps = {
title: string;
tooltipText?: string;
markerColor?: string;
};
// @public (undocumented)
export type Loading = Record<string, boolean>;
// @public (undocumented)
export type Maybe<T> = T | null;
// @public (undocumented)
export type Metric = {
kind: string;
name: string;
default: boolean;
};
// @public (undocumented)
export interface MetricData {
// (undocumented)
aggregation: DateAggregation[];
// (undocumented)
change: ChangeStatistic;
// (undocumented)
format: 'number' | 'currency';
// (undocumented)
id: string;
}
// @public (undocumented)
export const MockConfigProvider: ({ children, ...context }: MockConfigProviderProps) => JSX.Element;
// @public (undocumented)
export const MockCurrencyProvider: ({ children, ...context }: MockCurrencyProviderProps) => JSX.Element;
// @public (undocumented)
export interface PageFilters {
// (undocumented)
duration: Duration;
// (undocumented)
group: Maybe<string>;
// (undocumented)
metric: string | null;
// (undocumented)
project: Maybe<string>;
}
// @public (undocumented)
export interface Product {
// (undocumented)
kind: string;
// (undocumented)
name: string;
}
// @public (undocumented)
export type ProductFilters = Array<ProductPeriod>;
// @public (undocumented)
export type ProductInsightsOptions = {
product: string;
group: string;
intervals: string;
project: Maybe<string>;
};
// @public (undocumented)
export interface ProductPeriod {
// (undocumented)
duration: Duration;
// (undocumented)
productType: string;
}
// @public (undocumented)
export interface Project {
// (undocumented)
id: string;
// (undocumented)
name?: string;
}
// @public
export class ProjectGrowthAlert implements Alert {
constructor(data: ProjectGrowthData);
// (undocumented)
data: ProjectGrowthData;
// (undocumented)
get element(): JSX.Element;
// (undocumented)
get subtitle(): string;
// (undocumented)
get title(): string;
// (undocumented)
get url(): string;
}
// @public (undocumented)
export interface ProjectGrowthData {
// (undocumented)
aggregation: [number, number];
// (undocumented)
change: ChangeStatistic;
// (undocumented)
periodEnd: string;
// (undocumented)
periodStart: string;
// (undocumented)
products: Array<AlertCost>;
// (undocumented)
project: string;
}
// @public (undocumented)
export interface ResourceData {
// (undocumented)
current: number;
// (undocumented)
name: Maybe<string>;
// (undocumented)
previous: number;
}
// @public (undocumented)
export type TooltipItem = {
fill: string;
label: string;
value: string;
};
// @public (undocumented)
export type Trendline = {
slope: number;
intercept: number;
};
// @public
export class UnlabeledDataflowAlert implements Alert {
constructor(data: UnlabeledDataflowData);
// (undocumented)
data: UnlabeledDataflowData;
// (undocumented)
get element(): JSX.Element;
// (undocumented)
status?: AlertStatus;
// (undocumented)
get subtitle(): string;
// (undocumented)
get title(): string;
// (undocumented)
get url(): string;
}
// @public (undocumented)
export interface UnlabeledDataflowAlertProject {
// (undocumented)
id: string;
// (undocumented)
labeledCost: number;
// (undocumented)
unlabeledCost: number;
}
// @public (undocumented)
export interface UnlabeledDataflowData {
// (undocumented)
labeledCost: number;
// (undocumented)
periodEnd: string;
// (undocumented)
periodStart: string;
// (undocumented)
projects: Array<UnlabeledDataflowAlertProject>;
// (undocumented)
unlabeledCost: number;
}
// (No @packageDocumentation comment for this package)
```
+31
View File
@@ -0,0 +1,31 @@
## API Report File for "@backstage/plugin-explore-react"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
// @public (undocumented)
export type ExploreTool = {
title: string;
description?: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
};
// @public (undocumented)
export interface ExploreToolsConfig {
// (undocumented)
getTools: () => Promise<ExploreTool[]>;
}
// @public (undocumented)
export const exploreToolsConfigRef: ApiRef<ExploreToolsConfig>;
// (No @packageDocumentation comment for this package)
```
+46
View File
@@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers';
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
```
## Customization
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
```tsx
import {
DomainExplorerContent,
ExploreLayout,
} from '@backstage/plugin-explore';
import React from 'react';
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
export const ExplorePage = () => {
return (
<ExploreLayout
title="Explore the ACME corp ecosystem"
subtitle="Browse our ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="inner-source" title="InnerSource">
<AcmeInnserSourceExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
export const explorePage = <ExplorePage />;
```
Now register the new explore page in `packages/app/src/App.tsx`.
```diff
+ import { explorePage } from './components/explore/ExplorePage';
const routes = (
<FlatRoutes>
- <Route path="/explore" element={<ExplorePage />} />
+ <Route path="/explore" element={<ExplorePage />}>
+ {explorePage}
+ </Route>
</FlatRoutes>
);
```
+38
View File
@@ -0,0 +1,38 @@
## API Report File for "@backstage/plugin-explore"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core';
import { ExternalRouteRef } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export const catalogEntityRouteRef: ExternalRouteRef<{
name: string;
kind: string;
namespace: string;
}, false>;
// @public (undocumented)
export const ExplorePage: () => JSX.Element;
// @public (undocumented)
export const explorePlugin: BackstagePlugin<{
explore: RouteRef<undefined>;
}, {
catalogEntity: ExternalRouteRef<{
name: string;
kind: string;
namespace: string;
}, false>;
}>;
// @public (undocumented)
export const exploreRouteRef: RouteRef<undefined>;
// (No @packageDocumentation comment for this package)
```
+2
View File
@@ -38,9 +38,11 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"classnames": "^2.2.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
@@ -0,0 +1,63 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor, getByText } from '@testing-library/react';
import React from 'react';
import { DefaultExplorePage } from './DefaultExplorePage';
describe('<DefaultExplorePage />', () => {
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders the default explore page', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getAllByRole } = await renderInTestApp(
<Wrapper>
<DefaultExplorePage />
</Wrapper>,
);
await waitFor(() => {
const elements = getAllByRole('tab');
expect(elements.length).toBe(3);
expect(getByText(elements[0], 'Domains')).toBeInTheDocument();
expect(getByText(elements[1], 'Groups')).toBeInTheDocument();
expect(getByText(elements[2], 'Tools')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { configApiRef, useApi } from '@backstage/core';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ExploreLayout } from '../ExploreLayout';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const DefaultExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<ExploreLayout
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="groups" title="Groups">
<GroupsExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DefaultExplorePage } from './DefaultExplorePage';
@@ -41,6 +41,12 @@ describe('<DomainExplorerContent />', () => {
</ApiProvider>
);
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
};
beforeEach(() => {
jest.resetAllMocks();
});
@@ -74,11 +80,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() => {
@@ -87,6 +89,19 @@ describe('<DomainExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = await renderInTestApp(
<Wrapper>
<DomainExplorerContent title="Our Areas" />
</Wrapper>,
mountedRoutes,
);
await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument());
});
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
@@ -94,11 +109,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -114,11 +125,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -79,10 +79,16 @@ const Body = () => {
);
};
export const DomainExplorerContent = () => {
type DomainExplorerContentProps = {
title?: string;
};
export const DomainExplorerContent = ({
title,
}: DomainExplorerContentProps) => {
return (
<Content noPadding>
<ContentHeader title="Domains">
<ContentHeader title={title ?? 'Domains'}>
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
</ContentHeader>
<Body />
@@ -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 { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ExploreLayout } from './ExploreLayout';
describe('<ExploreLayout />', () => {
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<>{children}</>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders an explore tabbed layout page with defaults', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout>
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() => {
expect(getByText('Explore our ecosystem')).toBeInTheDocument();
expect(
getByText('Discover solutions available in our ecosystem'),
).toBeInTheDocument();
});
});
it('renders a custom page title', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout title="Explore our universe">
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() =>
expect(getByText('Explore our universe')).toBeInTheDocument(),
);
});
it('renders a custom page subtitle', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout subtitle="Browse the ACME Corp ecosystem">
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() =>
expect(getByText('Browse the ACME Corp ecosystem')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,102 @@
/*
* 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 { attachComponentData, Header, Page, RoutedTabs } from '@backstage/core';
import { TabProps } from '@material-ui/core';
import { Children, default as React, Fragment, isValidElement } from 'react';
// TODO: This layout could be a shared based component if it was possible to create custom TabbedLayouts
// A generalized version of createSubRoutesFromChildren, etc. would be required
type SubRoute = {
path: string;
title: string;
children: JSX.Element;
tabProps?: TabProps<React.ElementType, { component?: React.ElementType }>;
};
const Route: (props: SubRoute) => null = () => null;
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
function createSubRoutesFromChildren(
childrenProps: React.ReactNode,
): SubRoute[] {
// Directly comparing child.type with Route will not work with in
// combination with react-hot-loader in storybook
// https://github.com/gaearon/react-hot-loader/issues/304
const routeType = (
<Route path="" title="">
<div />
</Route>
).type;
return Children.toArray(childrenProps).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children);
}
if (child.type !== routeType) {
throw new Error('Child of ExploreLayout must be an ExploreLayout.Route');
}
const { path, title, children, tabProps } = child.props;
return [{ path, title, children, tabProps }];
});
}
type ExploreLayoutProps = {
title?: string;
subtitle?: string;
children?: React.ReactNode;
};
/**
* Explore is a compound component, which allows you to define a custom layout
*
* @example
* ```jsx
* <ExploreLayout title="Explore ACME's ecosystem">
* <ExploreLayout.Route path="/example" title="Example tab">
* <div>This is rendered under /example/anything-here route</div>
* </ExploreLayout.Route>
* </ExploreLayout>
* ```
*/
export const ExploreLayout = ({
title,
subtitle,
children,
}: ExploreLayoutProps) => {
const routes = createSubRoutesFromChildren(children);
return (
<Page themeId="home">
<Header
title={title ?? 'Explore our ecosystem'}
subtitle={subtitle ?? 'Discover solutions available in our ecosystem'}
/>
<RoutedTabs routes={routes} />
</Page>
);
};
ExploreLayout.Route = Route;
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExploreLayout } from './ExploreLayout';
@@ -0,0 +1,48 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { useOutlet } from 'react-router';
import { ExplorePage } from './ExplorePage';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
jest.mock('../DefaultExplorePage', () => ({
...jest.requireActual('../DefaultExplorePage'),
DefaultExplorePage: jest.fn().mockReturnValue('DefaultExplorePageMock'),
}));
describe('ExplorePage', () => {
it('renders provided router element', async () => {
const { getByText } = await renderInTestApp(<ExplorePage />);
expect(getByText('Route Children')).toBeInTheDocument();
});
it('renders default explorer page when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<ExplorePage />);
expect(getByText('DefaultExplorePageMock')).toBeInTheDocument();
});
});
@@ -13,22 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { configApiRef, Header, Page, useApi } from '@backstage/core';
import React from 'react';
import { ExploreTabs } from './ExploreTabs';
import { useOutlet } from 'react-router';
import { DefaultExplorePage } from '../DefaultExplorePage';
export const ExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
/>
const outlet = useOutlet();
<ExploreTabs />
</Page>
);
return <>{outlet || <DefaultExplorePage />}</>;
};
@@ -1,34 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const ExploreTabs = () => (
<TabbedLayout>
<TabbedLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="groups" title="Groups">
<GroupsExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</TabbedLayout.Route>
</TabbedLayout>
);
@@ -40,6 +40,12 @@ describe('<GroupsExplorerContent />', () => {
</ApiProvider>
);
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
};
beforeEach(() => {
jest.resetAllMocks();
@@ -69,11 +75,7 @@ describe('<GroupsExplorerContent />', () => {
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() => {
@@ -81,6 +83,19 @@ describe('<GroupsExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = await renderInTestApp(
<Wrapper>
<GroupsExplorerContent title="Our Teams" />
</Wrapper>,
mountedRoutes,
);
await waitFor(() => expect(getByText('Our Teams')).toBeInTheDocument());
});
it('renders a friendly error if it cannot collect domains', async () => {
const catalogError = new Error('Network timeout');
catalogApi.getEntities.mockRejectedValueOnce(catalogError);
@@ -89,11 +104,7 @@ describe('<GroupsExplorerContent />', () => {
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -13,14 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Content, ContentHeader, SupportButton } from '@backstage/core';
import React from 'react';
import { GroupsDiagram } from './GroupsDiagram';
export const GroupsExplorerContent = () => {
type GroupsExplorerContentProps = {
title?: string;
};
export const GroupsExplorerContent = ({
title,
}: GroupsExplorerContentProps) => {
return (
<Content noPadding>
<ContentHeader title="Groups">
<ContentHeader title={title ?? 'Groups'}>
<SupportButton>Explore your groups.</SupportButton>
</ContentHeader>
@@ -80,6 +80,18 @@ describe('<ToolExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
const { getByText } = await renderInTestApp(
<Wrapper>
<ToolExplorerContent title="Our Tools" />
</Wrapper>,
);
await waitFor(() => expect(getByText('Our Tools')).toBeInTheDocument());
});
it('renders empty state', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Content,
ContentHeader,
@@ -61,9 +62,13 @@ const Body = () => {
);
};
export const ToolExplorerContent = () => (
type ToolExplorerContentProps = {
title?: string;
};
export const ToolExplorerContent = ({ title }: ToolExplorerContentProps) => (
<Content noPadding>
<ContentHeader title="Tools">
<ContentHeader title={title ?? 'Tools'}>
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
</ContentHeader>
<Body />
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExploreLayout } from './ExploreLayout';
+37 -1
View File
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { createRoutableExtension } from '@backstage/core';
import {
createComponentExtension,
createRoutableExtension,
} from '@backstage/core';
import { explorePlugin } from './plugin';
import { exploreRouteRef } from './routes';
@@ -25,3 +28,36 @@ export const ExplorePage = explorePlugin.provide(
mountPoint: exploreRouteRef,
}),
);
export const DomainExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/DomainExplorerContent').then(
m => m.DomainExplorerContent,
),
},
}),
);
export const GroupsExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/GroupsExplorerContent').then(
m => m.GroupsExplorerContent,
),
},
}),
);
export const ToolExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/ToolExplorerContent').then(
m => m.ToolExplorerContent,
),
},
}),
);
+2 -1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { ExploreLayout } from './components';
export * from './extensions';
export { explorePlugin } from './plugin';
export { explorePlugin, explorePlugin as plugin } from './plugin';
export * from './routes';
+27
View File
@@ -0,0 +1,27 @@
## API Report File for "@backstage/plugin-fossa"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core';
import { InfoCardVariants } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export const EntityFossaCard: ({ variant }: {
variant?: InfoCardVariants| undefined;
}) => JSX.Element;
// @public (undocumented)
export const FossaPage: () => JSX.Element;
// @public (undocumented)
export const fossaPlugin: BackstagePlugin<{
fossaOverview: RouteRef<undefined>;
}, {}>;
// (No @packageDocumentation comment for this package)
```
+86
View File
@@ -0,0 +1,86 @@
## API Report File for "@backstage/plugin-gcp-projects"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { OAuthApi } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export type GcpApi = {
listProjects(): Promise<Project[]>;
getProject(projectId: string): Promise<Project>;
createProject(options: {
projectId: string;
projectName: string;
}): Promise<Operation>;
};
// @public (undocumented)
export const gcpApiRef: ApiRef<GcpApi>;
// @public (undocumented)
export class GcpClient implements GcpApi {
constructor(googleAuthApi: OAuthApi);
// (undocumented)
createProject(options: {
projectId: string;
projectName: string;
}): Promise<Operation>;
// (undocumented)
getProject(projectId: string): Promise<Project>;
// (undocumented)
getToken(): Promise<string>;
// (undocumented)
listProjects(): Promise<Project[]>;
}
// @public (undocumented)
export const GcpProjectsPage: () => JSX.Element;
// @public (undocumented)
const gcpProjectsPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {}>;
export { gcpProjectsPlugin }
export { gcpProjectsPlugin as plugin }
// @public (undocumented)
export type Operation = {
name: string;
metadata: string;
done: boolean;
error: Status;
response: string;
};
// @public (undocumented)
export type Project = {
name: string;
projectNumber?: string;
projectId: string;
lifecycleState?: string;
createTime?: string;
};
// @public (undocumented)
export type ProjectDetails = {
details: string;
};
// @public (undocumented)
export type Status = {
code: number;
message: string;
details: string[];
};
// (No @packageDocumentation comment for this package)
```
+25
View File
@@ -0,0 +1,25 @@
## API Report File for "@backstage/plugin-git-release-manager"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export const gitReleaseManagerApiRef: ApiRef<GitReleaseApi>;
// @public (undocumented)
export const GitReleaseManagerPage: GitReleaseManager;
// @public (undocumented)
export const gitReleaseManagerPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {}>;
// (No @packageDocumentation comment for this package)
```
+213
View File
@@ -0,0 +1,213 @@
## API Report File for "@backstage/plugin-github-actions"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { ConfigApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { InfoCardVariants } from '@backstage/core';
import { OAuthApi } from '@backstage/core';
import { RestEndpointMethodTypes } from '@octokit/rest';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export enum BuildStatus {
// (undocumented)
'failure' = 1,
// (undocumented)
'pending' = 2,
// (undocumented)
'running' = 3,
// (undocumented)
'success' = 0
}
// @public (undocumented)
export const EntityGithubActionsContent: (_props: {
entity?: Entity| undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityLatestGithubActionRunCard: ({ branch, variant, }: {
entity?: Entity| undefined;
branch: string;
variant?: InfoCardVariants| undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityLatestGithubActionsForBranchCard: ({ branch, variant, }: {
entity?: Entity| undefined;
branch: string;
variant?: InfoCardVariants| undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityRecentGithubActionsRunsCard: ({ branch, dense, limit, variant, }: Props) => JSX.Element;
// @public (undocumented)
export const GITHUB_ACTIONS_ANNOTATION = "github.com/project-slug";
// @public (undocumented)
export type GithubActionsApi = {
listWorkflowRuns: ({ hostname, owner, repo, pageSize, page, branch, }: {
hostname?: string;
owner: string;
repo: string;
pageSize?: number;
page?: number;
branch?: string;
}) => Promise<RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data']>;
getWorkflow: ({ hostname, owner, repo, id, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
}) => Promise<RestEndpointMethodTypes['actions']['getWorkflow']['response']['data']>;
getWorkflowRun: ({ hostname, owner, repo, id, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
}) => Promise<RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data']>;
reRunWorkflow: ({ hostname, owner, repo, runId, }: {
hostname?: string;
owner: string;
repo: string;
runId: number;
}) => Promise<any>;
listJobsForWorkflowRun: ({ hostname, owner, repo, id, pageSize, page, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
pageSize?: number;
page?: number;
}) => Promise<RestEndpointMethodTypes['actions']['listJobsForWorkflowRun']['response']['data']>;
downloadJobLogsForWorkflowRun: ({ hostname, owner, repo, runId, }: {
hostname?: string;
owner: string;
repo: string;
runId: number;
}) => Promise<RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data']>;
};
// @public (undocumented)
export const githubActionsApiRef: ApiRef<GithubActionsApi>;
// @public (undocumented)
export class GithubActionsClient implements GithubActionsApi {
constructor(options: {
configApi: ConfigApi;
githubAuthApi: OAuthApi;
});
// (undocumented)
downloadJobLogsForWorkflowRun({ hostname, owner, repo, runId, }: {
hostname?: string;
owner: string;
repo: string;
runId: number;
}): Promise<RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data']>;
// (undocumented)
getWorkflow({ hostname, owner, repo, id, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
}): Promise<RestEndpointMethodTypes['actions']['getWorkflow']['response']['data']>;
// (undocumented)
getWorkflowRun({ hostname, owner, repo, id, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
}): Promise<RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data']>;
// (undocumented)
listJobsForWorkflowRun({ hostname, owner, repo, id, pageSize, page, }: {
hostname?: string;
owner: string;
repo: string;
id: number;
pageSize?: number;
page?: number;
}): Promise<RestEndpointMethodTypes['actions']['listJobsForWorkflowRun']['response']['data']>;
// (undocumented)
listWorkflowRuns({ hostname, owner, repo, pageSize, page, branch, }: {
hostname?: string;
owner: string;
repo: string;
pageSize?: number;
page?: number;
branch?: string;
}): Promise<RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data']>;
// (undocumented)
reRunWorkflow({ hostname, owner, repo, runId, }: {
hostname?: string;
owner: string;
repo: string;
runId: number;
}): Promise<any>;
}
// @public (undocumented)
const githubActionsPlugin: BackstagePlugin<{
entityContent: RouteRef<undefined>;
}, {}>;
export { githubActionsPlugin }
export { githubActionsPlugin as plugin }
// @public (undocumented)
const isGithubActionsAvailable: (entity: Entity) => boolean;
export { isGithubActionsAvailable }
export { isGithubActionsAvailable as isPluginApplicableToEntity }
// @public (undocumented)
export type Job = {
html_url: string;
status: string;
conclusion: string;
started_at: string;
completed_at: string;
id: number;
name: string;
steps: Step[];
};
// @public (undocumented)
export type Jobs = {
total_count: number;
jobs: Job[];
};
// @public (undocumented)
export const LatestWorkflowRunCard: ({ branch, variant, }: Props_3) => JSX.Element;
// @public (undocumented)
export const LatestWorkflowsForBranchCard: ({ branch, variant, }: Props_3) => JSX.Element;
// @public (undocumented)
export const RecentWorkflowRunsCard: ({ branch, dense, limit, variant, }: Props) => JSX.Element;
// @public (undocumented)
export const Router: (_props: Props_2) => JSX.Element;
// @public (undocumented)
export type Step = {
name: string;
status: string;
conclusion?: string;
number: number;
started_at: string;
completed_at: string;
};
// (No @packageDocumentation comment for this package)
```
+197
View File
@@ -0,0 +1,197 @@
## API Report File for "@backstage/plugin-gitops-profiles"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export interface ApplyProfileRequest {
// (undocumented)
gitHubToken: string;
// (undocumented)
gitHubUser: string;
// (undocumented)
profiles: string[];
// (undocumented)
targetOrg: string;
// (undocumented)
targetRepo: string;
}
// @public (undocumented)
export interface ChangeClusterStateRequest {
// (undocumented)
clusterState: 'present' | 'absent';
// (undocumented)
gitHubToken: string;
// (undocumented)
gitHubUser: string;
// (undocumented)
targetOrg: string;
// (undocumented)
targetRepo: string;
}
// @public (undocumented)
export interface CloneFromTemplateRequest {
// (undocumented)
gitHubToken: string;
// (undocumented)
gitHubUser: string;
// (undocumented)
secrets: {
awsAccessKeyId: string;
awsSecretAccessKey: string;
};
// (undocumented)
targetOrg: string;
// (undocumented)
targetRepo: string;
// (undocumented)
templateRepository: string;
}
// @public (undocumented)
export interface ClusterStatus {
// (undocumented)
conclusion: string;
// (undocumented)
link: string;
// (undocumented)
name: string;
// (undocumented)
runStatus: Status[];
// (undocumented)
status: string;
}
// @public (undocumented)
export class FetchError extends Error {
// (undocumented)
static forResponse(resp: Response): Promise<FetchError>;
// (undocumented)
get name(): string;
}
// @public (undocumented)
export interface GithubUserInfoRequest {
// (undocumented)
accessToken: string;
}
// @public (undocumented)
export interface GithubUserInfoResponse {
// (undocumented)
login: string;
}
// @public (undocumented)
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
fetchUserInfo(req: GithubUserInfoRequest): Promise<GithubUserInfoResponse>;
};
// @public (undocumented)
export const gitOpsApiRef: ApiRef<GitOpsApi>;
// @public (undocumented)
export const GitopsProfilesClusterListPage: () => JSX.Element;
// @public (undocumented)
export const GitopsProfilesClusterPage: () => JSX.Element;
// @public (undocumented)
export const GitopsProfilesCreatePage: () => JSX.Element;
// @public (undocumented)
const gitopsProfilesPlugin: BackstagePlugin<{
listPage: RouteRef<undefined>;
detailsPage: RouteRef<{
owner: string;
repo: string;
}>;
createPage: RouteRef<undefined>;
}, {}>;
export { gitopsProfilesPlugin }
export { gitopsProfilesPlugin as plugin }
// @public (undocumented)
export class GitOpsRestApi implements GitOpsApi {
constructor(url?: string);
// (undocumented)
applyProfiles(req: ApplyProfileRequest): Promise<any>;
// (undocumented)
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
// (undocumented)
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
// (undocumented)
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
// (undocumented)
fetchUserInfo(req: GithubUserInfoRequest): Promise<GithubUserInfoResponse>;
// (undocumented)
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
// (undocumented)
url: string;
}
// @public (undocumented)
export interface ListClusterRequest {
// (undocumented)
gitHubToken: string;
// (undocumented)
gitHubUser: string;
}
// @public (undocumented)
export interface ListClusterStatusesResponse {
// (undocumented)
result: ClusterStatus[];
}
// @public (undocumented)
export interface PollLogRequest {
// (undocumented)
gitHubToken: string;
// (undocumented)
gitHubUser: string;
// (undocumented)
targetOrg: string;
// (undocumented)
targetRepo: string;
}
// @public (undocumented)
export interface Status {
// (undocumented)
conclusion: string;
// (undocumented)
message: string;
// (undocumented)
status: string;
}
// @public (undocumented)
export interface StatusResponse {
// (undocumented)
link: string;
// (undocumented)
result: Status[];
// (undocumented)
status: string;
}
// (No @packageDocumentation comment for this package)
```
+82
View File
@@ -0,0 +1,82 @@
## API Report File for "@backstage/plugin-graphiql"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core';
import { OAuthApi } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
export type EndpointConfig = {
id: string;
title: string;
url: string;
method?: 'POST';
headers?: {
[name in string]: string;
};
};
// @public (undocumented)
export type GithubEndpointConfig = {
id: string;
title: string;
url?: string;
errorApi?: ErrorApi;
githubAuthApi: OAuthApi;
};
// @public (undocumented)
export const GraphiQLIcon: IconComponent;
// @public (undocumented)
export const GraphiQLPage: () => JSX.Element;
// @public (undocumented)
const graphiqlPlugin: BackstagePlugin<{}, {}>;
export { graphiqlPlugin }
export { graphiqlPlugin as plugin }
// @public (undocumented)
export const graphiQLRouteRef: RouteRef<undefined>;
// @public (undocumented)
export type GraphQLBrowseApi = {
getEndpoints(): Promise<GraphQLEndpoint[]>;
};
// @public (undocumented)
export const graphQlBrowseApiRef: ApiRef<GraphQLBrowseApi>;
// @public (undocumented)
export type GraphQLEndpoint = {
id: string;
title: string;
fetcher: (body: any) => Promise<any>;
};
// @public (undocumented)
export class GraphQLEndpoints implements GraphQLBrowseApi {
// (undocumented)
static create(config: EndpointConfig): GraphQLEndpoint;
// (undocumented)
static from(endpoints: GraphQLEndpoint[]): GraphQLEndpoints;
// (undocumented)
getEndpoints(): Promise<GraphQLEndpoint[]>;
static github(config: GithubEndpointConfig): GraphQLEndpoint;
}
// @public (undocumented)
export const Router: () => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+25
View File
@@ -0,0 +1,25 @@
## API Report File for "@backstage/plugin-graphql-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
}
// (No @packageDocumentation comment for this package)
```
+205
View File
@@ -0,0 +1,205 @@
## API Report File for "@backstage/plugin-ilert"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core';
import { BackstagePlugin } from '@backstage/core';
import { ConfigApi } from '@backstage/core';
import { DiscoveryApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import { RouteRef } from '@backstage/core';
// @public (undocumented)
export const EntityILertCard: () => JSX.Element;
// @public (undocumented)
export type GetIncidentsCountOpts = {
states?: IncidentStatus[];
};
// @public (undocumented)
export type GetIncidentsOpts = {
maxResults?: number;
startIndex?: number;
states?: IncidentStatus[];
alertSources?: number[];
};
// @public (undocumented)
export interface ILertApi {
// (undocumented)
acceptIncident(incident: Incident, userName: string): Promise<Incident>;
// (undocumented)
addImmediateMaintenance(alertSourceId: number, minutes: number): Promise<void>;
// (undocumented)
assignIncident(incident: Incident, responder: IncidentResponder): Promise<Incident>;
// (undocumented)
createIncident(eventRequest: EventRequest): Promise<boolean>;
// (undocumented)
disableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
// (undocumented)
enableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
// (undocumented)
fetchAlertSource(idOrIntegrationKey: number | string): Promise<AlertSource>;
// (undocumented)
fetchAlertSourceOnCalls(alertSource: AlertSource): Promise<OnCall[]>;
// (undocumented)
fetchAlertSources(): Promise<AlertSource[]>;
// (undocumented)
fetchIncident(id: number): Promise<Incident>;
// (undocumented)
fetchIncidentActions(incident: Incident): Promise<IncidentAction[]>;
// (undocumented)
fetchIncidentResponders(incident: Incident): Promise<IncidentResponder[]>;
// (undocumented)
fetchIncidents(opts?: GetIncidentsOpts): Promise<Incident[]>;
// (undocumented)
fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise<number>;
// (undocumented)
fetchOnCallSchedules(): Promise<Schedule[]>;
// (undocumented)
fetchUptimeMonitor(id: number): Promise<UptimeMonitor>;
// (undocumented)
fetchUptimeMonitors(): Promise<UptimeMonitor[]>;
// (undocumented)
fetchUsers(): Promise<User[]>;
// (undocumented)
getAlertSourceDetailsURL(alertSource: AlertSource | null): string;
// (undocumented)
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string;
// (undocumented)
getIncidentDetailsURL(incident: Incident): string;
// (undocumented)
getScheduleDetailsURL(schedule: Schedule): string;
// (undocumented)
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string;
// (undocumented)
getUserInitials(user: User | null): string;
// (undocumented)
getUserPhoneNumber(user: User | null): string;
// (undocumented)
overrideShift(scheduleId: number, userId: number, start: string, end: string): Promise<Schedule>;
// (undocumented)
pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
// (undocumented)
resolveIncident(incident: Incident, userName: string): Promise<Incident>;
// (undocumented)
resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
// (undocumented)
triggerIncidentAction(incident: Incident, action: IncidentAction): Promise<void>;
}
// @public (undocumented)
export const ilertApiRef: ApiRef<ILertApi>;
// @public (undocumented)
export const ILertCard: () => JSX.Element;
// @public (undocumented)
export class ILertClient implements ILertApi {
constructor(opts: Options);
// (undocumented)
acceptIncident(incident: Incident, userName: string): Promise<Incident>;
// (undocumented)
addImmediateMaintenance(alertSourceId: number, minutes: number): Promise<void>;
// (undocumented)
assignIncident(incident: Incident, responder: IncidentResponder): Promise<Incident>;
// (undocumented)
createIncident(eventRequest: EventRequest): Promise<boolean>;
// (undocumented)
disableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
// (undocumented)
enableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
// (undocumented)
fetchAlertSource(idOrIntegrationKey: number | string): Promise<AlertSource>;
// (undocumented)
fetchAlertSourceOnCalls(alertSource: AlertSource): Promise<OnCall[]>;
// (undocumented)
fetchAlertSources(): Promise<AlertSource[]>;
// (undocumented)
fetchIncident(id: number): Promise<Incident>;
// (undocumented)
fetchIncidentActions(incident: Incident): Promise<IncidentAction[]>;
// (undocumented)
fetchIncidentResponders(incident: Incident): Promise<IncidentResponder[]>;
// (undocumented)
fetchIncidents(opts?: GetIncidentsOpts): Promise<Incident[]>;
// (undocumented)
fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise<number>;
// (undocumented)
fetchOnCallSchedules(): Promise<Schedule[]>;
// (undocumented)
fetchUptimeMonitor(id: number): Promise<UptimeMonitor>;
// (undocumented)
fetchUptimeMonitors(): Promise<UptimeMonitor[]>;
// (undocumented)
fetchUsers(): Promise<User[]>;
// (undocumented)
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): ILertClient;
// (undocumented)
getAlertSourceDetailsURL(alertSource: AlertSource | null): string;
// (undocumented)
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string;
// (undocumented)
getIncidentDetailsURL(incident: Incident): string;
// (undocumented)
getScheduleDetailsURL(schedule: Schedule): string;
// (undocumented)
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string;
// (undocumented)
getUserInitials(user: User | null): string;
// (undocumented)
getUserPhoneNumber(user: User | null): string;
// (undocumented)
overrideShift(scheduleId: number, userId: number, start: string, end: string): Promise<Schedule>;
// (undocumented)
pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
// (undocumented)
resolveIncident(incident: Incident, userName: string): Promise<Incident>;
// (undocumented)
resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
// (undocumented)
triggerIncidentAction(incident: Incident, action: IncidentAction): Promise<void>;
}
// @public (undocumented)
export const ILertIcon: IconComponent;
// @public (undocumented)
export const ILertPage: () => JSX.Element;
// @public (undocumented)
const ilertPlugin: BackstagePlugin<{
root: RouteRef<undefined>;
}, {}>;
export { ilertPlugin }
export { ilertPlugin as plugin }
// @public (undocumented)
export const iLertRouteRef: RouteRef<undefined>;
// @public (undocumented)
const isPluginApplicableToEntity: (entity: Entity) => boolean;
export { isPluginApplicableToEntity as isILertAvailable }
export { isPluginApplicableToEntity }
// @public (undocumented)
export const Router: () => JSX.Element;
// @public (undocumented)
export type TableState = {
page: number;
pageSize: number;
};
// (No @packageDocumentation comment for this package)
```

Some files were not shown because too many files have changed in this diff Show More