Merge branch 'backstage:master' into master
This commit is contained in:
@@ -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>
|
||||
);
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Make the `create-github-app` command disable webhooks by default.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -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`.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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, '"');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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';
|
||||
+22
-15
@@ -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>
|
||||
);
|
||||
+21
-10
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -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
Reference in New Issue
Block a user