diff --git a/.changeset/cuddly-donuts-whisper.md b/.changeset/cuddly-donuts-whisper.md new file mode 100644 index 0000000000..fd1b665b99 --- /dev/null +++ b/.changeset/cuddly-donuts-whisper.md @@ -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 ( + + + + + + + + + ); +}; + +export const explorePage = ; +``` + +Now register the new explore page in `packages/app/src/App.tsx`. + +```diff ++ import { explorePage } from './components/explore/ExplorePage'; + +const routes = ( + +- } /> ++ }> ++ {explorePage} ++ + +); +``` diff --git a/.changeset/few-windows-whisper.md b/.changeset/few-windows-whisper.md new file mode 100644 index 0000000000..565e8798a1 --- /dev/null +++ b/.changeset/few-windows-whisper.md @@ -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 diff --git a/.changeset/odd-humans-exercise.md b/.changeset/odd-humans-exercise.md new file mode 100644 index 0000000000..136ec86b69 --- /dev/null +++ b/.changeset/odd-humans-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Make the `create-github-app` command disable webhooks by default. diff --git a/.changeset/popular-rice-wonder.md b/.changeset/popular-rice-wonder.md new file mode 100644 index 0000000000..f815ad949a --- /dev/null +++ b/.changeset/popular-rice-wonder.md @@ -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`. diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md new file mode 100644 index 0000000000..fa2839aad2 --- /dev/null +++ b/.changeset/seven-adults-act.md @@ -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 diff --git a/.changeset/techdocs-cool-rivers-suffer.md b/.changeset/techdocs-cool-rivers-suffer.md new file mode 100644 index 0000000000..2386d27525 --- /dev/null +++ b/.changeset/techdocs-cool-rivers-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Refactor the implicit logic from `` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend. diff --git a/.changeset/techdocs-poor-forks-repeat.md b/.changeset/techdocs-poor-forks-repeat.md new file mode 100644 index 0000000000..89c4e0dc5a --- /dev/null +++ b/.changeset/techdocs-poor-forks-repeat.md @@ -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`). diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md new file mode 100644 index 0000000000..814ff63729 --- /dev/null +++ b/docs/auth/identity-resolver.md @@ -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 { + 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 { + 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 { + 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, + } + }; + } + }) + } + }) +} +``` diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 70541b85db..2189b7d799 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -34,9 +34,8 @@ More specifically, the Service Catalog enables two main use-cases: ## Getting Started The Software Catalog is available to browse at `/catalog`. If you've followed -[Installing in your Backstage App](./installation.md) in your separate App or -[Getting Started with Backstage](../../getting-started) for this repo, you -should be able to browse the catalog at `http://localhost:3000`. +[Getting Started with Backstage](../../getting-started), you should be able to +browse the catalog at `http://localhost:3000`. ![](../../assets/software-catalog/service-catalog-home.png) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md deleted file mode 100644 index 0b622fb093..0000000000 --- a/docs/features/software-catalog/installation.md +++ /dev/null @@ -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 -} /> -}> - {/* - 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 - */} - - -``` - -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 -; -``` - -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! diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md new file mode 100644 index 0000000000..cdd8166889 --- /dev/null +++ b/docs/features/software-templates/configuration.md @@ -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 +``` diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 12ce6e3ed3..1434d62a29 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -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`. diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md deleted file mode 100644 index d5643ca955..0000000000 --- a/docs/features/software-templates/installation.md +++ /dev/null @@ -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 -} />; -``` - -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 -; -``` - -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= 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 -``` diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index b9340330a8..3fdc942ac4 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -28,10 +28,10 @@ scratch. ### Use the documentation template Your working Backstage instance should by default have a documentation template -added. If not, follow these -[instructions](../software-templates/installation.md#adding-templates) to add -the documentation template. The template creates a component with only TechDocs -configuration and default markdown files as below mentioned in manual +added. If not, copy the catalog locations from the +[create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs) +to add the documentation template. The template creates a component with only +TechDocs configuration and default markdown files as below mentioned in manual documentation setup, and is otherwise empty. ![Documentation Template](../../assets/techdocs/documentation-template.png) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 87d23b45d5..805e321093 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -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`. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f2ce425307..e0add7a007 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -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", diff --git a/mkdocs.yml b/mkdocs.yml index 67b97b6ffb..4b2b29a997 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/packages/app/package.json b/packages/app/package.json index 13b90d1af5..9d22f6bf28 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b14f9a3163..cc65cd65b3 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 = ( /> } /> } /> + } /> } /> } /> diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 0dab9670de..68644bef87 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -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; // @public (undocumented) @@ -326,7 +326,7 @@ export type LocationSpec = { presence?: 'optional' | 'required'; }; -// @public (undocumented) +// @public @deprecated (undocumented) export const locationSpecSchema: yup.ObjectSchema; // @public (undocumented) diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index 4d2e602862..3a2fee5089 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -17,6 +17,7 @@ import * as yup from 'yup'; import { LocationSpec, Location } from './types'; +/** @deprecated */ export const locationSpecSchema = yup .object({ type: yup.string().required(), @@ -26,6 +27,7 @@ export const locationSpecSchema = yup .noUnknown() .required(); +/** @deprecated */ export const locationSchema = yup .object({ id: yup.string().required(), @@ -35,6 +37,7 @@ export const locationSchema = yup .noUnknown() .required(); +/** @deprecated */ export const analyzeLocationSchema = yup .object<{ location: LocationSpec }>({ location: locationSpecSchema, diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts index 45671c2ead..0ffc1a08ff 100644 --- a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts +++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts @@ -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, '"'); diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index b85c9392d7..5aa709121a 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -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; diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index a5ba5c4bca..ecf06955b0 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -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 diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts index bec176fda8..654c547fca 100644 --- a/packages/core-app-api/src/index.test.ts +++ b/packages/core-app-api/src/index.test.ts @@ -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), diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx new file mode 100644 index 0000000000..e20af76ae1 --- /dev/null +++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx @@ -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 }) => ( + + {children} + +); + +describe('FeatureFlagged', () => { + describe('with', () => { + it('should render contents when the feature flag is enabled', async () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => true); + + const { queryByText } = render( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + 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( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + 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( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + 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( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + expect(await queryByText('BACKSTAGE!')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx new file mode 100644 index 0000000000..40ef445f92 --- /dev/null +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -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); diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx index a9b83d1016..3ed5d5eb3a 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -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 }) => ( + + {children} + +); + function makeRouteRenderer(node: ReactNode) { let rendered: RenderResult | undefined = undefined; return (path: string) => { const content = ( - ({ - NotFoundErrorPage: () => <>Not Found, - }), - } as unknown) as AppContext - } - > - - + + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown) as AppContext + } + > + + + ); if (rendered) { rendered.unmount(); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index 54ef092965..ad1f764e6c 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -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(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({ diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index e940a2cada..b9ca45f54a 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -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(), + (acc, node) => { + if (node.type === FeatureFlagged) { + const props = node.props as FeatureFlaggedProps; + acc.add('with' in props ? props.with : props.without); + } + }, +); diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts index 7982333f4b..b37b51e919 100644 --- a/packages/core-app-api/src/routing/index.ts +++ b/packages/core-app-api/src/routing/index.ts @@ -15,3 +15,5 @@ */ export { FlatRoutes } from './FlatRoutes'; +export { FeatureFlagged } from './FeatureFlagged'; +export type { FeatureFlaggedProps } from './FeatureFlagged'; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index a6f196847a..90ea1f5e46 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -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; +// @public +export interface ElementCollection { + findComponentData(query: { + key: string; + }): T[]; + getElements(): Array>; + 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(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T; + // @public (undocumented) export type UserFlags = {}; diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 816727ed9e..48706a5877 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -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", diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 26a0c597b1..0ee1447b4e 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -20,3 +20,5 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; +export { useElementFilter } from './useElementFilter'; +export type { ElementCollection } from './useElementFilter'; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx new file mode 100644 index 0000000000..73fbb9e5a3 --- /dev/null +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -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 }) => ( + + {children} + +); + +describe('useElementFilter', () => { + it('should select elements based on a component data key', () => { + const tree = ( + + + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + + + + ); + + 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 = ( + + + + + + + + + + + + + + ); + + 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 = ( + +

Hello

+
+ ); + + 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 = ( + <> + + <> + + + + + + + + hello my name + <> + + + + + + is text + + + + ); + + 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); + }); +}); diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx new file mode 100644 index 0000000000..0549472ab2 --- /dev/null +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -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) => boolean, + strictError?: string, +): Array> { + 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(query: { key: string }): T[]; + + /** + * Returns all of the elements currently selected by this collection. + */ + getElements(): Array< + ReactElement + >; +} + +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(query: { key: string }): T[] { + const selection = selectChildren( + this.node, + this.featureFlagsApi, + node => getComponentData(node, query.key) !== undefined, + ); + return selection + .map(node => getComponentData(node, query.key)) + .filter((data: T | undefined): data is T => data !== undefined); + } + + getElements(): Array< + ReactElement + > { + return selectChildren(this.node, this.featureFlagsApi) as Array< + ReactElement + >; + } +} + +/** + * 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( + 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]); +} diff --git a/packages/core-plugin-api/src/index.test.ts b/packages/core-plugin-api/src/index.test.ts index 9414a6b730..5d29ead619 100644 --- a/packages/core-plugin-api/src/index.test.ts +++ b/packages/core-plugin-api/src/index.test.ts @@ -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), diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index a1df0f294c..4658e413c1 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -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 diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 95add27d6a..0eb5d9e29c 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -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" diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md new file mode 100644 index 0000000000..5a09cfceea --- /dev/null +++ b/plugins/api-docs/api-report.md @@ -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; + +// @public (undocumented) +const apiDocsPlugin: BackstagePlugin<{ + root: RouteRef; +}, { + createComponent: ExternalRouteRef; +}>; + +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) + +``` diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md new file mode 100644 index 0000000000..4f59102265 --- /dev/null +++ b/plugins/app-backend/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + appPackageName: string; + // (undocumented) + config: Config; + disableConfigInjection?: boolean; + // (undocumented) + logger: Logger; + staticFallbackHandler?: express.Handler; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md new file mode 100644 index 0000000000..dea5500d53 --- /dev/null +++ b/plugins/auth-backend/api-report.md @@ -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; + logout?(req: express.Request, res: express.Response): Promise; + refresh?(req: express.Request, res: express.Response): Promise; + start(req: express.Request, res: express.Response): Promise; +} + +// @public (undocumented) +export type AuthResponse = { + 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; + +// @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; + 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; + // (undocumented) + static fromConfig(config: AuthProviderConfig, handlers: OAuthHandlers, options: Pick): OAuthAdapter; + // (undocumented) + logout(req: express.Request, res: express.Response): Promise; + // (undocumented) + refresh(req: express.Request, res: express.Response): Promise; + // (undocumented) + start(req: express.Request, res: express.Response): Promise; +} + +// @public (undocumented) +export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { + constructor(handlers: Map); + // (undocumented) + frameHandler(req: express.Request, res: express.Response): Promise; + // (undocumented) + logout(req: express.Request, res: express.Response): Promise; + // (undocumented) + static mapConfig(config: Config, factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers): OAuthEnvironmentHandler; + // (undocumented) + refresh(req: express.Request, res: express.Response): Promise; + // (undocumented) + start(req: express.Request, res: express.Response): Promise; +} + +// @public +export interface OAuthHandlers { + handler(req: express.Request): Promise<{ + response: AuthResponse; + refreshToken?: string; + }>; + logout?(): Promise; + refresh?(req: OAuthRefreshRequest): Promise>; + start(req: OAuthStartRequest): Promise; +} + +// @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; + +// @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; +} | { + type: 'authorization_response'; + error: Error; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 1c6192d5c5..ac8f974cdb 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -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, }); diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 6e984d41cc..2080b08cb6 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -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[]; }; }; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 0ec65b7a0b..57c2b3345b 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -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 = { + 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', + }, + }); }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index abd3624df2..947b8dac1f 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -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; @@ -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 { + async findUser(query: UserQuery): Promise { const filter: Record = { 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) { diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts new file mode 100644 index 0000000000..c60198dc98 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -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], + }; +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts index f15469668f..fbd58081e3 100644 --- a/plugins/auth-backend/src/lib/catalog/index.ts +++ b/plugins/auth-backend/src/lib/catalog/index.ts @@ -15,3 +15,4 @@ */ export { CatalogIdentityClient } from './CatalogIdentityClient'; +export { getEntityClaims } from './helpers'; diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts index 2615a2d8e5..8bff8b250b 100644 --- a/plugins/auth-backend/src/providers/google/index.ts +++ b/plugins/auth-backend/src/providers/google/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { createGoogleProvider } from './provider'; +export { + createGoogleProvider, + googleDefaultSignInResolver, + googleEmailSignInResolver, +} from './provider'; export type { GoogleProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts new file mode 100644 index 0000000000..45df5bd319 --- /dev/null +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -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', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a714674447..ace0c8e132 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -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; + authHandler: AuthHandler; 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; + private readonly authHandler: AuthHandler; 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 { - 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 = 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 = 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; + + /** + * 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; + }; +}; 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 = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolverFn = + options?.signIn?.resolver ?? googleDefaultSignInResolver; + + const signInResolver: SignInResolver = 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, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 3f3d16f28f..eedc79cf09 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -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 diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 700af3ebef..e4bbaa091c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -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 = { 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 = { + /** + * 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 = ( + info: SignInInfo, + context: { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; + }, +) => Promise; + +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 = ( + input: AuthResult, +) => Promise; diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md new file mode 100644 index 0000000000..1523d685b4 --- /dev/null +++ b/plugins/badges-backend/api-report.md @@ -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; + createBadgeJson(options: BadgeOptions): Promise; + createBadgeSvg(options: BadgeOptions): Promise; +}; + +// @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; + +// @public (undocumented) +export class DefaultBadgeBuilder implements BadgeBuilder { + constructor(factories: BadgeFactories); + // (undocumented) + createBadgeJson(options: BadgeOptions): Promise; + // (undocumented) + createBadgeSvg(options: BadgeOptions): Promise; + // (undocumented) + getBadges(): Promise; + } + +// @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) + +``` diff --git a/plugins/badges/api-report.md b/plugins/badges/api-report.md new file mode 100644 index 0000000000..dfe7cbdae8 --- /dev/null +++ b/plugins/badges/api-report.md @@ -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) + +``` diff --git a/plugins/bitrise/api-report.md b/plugins/bitrise/api-report.md new file mode 100644 index 0000000000..c07178111d --- /dev/null +++ b/plugins/bitrise/api-report.md @@ -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) + +``` diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 2f06b9c062..ce97bb7a54 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -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. diff --git a/plugins/catalog-graphql/api-report.md b/plugins/catalog-graphql/api-report.md new file mode 100644 index 0000000000..cbe325f4c5 --- /dev/null +++ b/plugins/catalog-graphql/api-report.md @@ -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; + +// @public (undocumented) +export interface ModuleOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md new file mode 100644 index 0000000000..34baed46b5 --- /dev/null +++ b/plugins/catalog-import/api-report.md @@ -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: ({ name, options, required, control, errors, rules, loading, loadingText, helperText, errorHelperText, textFieldProps, }: Props_4) => JSX.Element; + +// @public (undocumented) +export interface CatalogImportApi { + // (undocumented) + analyzeUrl(url: string): Promise; + // (undocumented) + submitPullRequest(options: { + repositoryUrl: string; + fileContent: string; + title: string; + body: string; + }): Promise<{ + link: string; + location: string; + }>; +} + +// @public (undocumented) +export const catalogImportApiRef: ApiRef; + +// @public (undocumented) +export class CatalogImportClient implements CatalogImportApi { + constructor(options: { + discoveryApi: DiscoveryApi; + githubAuthApi: OAuthApi; + identityApi: IdentityApi; + scmIntegrationsApi: ScmIntegrationRegistry; + catalogApi: CatalogApi; + }); + // (undocumented) + analyzeUrl(url: string): Promise; + // (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; +}, {}>; + +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: >({ defaultValues, onSubmit, render, }: Props_5) => 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) + +``` diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md index 611d2989e8..8527c8da7c 100644 --- a/plugins/catalog/README.md +++ b/plugins/catalog/README.md @@ -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'; + + ++ } /> ++ }> ++ {/* ++ 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} ++ + ... + +``` + +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<{}>) => ( + + ++ + ... + +``` + +## 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. diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 4e35cc7618..0af476b95a 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index dfd27631f8..aceb1da6f2 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -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; }; +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 = ( - -
- - ).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() // 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, diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 20b9aaba4f..5b21266d44 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -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 }) => ( + + {children} + +); describe('EntitySwitch', () => { it('should switch child when entity switches', () => { @@ -32,15 +45,17 @@ describe('EntitySwitch', () => { ); const rendered = render( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).toBeInTheDocument(); @@ -48,15 +63,17 @@ describe('EntitySwitch', () => { expect(rendered.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); @@ -64,15 +81,17 @@ describe('EntitySwitch', () => { expect(rendered.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); @@ -88,24 +107,28 @@ describe('EntitySwitch', () => { }; const rendered = render( - - - - - - , + + + + + + + + , ); expect(rendered.queryByText('A')).toBeInTheDocument(); expect(rendered.queryByText('B')).not.toBeInTheDocument(); rendered.rerender( - - - - - - , + + + + + + + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index a3ede42288..4b12a7249b 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -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((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, diff --git a/plugins/circleci/api-report.md b/plugins/circleci/api-report.md new file mode 100644 index 0000000000..6c774cc6b0 --- /dev/null +++ b/plugins/circleci/api-report.md @@ -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): Promise; + // (undocumented) + getBuilds({ limit, offset }: { + limit: number; + offset: number; + }, options: Partial): Promise; + // (undocumented) + getUser(options: Partial): Promise; + // (undocumented) + retry(buildNumber: number, options: Partial): Promise; +} + +// @public (undocumented) +export const circleCIApiRef: ApiRef; + +// @public (undocumented) +export const circleCIBuildRouteRef: RouteRef; + +// @public (undocumented) +const circleCIPlugin: BackstagePlugin<{}, {}>; + +export { circleCIPlugin } + +export { circleCIPlugin as plugin } + +// @public (undocumented) +export const circleCIRouteRef: RouteRef; + +// @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) + +``` diff --git a/plugins/cloudbuild/api-report.md b/plugins/cloudbuild/api-report.md new file mode 100644 index 0000000000..58dc59c175 --- /dev/null +++ b/plugins/cloudbuild/api-report.md @@ -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; + getWorkflow: ({ projectId, id, }: { + projectId: string; + id: string; + }) => Promise; + getWorkflowRun: ({ projectId, id, }: { + projectId: string; + id: string; + }) => Promise; + reRunWorkflow: ({ projectId, runId, }: { + projectId: string; + runId: string; + }) => Promise; +}; + +// @public (undocumented) +export const cloudbuildApiRef: ApiRef; + +// @public (undocumented) +export class CloudbuildClient implements CloudbuildApi { + constructor(googleAuthApi: OAuthApi); + // (undocumented) + getToken(): Promise; + // (undocumented) + getWorkflow({ projectId, id, }: { + projectId: string; + id: string; + }): Promise; + // (undocumented) + getWorkflowRun({ projectId, id, }: { + projectId: string; + id: string; + }): Promise; + // (undocumented) + listWorkflowRuns({ projectId, }: { + projectId: string; + }): Promise; + // (undocumented) + reRunWorkflow({ projectId, runId, }: { + projectId: string; + runId: string; + }): Promise; +} + +// @public (undocumented) +const cloudbuildPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +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) + +``` diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md new file mode 100644 index 0000000000..f8f1575d0b --- /dev/null +++ b/plugins/code-coverage-backend/api-report.md @@ -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; + +// @public (undocumented) +export const makeRouter: (options: RouterOptions) => Promise; + +// @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) + +``` diff --git a/plugins/code-coverage/api-report.md b/plugins/code-coverage/api-report.md new file mode 100644 index 0000000000..bade5ddb8a --- /dev/null +++ b/plugins/code-coverage/api-report.md @@ -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; +}, {}>; + +// @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) + +``` diff --git a/plugins/config-schema/api-report.md b/plugins/config-schema/api-report.md new file mode 100644 index 0000000000..67497be6b6 --- /dev/null +++ b/plugins/config-schema/api-report.md @@ -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; +} + +// @public (undocumented) +export const configSchemaApiRef: ApiRef; + +// @public (undocumented) +export const ConfigSchemaPage: () => JSX.Element; + +// @public (undocumented) +export const configSchemaPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +// @public +export class StaticSchemaLoader implements ConfigSchemaApi { + constructor({ url }?: { + url?: string; + }); + // (undocumented) + schema$(): Observable; + } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/cost-insights/api-report.md b/plugins/cost-insights/api-report.md new file mode 100644 index 0000000000..7476f75cad --- /dev/null +++ b/plugins/cost-insights/api-report.md @@ -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; + AcceptForm?: Maybe; + DismissForm?: Maybe; + onSnoozed?(options: AlertOptions): Promise; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; +}; + +// @public (undocumented) +export interface AlertCost { + // (undocumented) + aggregation: [number, number]; + // (undocumented) + id: string; +} + +// @public (undocumented) +export interface AlertDismissFormData { + // (undocumented) + feedback: Maybe; + // (undocumented) + other: Maybe; + // (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 = ForwardRefExoticComponent & RefAttributes>; + +// @public (undocumented) +export type AlertFormProps = { + alert: A; + onSubmit: (data: FormData) => void; + disableSubmit: (isDisabled: boolean) => void; +}; + +// @public (undocumented) +export interface AlertOptions { + // (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) => 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; +}; + +// @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; + tooltip?: ContentRenderer; + onClick?: RechartsFunction; + onMouseMove?: RechartsFunction; +}; + +// @public (undocumented) +export const BarChartTooltip: ({ title, content, subtitle, topRight, actions, children, }: PropsWithChildren) => 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; + // (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; +}; + +// @public (undocumented) +export type CostGrowthProps = { + change: ChangeStatistic; + duration: Duration; +}; + +// @public (undocumented) +export type CostInsightsApi = { + getLastCompleteBillingDate(): Promise; + getUserGroups(userId: string): Promise; + getGroupProjects(group: string): Promise; + getGroupDailyCost(group: string, intervals: string): Promise; + getProjectDailyCost(project: string, intervals: string): Promise; + getDailyMetricData(metric: string, intervals: string): Promise; + getProductInsights(options: ProductInsightsOptions): Promise; + getAlerts(group: string): Promise; +}; + +// @public (undocumented) +export const costInsightsApiRef: ApiRef; + +// @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; + growthAlerts: RouteRef; + unlabeledDataflowAlerts: RouteRef; +}, {}>; + +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; + // (undocumented) + id: Maybe; +} + +// @public (undocumented) +export class ExampleCostInsightsClient implements CostInsightsApi { + // (undocumented) + getAlerts(group: string): Promise; + // (undocumented) + getDailyMetricData(metric: string, intervals: string): Promise; + // (undocumented) + getGroupDailyCost(group: string, intervals: string): Promise; + // (undocumented) + getGroupProjects(group: string): Promise; + // (undocumented) + getLastCompleteBillingDate(): Promise; + // (undocumented) + getProductInsights(options: ProductInsightsOptions): Promise; + // (undocumented) + getProjectDailyCost(project: string, intervals: string): Promise; + // (undocumented) + getUserGroups(userId: string): Promise; + } + +// @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) => JSX.Element; + +// @public (undocumented) +export type LegendItemProps = { + title: string; + tooltipText?: string; + markerColor?: string; +}; + +// @public (undocumented) +export type Loading = Record; + +// @public (undocumented) +export type Maybe = 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; + // (undocumented) + metric: string | null; + // (undocumented) + project: Maybe; +} + +// @public (undocumented) +export interface Product { + // (undocumented) + kind: string; + // (undocumented) + name: string; +} + +// @public (undocumented) +export type ProductFilters = Array; + +// @public (undocumented) +export type ProductInsightsOptions = { + product: string; + group: string; + intervals: string; + project: Maybe; +}; + +// @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; + // (undocumented) + project: string; +} + +// @public (undocumented) +export interface ResourceData { + // (undocumented) + current: number; + // (undocumented) + name: Maybe; + // (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; + // (undocumented) + unlabeledCost: number; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/explore-react/api-report.md b/plugins/explore-react/api-report.md new file mode 100644 index 0000000000..aaf272a06a --- /dev/null +++ b/plugins/explore-react/api-report.md @@ -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; +} + +// @public (undocumented) +export const exploreToolsConfigRef: ApiRef; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/explore/README.md b/plugins/explore/README.md index fea1333e34..8e1cbaed02 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers'; ``` + +## 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 ( + + + + + + + + + ); +}; + +export const explorePage = ; +``` + +Now register the new explore page in `packages/app/src/App.tsx`. + +```diff ++ import { explorePage } from './components/explore/ExplorePage'; + +const routes = ( + +- } /> ++ }> ++ {explorePage} ++ + +); +``` diff --git a/plugins/explore/api-report.md b/plugins/explore/api-report.md new file mode 100644 index 0000000000..590af53de8 --- /dev/null +++ b/plugins/explore/api-report.md @@ -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; +}, { + catalogEntity: ExternalRouteRef<{ + name: string; + kind: string; + namespace: string; + }, false>; +}>; + +// @public (undocumented) +export const exploreRouteRef: RouteRef; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 746d8fc1ce..156a3f621e 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -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" }, diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx new file mode 100644 index 0000000000..8f69ea3527 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -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('', () => { + const catalogApi: jest.Mocked = { + 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 }) => ( + + {children} + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders the default explore page', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getAllByRole } = await renderInTestApp( + + + , + ); + + 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(); + }); + }); +}); diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx new file mode 100644 index 0000000000..abc73dca31 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx @@ -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 ( + + + + + + + + + + + + ); +}; diff --git a/plugins/explore/src/components/DefaultExplorePage/index.ts b/plugins/explore/src/components/DefaultExplorePage/index.ts new file mode 100644 index 0000000000..b4df272266 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/index.ts @@ -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'; diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index 1784587a95..1a93463862 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -41,6 +41,12 @@ describe('', () => { ); + const mountedRoutes = { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }; + beforeEach(() => { jest.resetAllMocks(); }); @@ -74,11 +80,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => { @@ -87,6 +89,19 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument()); + }); + it('renders empty state', async () => { catalogApi.getEntities.mockResolvedValue({ items: [] }); @@ -94,11 +109,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => @@ -114,11 +125,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx index a12811e486..8217413842 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -79,10 +79,16 @@ const Body = () => { ); }; -export const DomainExplorerContent = () => { +type DomainExplorerContentProps = { + title?: string; +}; + +export const DomainExplorerContent = ({ + title, +}: DomainExplorerContentProps) => { return ( - + Discover the domains in your ecosystem. diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx new file mode 100644 index 0000000000..1021ff25d0 --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ExploreLayout } from './ExploreLayout'; + +describe('', () => { + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders an explore tabbed layout page with defaults', async () => { + const { getByText } = await renderInTestApp( + + + +
Tools Content
+
+
+
, + ); + + 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( + + + +
Tools Content
+
+
+
, + ); + + await waitFor(() => + expect(getByText('Explore our universe')).toBeInTheDocument(), + ); + }); + + it('renders a custom page subtitle', async () => { + const { getByText } = await renderInTestApp( + + + +
Tools Content
+
+
+
, + ); + + await waitFor(() => + expect(getByText('Browse the ACME Corp ecosystem')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx new file mode 100644 index 0000000000..7e64ed815c --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx @@ -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; +}; + +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 = ( + +
+ + ).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 + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const ExploreLayout = ({ + title, + subtitle, + children, +}: ExploreLayoutProps) => { + const routes = createSubRoutesFromChildren(children); + + return ( + +
+ + + ); +}; + +ExploreLayout.Route = Route; diff --git a/plugins/explore/src/components/ExploreLayout/index.ts b/plugins/explore/src/components/ExploreLayout/index.ts new file mode 100644 index 0000000000..6cbae79a71 --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/index.ts @@ -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'; diff --git a/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx new file mode 100644 index 0000000000..9034ee72c0 --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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(); + + 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(); + + expect(getByText('DefaultExplorePageMock')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/ExplorePage/ExplorePage.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx index 3f9394a1ab..e3601614d2 100644 --- a/plugins/explore/src/components/ExplorePage/ExplorePage.tsx +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx @@ -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 ( - -
+ const outlet = useOutlet(); - - - ); + return <>{outlet || }; }; diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx deleted file mode 100644 index 0415dd97ae..0000000000 --- a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx +++ /dev/null @@ -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 = () => ( - - - - - - - - - - - -); diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 38c4678d5a..4e92784818 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -40,6 +40,12 @@ describe('', () => { ); + const mountedRoutes = { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }; + beforeEach(() => { jest.resetAllMocks(); @@ -69,11 +75,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => { @@ -81,6 +83,19 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + 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('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx index c4d46206e4..bf2363f16c 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx @@ -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 ( - + Explore your groups. diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx index 125a72eb12..5cc444ace5 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -80,6 +80,18 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => expect(getByText('Our Tools')).toBeInTheDocument()); + }); + it('renders empty state', async () => { exploreToolsConfigApi.getTools.mockResolvedValue([]); diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx index e00606eeb2..8dcf1957d0 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx @@ -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) => ( - + Discover the tools in your ecosystem. diff --git a/plugins/explore/src/components/index.ts b/plugins/explore/src/components/index.ts new file mode 100644 index 0000000000..6cbae79a71 --- /dev/null +++ b/plugins/explore/src/components/index.ts @@ -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'; diff --git a/plugins/explore/src/extensions.tsx b/plugins/explore/src/extensions.tsx index cdf43d3035..88ea2561fa 100644 --- a/plugins/explore/src/extensions.tsx +++ b/plugins/explore/src/extensions.tsx @@ -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, + ), + }, + }), +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index 70a00f5bbb..bee46a31ec 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -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'; diff --git a/plugins/fossa/api-report.md b/plugins/fossa/api-report.md new file mode 100644 index 0000000000..edb2cfdd0a --- /dev/null +++ b/plugins/fossa/api-report.md @@ -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; +}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/gcp-projects/api-report.md b/plugins/gcp-projects/api-report.md new file mode 100644 index 0000000000..22812822c0 --- /dev/null +++ b/plugins/gcp-projects/api-report.md @@ -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; + getProject(projectId: string): Promise; + createProject(options: { + projectId: string; + projectName: string; + }): Promise; +}; + +// @public (undocumented) +export const gcpApiRef: ApiRef; + +// @public (undocumented) +export class GcpClient implements GcpApi { + constructor(googleAuthApi: OAuthApi); + // (undocumented) + createProject(options: { + projectId: string; + projectName: string; + }): Promise; + // (undocumented) + getProject(projectId: string): Promise; + // (undocumented) + getToken(): Promise; + // (undocumented) + listProjects(): Promise; +} + +// @public (undocumented) +export const GcpProjectsPage: () => JSX.Element; + +// @public (undocumented) +const gcpProjectsPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +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) + +``` diff --git a/plugins/git-release-manager/api-report.md b/plugins/git-release-manager/api-report.md new file mode 100644 index 0000000000..d898f83dd6 --- /dev/null +++ b/plugins/git-release-manager/api-report.md @@ -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; + +// @public (undocumented) +export const GitReleaseManagerPage: GitReleaseManager; + +// @public (undocumented) +export const gitReleaseManagerPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/github-actions/api-report.md b/plugins/github-actions/api-report.md new file mode 100644 index 0000000000..1795bbdbea --- /dev/null +++ b/plugins/github-actions/api-report.md @@ -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; + getWorkflow: ({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }) => Promise; + getWorkflowRun: ({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }) => Promise; + reRunWorkflow: ({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }) => Promise; + listJobsForWorkflowRun: ({ hostname, owner, repo, id, pageSize, page, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + pageSize?: number; + page?: number; + }) => Promise; + downloadJobLogsForWorkflowRun: ({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }) => Promise; +}; + +// @public (undocumented) +export const githubActionsApiRef: ApiRef; + +// @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; + // (undocumented) + getWorkflow({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }): Promise; + // (undocumented) + getWorkflowRun({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }): Promise; + // (undocumented) + listJobsForWorkflowRun({ hostname, owner, repo, id, pageSize, page, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + pageSize?: number; + page?: number; + }): Promise; + // (undocumented) + listWorkflowRuns({ hostname, owner, repo, pageSize, page, branch, }: { + hostname?: string; + owner: string; + repo: string; + pageSize?: number; + page?: number; + branch?: string; + }): Promise; + // (undocumented) + reRunWorkflow({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }): Promise; +} + +// @public (undocumented) +const githubActionsPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +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) + +``` diff --git a/plugins/gitops-profiles/api-report.md b/plugins/gitops-profiles/api-report.md new file mode 100644 index 0000000000..aad08f2b43 --- /dev/null +++ b/plugins/gitops-profiles/api-report.md @@ -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; + // (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; + changeClusterState(req: ChangeClusterStateRequest): Promise; + cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; + applyProfiles(req: ApplyProfileRequest): Promise; + listClusters(req: ListClusterRequest): Promise; + fetchUserInfo(req: GithubUserInfoRequest): Promise; +}; + +// @public (undocumented) +export const gitOpsApiRef: ApiRef; + +// @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; + detailsPage: RouteRef<{ + owner: string; + repo: string; + }>; + createPage: RouteRef; +}, {}>; + +export { gitopsProfilesPlugin } + +export { gitopsProfilesPlugin as plugin } + +// @public (undocumented) +export class GitOpsRestApi implements GitOpsApi { + constructor(url?: string); + // (undocumented) + applyProfiles(req: ApplyProfileRequest): Promise; + // (undocumented) + changeClusterState(req: ChangeClusterStateRequest): Promise; + // (undocumented) + cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; + // (undocumented) + fetchLog(req: PollLogRequest): Promise; + // (undocumented) + fetchUserInfo(req: GithubUserInfoRequest): Promise; + // (undocumented) + listClusters(req: ListClusterRequest): Promise; + // (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) + +``` diff --git a/plugins/graphiql/api-report.md b/plugins/graphiql/api-report.md new file mode 100644 index 0000000000..7c73b6a985 --- /dev/null +++ b/plugins/graphiql/api-report.md @@ -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; + +// @public (undocumented) +export type GraphQLBrowseApi = { + getEndpoints(): Promise; +}; + +// @public (undocumented) +export const graphQlBrowseApiRef: ApiRef; + +// @public (undocumented) +export type GraphQLEndpoint = { + id: string; + title: string; + fetcher: (body: any) => Promise; +}; + +// @public (undocumented) +export class GraphQLEndpoints implements GraphQLBrowseApi { + // (undocumented) + static create(config: EndpointConfig): GraphQLEndpoint; + // (undocumented) + static from(endpoints: GraphQLEndpoint[]): GraphQLEndpoints; + // (undocumented) + getEndpoints(): Promise; + static github(config: GithubEndpointConfig): GraphQLEndpoint; +} + +// @public (undocumented) +export const Router: () => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/graphql/api-report.md b/plugins/graphql/api-report.md new file mode 100644 index 0000000000..8109c94122 --- /dev/null +++ b/plugins/graphql/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/ilert/api-report.md b/plugins/ilert/api-report.md new file mode 100644 index 0000000000..f61b43b90b --- /dev/null +++ b/plugins/ilert/api-report.md @@ -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; + // (undocumented) + addImmediateMaintenance(alertSourceId: number, minutes: number): Promise; + // (undocumented) + assignIncident(incident: Incident, responder: IncidentResponder): Promise; + // (undocumented) + createIncident(eventRequest: EventRequest): Promise; + // (undocumented) + disableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + enableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + // (undocumented) + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSources(): Promise; + // (undocumented) + fetchIncident(id: number): Promise; + // (undocumented) + fetchIncidentActions(incident: Incident): Promise; + // (undocumented) + fetchIncidentResponders(incident: Incident): Promise; + // (undocumented) + fetchIncidents(opts?: GetIncidentsOpts): Promise; + // (undocumented) + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + // (undocumented) + fetchOnCallSchedules(): Promise; + // (undocumented) + fetchUptimeMonitor(id: number): Promise; + // (undocumented) + fetchUptimeMonitors(): Promise; + // (undocumented) + fetchUsers(): Promise; + // (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; + // (undocumented) + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + resolveIncident(incident: Incident, userName: string): Promise; + // (undocumented) + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + triggerIncidentAction(incident: Incident, action: IncidentAction): Promise; +} + +// @public (undocumented) +export const ilertApiRef: ApiRef; + +// @public (undocumented) +export const ILertCard: () => JSX.Element; + +// @public (undocumented) +export class ILertClient implements ILertApi { + constructor(opts: Options); + // (undocumented) + acceptIncident(incident: Incident, userName: string): Promise; + // (undocumented) + addImmediateMaintenance(alertSourceId: number, minutes: number): Promise; + // (undocumented) + assignIncident(incident: Incident, responder: IncidentResponder): Promise; + // (undocumented) + createIncident(eventRequest: EventRequest): Promise; + // (undocumented) + disableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + enableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + // (undocumented) + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSources(): Promise; + // (undocumented) + fetchIncident(id: number): Promise; + // (undocumented) + fetchIncidentActions(incident: Incident): Promise; + // (undocumented) + fetchIncidentResponders(incident: Incident): Promise; + // (undocumented) + fetchIncidents(opts?: GetIncidentsOpts): Promise; + // (undocumented) + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + // (undocumented) + fetchOnCallSchedules(): Promise; + // (undocumented) + fetchUptimeMonitor(id: number): Promise; + // (undocumented) + fetchUptimeMonitors(): Promise; + // (undocumented) + fetchUsers(): Promise; + // (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; + // (undocumented) + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + resolveIncident(incident: Incident, userName: string): Promise; + // (undocumented) + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + triggerIncidentAction(incident: Incident, action: IncidentAction): Promise; +} + +// @public (undocumented) +export const ILertIcon: IconComponent; + +// @public (undocumented) +export const ILertPage: () => JSX.Element; + +// @public (undocumented) +const ilertPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { ilertPlugin } + +export { ilertPlugin as plugin } + +// @public (undocumented) +export const iLertRouteRef: RouteRef; + +// @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) + +``` diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md new file mode 100644 index 0000000000..4c88aab70c --- /dev/null +++ b/plugins/jenkins/api-report.md @@ -0,0 +1,83 @@ +## API Report File for "@backstage/plugin-jenkins" + +> 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 { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityJenkinsContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestJenkinsRunCard: ({ branch, variant, }: { + branch: string; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isJenkinsAvailable: (entity: Entity) => boolean; + +export { isJenkinsAvailable } + +export { isJenkinsAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export const JENKINS_ANNOTATION = "jenkins.io/github-folder"; + +// @public (undocumented) +export class JenkinsApi { + constructor(options: Options); + // (undocumented) + extractJobDetailsFromBuildName(buildName: string): { + jobName: string; + buildNumber: number; + }; + // (undocumented) + extractScmDetailsFromJob(jobDetails: any): any | undefined; + // (undocumented) + getBuild(buildName: string): Promise; + // (undocumented) + getFolder(folderName: string): Promise; + // (undocumented) + getJob(jobName: string): Promise; + // (undocumented) + getLastBuild(jobName: string): Promise; + // (undocumented) + mapJenkinsBuildToCITable(jenkinsResult: any, jobScmInfo?: any): CITableBuildInfo; + // (undocumented) + retry(buildName: string): Promise; +} + +// @public (undocumented) +export const jenkinsApiRef: ApiRef; + +// @public (undocumented) +const jenkinsPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { jenkinsPlugin } + +export { jenkinsPlugin as plugin } + +// @public (undocumented) +export const LatestRunCard: ({ branch, variant, }: { + branch: string; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kafka-backend/api-report.md b/plugins/kafka-backend/api-report.md new file mode 100644 index 0000000000..47754ad572 --- /dev/null +++ b/plugins/kafka-backend/api-report.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/plugin-kafka-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; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kafka/api-report.md b/plugins/kafka/api-report.md new file mode 100644 index 0000000000..acb213fd53 --- /dev/null +++ b/plugins/kafka/api-report.md @@ -0,0 +1,41 @@ +## API Report File for "@backstage/plugin-kafka" + +> 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 EntityKafkaContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity as isKafkaAvailable } + +export { isPluginApplicableToEntity } + +// @public (undocumented) +export const KAFKA_CONSUMER_GROUP_ANNOTATION = "kafka.apache.org/consumer-groups"; + +// @public (undocumented) +const kafkaPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { kafkaPlugin } + +export { kafkaPlugin as plugin } + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md new file mode 100644 index 0000000000..fe43e8888a --- /dev/null +++ b/plugins/kubernetes-backend/api-report.md @@ -0,0 +1,103 @@ +## API Report File for "@backstage/plugin-kubernetes-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 { FetchResponse } from '@backstage/plugin-kubernetes-common'; +import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { Logger } from 'winston'; + +// @public (undocumented) +export interface ClusterDetails { + // (undocumented) + authProvider: string; + // (undocumented) + name: string; + // (undocumented) + serviceAccountToken?: string | undefined; + // (undocumented) + skipTLSVerify?: boolean; + // (undocumented) + url: string; +} + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface CustomResource { + // (undocumented) + apiVersion: string; + // (undocumented) + group: string; + // (undocumented) + plural: string; +} + +// @public (undocumented) +export interface FetchResponseWrapper { + // (undocumented) + errors: KubernetesFetchError[]; + // (undocumented) + responses: FetchResponse[]; +} + +// @public (undocumented) +export interface KubernetesClustersSupplier { + // (undocumented) + getClusters(): Promise; +} + +// @public (undocumented) +export interface KubernetesFetcher { + // (undocumented) + fetchObjectsForService(params: ObjectFetchParams): Promise; +} + +// @public (undocumented) +export type KubernetesObjectTypes = 'pods' | 'services' | 'configmaps' | 'deployments' | 'replicasets' | 'horizontalpodautoscalers' | 'ingresses' | 'customresources'; + +// @public (undocumented) +export interface KubernetesServiceLocator { + // (undocumented) + getClustersByServiceId(serviceId: string): Promise; +} + +// @public (undocumented) +export const makeRouter: (logger: Logger, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router; + +// @public (undocumented) +export interface ObjectFetchParams { + // (undocumented) + clusterDetails: ClusterDetails; + // (undocumented) + customResources: CustomResource[]; + // (undocumented) + labelSelector: string; + // (undocumented) + objectTypesToFetch: Set; + // (undocumented) + serviceId: string; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + clusterSupplier?: KubernetesClustersSupplier; + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + +// @public (undocumented) +export type ServiceLocatorMethod = 'multiTenant' | 'http'; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md new file mode 100644 index 0000000000..a8abec517e --- /dev/null +++ b/plugins/kubernetes-common/api-report.md @@ -0,0 +1,130 @@ +## API Report File for "@backstage/plugin-kubernetes-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Entity } from '@backstage/catalog-model'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1ConfigMap } from '@kubernetes/client-node'; +import { V1Deployment } from '@kubernetes/client-node'; +import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { V1Pod } from '@kubernetes/client-node'; +import { V1ReplicaSet } from '@kubernetes/client-node'; +import { V1Service } from '@kubernetes/client-node'; + +// @public (undocumented) +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; + +// @public (undocumented) +export interface ClusterObjects { + // (undocumented) + cluster: { + name: string; + }; + // (undocumented) + errors: KubernetesFetchError[]; + // (undocumented) + resources: FetchResponse[]; +} + +// @public (undocumented) +export interface ConfigMapFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'configmaps'; +} + +// @public (undocumented) +export interface CustomResourceFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'customresources'; +} + +// @public (undocumented) +export interface DeploymentFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'deployments'; +} + +// @public (undocumented) +export type FetchResponse = PodFetchResponse | ServiceFetchResponse | ConfigMapFetchResponse | DeploymentFetchResponse | ReplicaSetsFetchResponse | HorizontalPodAutoscalersFetchResponse | IngressesFetchResponse | CustomResourceFetchResponse; + +// @public (undocumented) +export interface HorizontalPodAutoscalersFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'horizontalpodautoscalers'; +} + +// @public (undocumented) +export interface IngressesFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'ingresses'; +} + +// @public (undocumented) +export type KubernetesErrorTypes = 'BAD_REQUEST' | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; + +// @public (undocumented) +export interface KubernetesFetchError { + // (undocumented) + errorType: KubernetesErrorTypes; + // (undocumented) + resourcePath?: string; + // (undocumented) + statusCode?: number; +} + +// @public (undocumented) +export interface KubernetesRequestBody { + // (undocumented) + auth?: { + google?: string; + }; + // (undocumented) + entity: Entity; +} + +// @public (undocumented) +export interface ObjectsByEntityResponse { + // (undocumented) + items: ClusterObjects[]; +} + +// @public (undocumented) +export interface PodFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'pods'; +} + +// @public (undocumented) +export interface ReplicaSetsFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'replicasets'; +} + +// @public (undocumented) +export interface ServiceFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'services'; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md new file mode 100644 index 0000000000..2f1b5155fd --- /dev/null +++ b/plugins/kubernetes/api-report.md @@ -0,0 +1,46 @@ +## API Report File for "@backstage/plugin-kubernetes" + +> 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 { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { OAuthApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityKubernetesContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { + constructor(options: { + googleAuthApi: OAuthApi; + }); + // (undocumented) + decorateRequestBodyForAuth(authProvider: string, requestBody: KubernetesRequestBody): Promise; + } + +// @public (undocumented) +export const kubernetesAuthProvidersApiRef: ApiRef; + +// @public (undocumented) +const kubernetesPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { kubernetesPlugin } + +export { kubernetesPlugin as plugin } + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/lighthouse/api-report.md b/plugins/lighthouse/api-report.md new file mode 100644 index 0000000000..18d8358257 --- /dev/null +++ b/plugins/lighthouse/api-report.md @@ -0,0 +1,186 @@ +## API Report File for "@backstage/plugin-lighthouse" + +> 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 { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export type Audit = AuditRunning | AuditFailed | AuditCompleted; + +// @public (undocumented) +export interface AuditCompleted extends AuditBase { + // (undocumented) + categories: Record; + // (undocumented) + report: Object; + // (undocumented) + status: 'COMPLETED'; + // (undocumented) + timeCompleted: string; +} + +// @public (undocumented) +export interface AuditFailed extends AuditBase { + // (undocumented) + status: 'FAILED'; + // (undocumented) + timeCompleted: string; +} + +// @public (undocumented) +export interface AuditRunning extends AuditBase { + // (undocumented) + status: 'RUNNING'; +} + +// @public (undocumented) +export const EmbeddedRouter: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityLastLighthouseAuditCard: ({ dense, variant, }: { + dense?: boolean | undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLighthouseContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export class FetchError extends Error { + // (undocumented) + static forResponse(resp: Response): Promise; + // (undocumented) + get name(): string; +} + +// @public (undocumented) +const isLighthouseAvailable: (entity: Entity) => boolean; + +export { isLighthouseAvailable } + +export { isLighthouseAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export interface LASListRequest { + // (undocumented) + limit?: number; + // (undocumented) + offset?: number; +} + +// @public (undocumented) +export interface LASListResponse { + // (undocumented) + items: Item[]; + // (undocumented) + limit: number; + // (undocumented) + offset: number; + // (undocumented) + total: number; +} + +// @public (undocumented) +export const LastLighthouseAuditCard: ({ dense, variant, }: { + dense?: boolean | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export type LighthouseApi = { + url: string; + getWebsiteList: (listOptions: LASListRequest) => Promise; + getWebsiteForAuditId: (auditId: string) => Promise; + triggerAudit: (payload: TriggerAuditPayload) => Promise; + getWebsiteByUrl: (websiteUrl: string) => Promise; +}; + +// @public (undocumented) +export const lighthouseApiRef: ApiRef; + +// @public (undocumented) +export interface LighthouseCategoryAbbr { + // (undocumented) + id: LighthouseCategoryId; + // (undocumented) + score: number; + // (undocumented) + title: string; +} + +// @public (undocumented) +export type LighthouseCategoryId = 'pwa' | 'seo' | 'performance' | 'accessibility' | 'best-practices'; + +// @public (undocumented) +export const LighthousePage: () => JSX.Element; + +// @public (undocumented) +const lighthousePlugin: BackstagePlugin<{ + root: RouteRef; + entityContent: RouteRef; +}, {}>; + +export { lighthousePlugin } + +export { lighthousePlugin as plugin } + +// @public (undocumented) +export class LighthouseRestApi implements LighthouseApi { + constructor(url: string); + // (undocumented) + static fromConfig(config: Config): LighthouseRestApi; + // (undocumented) + getWebsiteByUrl(websiteUrl: string): Promise; + // (undocumented) + getWebsiteForAuditId(auditId: string): Promise; + // (undocumented) + getWebsiteList({ limit, offset, }?: LASListRequest): Promise; + // (undocumented) + triggerAudit(payload: TriggerAuditPayload): Promise; + // (undocumented) + url: string; +} + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export interface TriggerAuditPayload { + // (undocumented) + options: { + lighthouseConfig: { + settings: { + emulatedFormFactor: string; + }; + }; + }; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface Website { + // (undocumented) + audits: Audit[]; + // (undocumented) + lastAudit: Audit; + // (undocumented) + url: string; +} + +// @public (undocumented) +export type WebsiteListResponse = LASListResponse; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/newrelic/api-report.md b/plugins/newrelic/api-report.md new file mode 100644 index 0000000000..e472405e22 --- /dev/null +++ b/plugins/newrelic/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-newrelic" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const NewRelicPage: () => JSX.Element; + +// @public (undocumented) +const newRelicPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { newRelicPlugin } + +export { newRelicPlugin as plugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md new file mode 100644 index 0000000000..cb3fa445f0 --- /dev/null +++ b/plugins/org/api-report.md @@ -0,0 +1,69 @@ +## API Report File for "@backstage/plugin-org" + +> 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 { GroupEntity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { UserEntity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const EntityGroupProfileCard: ({ variant, }: { + entity?: GroupEntity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityMembersListCard: (_props: { + entity?: GroupEntity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityOwnershipCard: ({ variant, }: { + entity?: Entity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityUserProfileCard: ({ variant, }: { + entity?: UserEntity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const GroupProfileCard: ({ variant, }: { + entity?: GroupEntity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const MembersListCard: (_props: { + entity?: GroupEntity; +}) => JSX.Element; + +// @public (undocumented) +const orgPlugin: BackstagePlugin<{}, {}>; + +export { orgPlugin } + +export { orgPlugin as plugin } + +// @public (undocumented) +export const OwnershipCard: ({ variant, }: { + entity?: Entity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const UserProfileCard: ({ variant, }: { + entity?: UserEntity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/pagerduty/api-report.md b/plugins/pagerduty/api-report.md new file mode 100644 index 0000000000..b3457ccb12 --- /dev/null +++ b/plugins/pagerduty/api-report.md @@ -0,0 +1,62 @@ +## API Report File for "@backstage/plugin-pagerduty" + +> 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 { PropsWithChildren } from 'react'; + +// @public (undocumented) +export const EntityPagerDutyCard: () => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity as isPagerDutyAvailable } + +export { isPluginApplicableToEntity } + +// @public (undocumented) +export const pagerDutyApiRef: ApiRef; + +// @public (undocumented) +export const PagerDutyCard: () => JSX.Element; + +// @public (undocumented) +export class PagerDutyClient implements PagerDutyApi { + constructor(config: ClientApiConfig); + // (undocumented) + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): PagerDutyClient; + // (undocumented) + getIncidentsByServiceId(serviceId: string): Promise; + // (undocumented) + getOnCallByPolicyId(policyId: string): Promise; + // (undocumented) + getServiceByIntegrationKey(integrationKey: string): Promise; + // (undocumented) + triggerAlarm({ integrationKey, source, description, userName, }: TriggerAlarmRequest): Promise; +} + +// @public (undocumented) +const pagerDutyPlugin: BackstagePlugin<{}, {}>; + +export { pagerDutyPlugin } + +export { pagerDutyPlugin as plugin } + +// @public (undocumented) +export function TriggerButton({ children, }: PropsWithChildren): JSX.Element; + +// @public (undocumented) +export class UnauthorizedError extends Error { +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/proxy-backend/api-report.md b/plugins/proxy-backend/api-report.md new file mode 100644 index 0000000000..1b1eba3b00 --- /dev/null +++ b/plugins/proxy-backend/api-report.md @@ -0,0 +1,18 @@ +## API Report File for "@backstage/plugin-proxy-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 { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/register-component/api-report.md b/plugins/register-component/api-report.md new file mode 100644 index 0000000000..401031d0ac --- /dev/null +++ b/plugins/register-component/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/plugin-register-component" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const RegisterComponentPage: ({ catalogRouteRef, }: { + catalogRouteRef: RouteRef; +}) => JSX.Element; + +// @public (undocumented) +const registerComponentPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { registerComponentPlugin as plugin } + +export { registerComponentPlugin } + +// @public @deprecated +export const Router: ({ catalogRouteRef }: { + catalogRouteRef: RouteRef; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/rollbar-backend/api-report.md b/plugins/rollbar-backend/api-report.md new file mode 100644 index 0000000000..fa8c67d4f0 --- /dev/null +++ b/plugins/rollbar-backend/api-report.md @@ -0,0 +1,60 @@ +## API Report File for "@backstage/plugin-rollbar-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; + +// @public (undocumented) +export function getRequestHeaders(token: string): { + headers: { + 'X-Rollbar-Access-Token': string; + }; +}; + +// @public (undocumented) +export class RollbarApi { + constructor(accessToken: string, logger: Logger); + // (undocumented) + getActivatedCounts(projectName: string, options?: { + environment: string; + item_id?: number; + }): Promise; + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getOccuranceCounts(projectName: string, options?: { + environment: string; + item_id?: number; + }): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(projectName: string): Promise; + // (undocumented) + getTopActiveItems(projectName: string, options?: { + hours: number; + environment: string; + }): Promise; + } + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; + // (undocumented) + rollbarApi?: RollbarApi; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/rollbar/api-report.md b/plugins/rollbar/api-report.md new file mode 100644 index 0000000000..7049414389 --- /dev/null +++ b/plugins/rollbar/api-report.md @@ -0,0 +1,78 @@ +## API Report File for "@backstage/plugin-rollbar" + +> 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 { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityPageRollbar: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityRollbarContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity } + +export { isPluginApplicableToEntity as isRollbarAvailable } + +// @public (undocumented) +export const ROLLBAR_ANNOTATION = "rollbar.com/project-slug"; + +// @public (undocumented) +export interface RollbarApi { + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(project: string): Promise; + // (undocumented) + getTopActiveItems(project: string, hours?: number): Promise; +} + +// @public (undocumented) +export const rollbarApiRef: ApiRef; + +// @public (undocumented) +export class RollbarClient implements RollbarApi { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(project: string): Promise; + // (undocumented) + getTopActiveItems(project: string, hours?: number, environment?: string): Promise; + } + +// @public (undocumented) +const rollbarPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { rollbarPlugin as plugin } + +export { rollbarPlugin } + +// @public (undocumented) +export const Router: (_props: Props_2) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 4ea79dfeed..f747fce610 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-scaffolder-backend +## 0.12.2 + +### Patch Changes + +- b49222176: Keep the empty string as empty string in `input` rather than replacing with `undefined` to make empty values ok for `cookiecutter` + ## 0.12.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/README.md b/plugins/scaffolder-backend/README.md index 304d43a750..7efe33f7f6 100644 --- a/plugins/scaffolder-backend/README.md +++ b/plugins/scaffolder-backend/README.md @@ -1,25 +1,64 @@ # Scaffolder Backend -Welcome to the scaffolder plugin! +This is the backend for the default Backstage [software +templates](https://backstage.io/docs/features/software-templates/software-templates-index). +This provides the API for the frontend [scaffolder +plugin](https://github.com/backstage/backstage/tree/master/plugins/scaffolder), +as well as the built-in template actions, tasks and stages. -## Jobs +## Installation -Documentation for `Jobs` here +This `@backstage/plugin-scaffolder-backend` package comes installed by default +in any Backstage application created with `npx @backstage/create-app`, so +installation is not usually required. -## Stages +To check if you already have the package, look under +`packages/backend/package.json`, in the `dependencies` block, for +`@backstage/plugin-scaffolder-backend`. The instructions below walk through +restoring the plugin, if you previously removed it. -Documentation for `Stages` here +### Install the package -## Tasks +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-scaffolder-backend +``` -Documentation for `Tasks` here +### Adding the plugin to your `packages/backend` -## Actions +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/scaffolder.ts` +with contents matching [scaffolder.ts in the create-app +template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts). -### Built-in: +With the `scaffolder.ts` router setup in place, add the router to +`packages/backend/src/index.ts`: -- #### GitHub Pull Request - - Minimum permissions required for GitHub App for creating a Pull Request with the built-in action: - - Read and Write permissions for `Contents`. - - Read and write permissions for `Pull Requests` and `Issues`. - - Read permissions on `Metadata`. +```diff ++import scaffolder from './plugins/scaffolder'; + +async function main() { + ... + const createEnv = makeCreateEnv(config); + + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); ++ const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); + + const apiRouter = Router(); ++ apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + ... + apiRouter.use(notFoundHandler()); + +``` + +### 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](https://backstage.io/docs/features/software-templates/adding-templates). + +To get up and running and try out some templates quickly, you can 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). diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md new file mode 100644 index 0000000000..69c54343e3 --- /dev/null +++ b/plugins/scaffolder-backend/api-report.md @@ -0,0 +1,518 @@ +## API Report File for "@backstage/plugin-scaffolder-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegrationConfig } from '@backstage/integration'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { ContainerRunner } from '@backstage/backend-common'; +import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import express from 'express'; +import { GithubCredentialsProvider } from '@backstage/integration'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { Gitlab } from '@gitbeaker/core'; +import { GitLabIntegrationConfig } from '@backstage/integration'; +import gitUrlParse from 'git-url-parse'; +import { JsonObject } from '@backstage/config'; +import { JsonValue } from '@backstage/config'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { Schema } from 'jsonschema'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { UrlReader } from '@backstage/backend-common'; +import { Writable } from 'stream'; + +// @public (undocumented) +export type ActionContext = { + baseUrl?: string; + logger: Logger; + logStream: Writable; + token?: string | undefined; + workspacePath: string; + input: Input; + output(name: string, value: JsonValue): void; + createTemporaryDirectory(): Promise; +}; + +// @public (undocumented) +export class AzurePreparer implements PreparerBase { + constructor(config: { + token?: string; + }); + // (undocumented) + static fromConfig(config: AzureIntegrationConfig): AzurePreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class AzurePublisher implements PublisherBase { + constructor(config: { + token: string; + }); + // (undocumented) + static fromConfig(config: AzureIntegrationConfig): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export class BitbucketPreparer implements PreparerBase { + constructor(config: { + username?: string; + token?: string; + appPassword?: string; + }); + // (undocumented) + static fromConfig(config: BitbucketIntegrationConfig): BitbucketPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class BitbucketPublisher implements PublisherBase { + constructor(config: { + host: string; + token?: string; + appPassword?: string; + username?: string; + apiBaseUrl?: string; + repoVisibility: RepoVisibilityOptions_2; + }); + // (undocumented) + static fromConfig(config: BitbucketIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions_2; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public +export class CatalogEntityClient { + constructor(catalogClient: CatalogApi); + findTemplate(templateName: string, options?: { + token?: string; + }): Promise; +} + +// @public (undocumented) +export class CookieCutter implements TemplaterBase { + constructor({ containerRunner }: { + containerRunner: ContainerRunner; + }); + // (undocumented) + run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; +} + +// @public (undocumented) +export const createBuiltinActions: (options: { + reader: UrlReader; + integrations: ScmIntegrations; + catalogClient: CatalogApi; + templaters: TemplaterBuilder; +}) => TemplateAction[]; + +// @public (undocumented) +export function createCatalogRegisterAction(options: { + catalogClient: CatalogApi; + integrations: ScmIntegrations; +}): TemplateAction; + +// @public +export function createDebugLogAction(): TemplateAction; + +// @public (undocumented) +export function createFetchCookiecutterAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; + templaters: TemplaterBuilder; +}): TemplateAction; + +// @public (undocumented) +export function createFetchPlainAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; +}): TemplateAction; + +// @public (undocumented) +export function createLegacyActions(options: Options): TemplateAction[]; + +// @public (undocumented) +export function createPublishAzureAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export function createPublishBitbucketAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public +export function createPublishFileAction(): TemplateAction; + +// @public (undocumented) +export function createPublishGithubAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export const createPublishGithubPullRequestAction: ({ integrations, clientFactory, }: CreateGithubPullRequestActionOptions) => TemplateAction; + +// @public (undocumented) +export function createPublishGitlabAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export class CreateReactAppTemplater implements TemplaterBase { + constructor({ containerRunner }: { + containerRunner: ContainerRunner; + }); + // (undocumented) + run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; +} + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export const createTemplateAction: | undefined; +}>>(templateAction: TemplateAction) => TemplateAction; + +// @public (undocumented) +export class FilePreparer implements PreparerBase { + // (undocumented) + prepare({ url, workspacePath }: PreparerOptions): Promise; +} + +// @public +export const getTemplaterKey: (entity: TemplateEntityV1alpha1) => string; + +// @public (undocumented) +export class GithubPreparer implements PreparerBase { + constructor(config: { + credentialsProvider: GithubCredentialsProvider; + }); + // (undocumented) + static fromConfig(config: GitHubIntegrationConfig): GithubPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public @deprecated (undocumented) +export class GithubPublisher implements PublisherBase { + constructor(config: { + credentialsProvider: GithubCredentialsProvider; + repoVisibility: RepoVisibilityOptions; + apiBaseUrl: string | undefined; + }); + // (undocumented) + static fromConfig(config: GitHubIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export class GitlabPreparer implements PreparerBase { + constructor(config: { + token?: string; + }); + // (undocumented) + static fromConfig(config: GitLabIntegrationConfig): GitlabPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class GitlabPublisher implements PublisherBase { + constructor(config: { + token: string; + client: Gitlab; + repoVisibility: RepoVisibilityOptions_3; + }); + // (undocumented) + static fromConfig(config: GitLabIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions_3; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export type Job = { + id: string; + context: StageContext; + status: ProcessorStatus; + stages: StageResult[]; + error?: Error; +}; + +// @public (undocumented) +export type JobAndDirectoryTuple = { + job: Job; + directory: string; +}; + +// @public (undocumented) +export class JobProcessor implements Processor { + constructor(workingDirectory: string); + // (undocumented) + create({ entity, values, stages, }: { + entity: TemplateEntityV1alpha1; + values: TemplaterValues; + stages: StageInput[]; + }): Job; + // (undocumented) + static fromConfig({ config, logger, }: { + config: Config; + logger: Logger; + }): Promise; + // (undocumented) + get(id: string): Job | undefined; + // (undocumented) + run(job: Job): Promise; + } + +// @public (undocumented) +export function joinGitUrlPath(repoUrl: string, path?: string): string; + +// @public (undocumented) +export type ParsedLocationAnnotation = { + protocol: 'file' | 'url'; + location: string; +}; + +// @public (undocumented) +export const parseLocationAnnotation: (entity: TemplateEntityV1alpha1) => ParsedLocationAnnotation; + +// @public (undocumented) +export interface PreparerBase { + prepare(opts: PreparerOptions): Promise; +} + +// @public (undocumented) +export type PreparerBuilder = { + register(host: string, preparer: PreparerBase): void; + get(url: string): PreparerBase; +}; + +// @public (undocumented) +export type PreparerOptions = { + url: string; + workspacePath: string; + logger: Logger; +}; + +// @public (undocumented) +export class Preparers implements PreparerBuilder { + // (undocumented) + static fromConfig(config: Config, _: { + logger: Logger; + }): Promise; + // (undocumented) + get(url: string): PreparerBase; + // (undocumented) + register(host: string, preparer: PreparerBase): void; +} + +// @public (undocumented) +export type Processor = { + create({ entity, values, stages, }: { + entity: TemplateEntityV1alpha1; + values: TemplaterValues; + stages: StageInput[]; + }): Job; + get(id: string): Job | undefined; + run(job: Job): Promise; +}; + +// @public (undocumented) +export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + +// @public +export type PublisherBase = { + publish(opts: PublisherOptions): Promise; +}; + +// @public (undocumented) +export type PublisherBuilder = { + register(host: string, publisher: PublisherBase): void; + get(storePath: string): PublisherBase; +}; + +// @public (undocumented) +export type PublisherOptions = { + values: TemplaterValues; + workspacePath: string; + logger: Logger; +}; + +// @public (undocumented) +export type PublisherResult = { + remoteUrl: string; + catalogInfoUrl?: string; +}; + +// @public (undocumented) +export class Publishers implements PublisherBuilder { + // (undocumented) + static fromConfig(config: Config, _options: { + logger: Logger; + }): Promise; + // (undocumented) + get(url: string): PublisherBase; + // (undocumented) + register(host: string, preparer: PublisherBase | undefined): void; +} + +// @public (undocumented) +export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + +// @public +export type RequiredTemplateValues = { + owner: string; + storePath: string; + destination?: { + git?: gitUrlParse.GitUrl; + }; +}; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + actions?: TemplateAction[]; + // (undocumented) + catalogClient: CatalogApi; + // (undocumented) + config: Config; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + logger: Logger; + // (undocumented) + preparers: PreparerBuilder; + // (undocumented) + publishers: PublisherBuilder; + // (undocumented) + reader: UrlReader; + // (undocumented) + taskWorkers?: number; + // (undocumented) + templaters: TemplaterBuilder; +} + +// @public (undocumented) +export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise; + +// @public (undocumented) +export type RunCommandOptions = { + command: string; + args: string[]; + logStream?: Writable; +}; + +// @public (undocumented) +export type StageContext = { + values: TemplaterValues; + entity: TemplateEntityV1alpha1; + logger: Logger; + logStream: Writable; + workspacePath: string; +} & T; + +// @public (undocumented) +export interface StageInput { + // (undocumented) + handler(ctx: StageContext): Promise; + // (undocumented) + name: string; +} + +// @public (undocumented) +export interface StageResult extends StageInput { + // (undocumented) + endedAt?: number; + // (undocumented) + log: string[]; + // (undocumented) + startedAt?: number; + // (undocumented) + status: ProcessorStatus; +} + +// @public +export type SupportedTemplatingKey = 'cookiecutter' | string; + +// @public (undocumented) +export type TemplateAction = { + id: string; + description?: string; + schema?: { + input?: Schema; + output?: Schema; + }; + handler: (ctx: ActionContext) => Promise; +}; + +// @public (undocumented) +export class TemplateActionRegistry { + // (undocumented) + get(actionId: string): TemplateAction; + // (undocumented) + list(): TemplateAction[]; + // (undocumented) + register(action: TemplateAction): void; +} + +// @public (undocumented) +export type TemplaterBase = { + run(opts: TemplaterRunOptions): Promise; +}; + +// @public +export type TemplaterBuilder = { + register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; + get(templater: string): TemplaterBase; +}; + +// @public (undocumented) +export type TemplaterConfig = { + templater?: TemplaterBase; +}; + +// @public +export type TemplaterRunOptions = { + workspacePath: string; + values: TemplaterValues; + logStream?: Writable; +}; + +// @public +export type TemplaterRunResult = { + resultDir: string; +}; + +// @public (undocumented) +export class Templaters implements TemplaterBuilder { + // (undocumented) + get(templaterId: string): TemplaterBase; + // (undocumented) + register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase): void; + } + +// @public (undocumented) +export type TemplaterValues = RequiredTemplateValues & Record; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index b9737bc2fc..f3e76720af 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.12.1", + "version": "0.12.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 94eaa62c85..bff10fc7e9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -170,11 +170,6 @@ export class TaskWorker { preventIndent: true, })(templateCtx); - // If it's just an empty string, treat it as undefined - if (templated === '') { - return undefined; - } - // If it smells like a JSON object then give it a parse as an object and if it fails return the string if ( (templated.startsWith('"') && templated.endsWith('"')) || @@ -213,6 +208,10 @@ export class TaskWorker { // Keep track of all tmp dirs that are created by the action so we can remove them after const tmpDirs = new Array(); + this.options.logger.debug(`Running ${action.id} with input`, { + input: JSON.stringify(input, null, 2), + }); + await action.handler({ baseUrl: task.spec.baseUrl, logger: taskLogger, diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md index 05a68dafdd..0bfd2f8a35 100644 --- a/plugins/scaffolder/README.md +++ b/plugins/scaffolder/README.md @@ -1,10 +1,86 @@ # Scaffolder Frontend -WORK IN PROGRESS +This is the React frontend for the default Backstage [software +templates](https://backstage.io/docs/features/software-templates/software-templates-index). +This package supplies interfaces related to showing available templates in the +Backstage catalog and the workflow to create software using those templates. -This is the frontend part of the default scaffolder plugin. +## Installation + +This `@backstage/plugin-scaffolder` package comes installed by default in any +Backstage application created with `npx @backstage/create-app`, so installation +is not usually required. + +To check if you already have the package, look under +`packages/app/package.json`, in the `dependencies` block, for +`@backstage/plugin-scaffolder`. The instructions below walk through restoring +the plugin, if you previously removed it. + +### Install the package + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-scaffolder +``` + +### Add 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: + +```diff +// packages/app/src/App.tsx ++import { ScaffolderPage } from '@backstage/plugin-scaffolder'; + + + + } /> + }> + {entityPage} + ++ } />; + ... + +``` + +The scaffolder plugin also has one external route that needs to be bound for it +to function: the `registerComponent` route which should link to the page where +the user can register existing software component. In a typical setup, the +register component route will be linked to the `catalog-import` plugin's import +page: + +```diff +// packages/app/src/App.tsx ++import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; ++import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; + +const app = createApp({ + // ... + bindRoutes({ bind }) { ++ bind(scaffolderPlugin.externalRoutes, { ++ registerComponent: catalogImportPlugin.routes.importPage, ++ }); + }, +}); +``` + +You may also want to add a link to the scaffolder page to your application +sidebar: + +```diff +// packages/app/src/components/Root/Root.tsx ++import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + ++ ; + ... + +``` ## Links -- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) -- [The Backstage homepage](https://backstage.io) +- [scaffolder-backend](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) + provides the backend API for this frontend. diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md new file mode 100644 index 0000000000..85c2142d5e --- /dev/null +++ b/plugins/scaffolder/api-report.md @@ -0,0 +1,112 @@ +## API Report File for "@backstage/plugin-scaffolder" + +> 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 { DiscoveryApi } from '@backstage/core'; +import { EntityName } from '@backstage/catalog-model'; +import { Extension } from '@backstage/core'; +import { ExternalRouteRef } from '@backstage/core'; +import { FieldProps } from '@rjsf/core'; +import { FieldValidation } from '@rjsf/core'; +import { IdentityApi } from '@backstage/core'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/config'; +import { Observable } from '@backstage/core'; +import { default as React_2 } from 'react'; +import { RouteRef } from '@backstage/core'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +// @public (undocumented) +export function createScaffolderFieldExtension(options: FieldExtensionOptions): Extension<() => null>; + +// @public (undocumented) +export const EntityPickerFieldExtension: () => null; + +// @public (undocumented) +export const OwnerPickerFieldExtension: () => null; + +// @public (undocumented) +export const RepoUrlPickerFieldExtension: () => null; + +// @public (undocumented) +export interface ScaffolderApi { + // (undocumented) + getIntegrationsList(options: { + allowedHosts: string[]; + }): Promise<{ + type: string; + title: string; + host: string; + }[]>; + // (undocumented) + getTask(taskId: string): Promise; + // (undocumented) + getTemplateParameterSchema(templateName: EntityName): Promise; + // (undocumented) + listActions(): Promise; + scaffold(templateName: string, values: Record): Promise; + // (undocumented) + streamLogs({ taskId, after, }: { + taskId: string; + after?: number; + }): Observable; +} + +// @public (undocumented) +export const scaffolderApiRef: ApiRef; + +// @public (undocumented) +export class ScaffolderClient implements ScaffolderApi { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + scmIntegrationsApi: ScmIntegrationRegistry; + }); + // (undocumented) + getIntegrationsList(options: { + allowedHosts: string[]; + }): Promise<{ + type: string; + title: string; + host: string; + }[]>; + // (undocumented) + getTask(taskId: string): Promise; + // (undocumented) + getTemplateParameterSchema(templateName: EntityName): Promise; + // (undocumented) + listActions(): Promise; + scaffold(templateName: string, values: Record): Promise; + // (undocumented) + streamLogs({ taskId, after, }: { + taskId: string; + after?: number; + }): Observable; +} + +// @public (undocumented) +export const ScaffolderFieldExtensions: React_2.ComponentType; + +// @public (undocumented) +export const ScaffolderPage: () => JSX.Element; + +// @public (undocumented) +const scaffolderPlugin: BackstagePlugin<{ + root: RouteRef; +}, { + registerComponent: ExternalRouteRef; +}>; + +export { scaffolderPlugin as plugin } + +export { scaffolderPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 69d71e7966..120cd7e297 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -33,11 +33,12 @@ "@backstage/catalog-client": "^0.3.13", "@backstage/catalog-model": "^0.8.2", "@backstage/config": "^0.1.5", - "@backstage/errors": "^0.1.1", "@backstage/core": "^0.7.12", "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.2", + "@backstage/core-plugin-api": "^0.1.2", + "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,10 +47,10 @@ "@rjsf/material-ui": "^2.4.0", "@types/react": "^16.9", "classnames": "^2.2.6", - "json-schema": "^0.3.0", "git-url-parse": "^11.4.4", "humanize-duration": "^3.25.1", "immer": "^9.0.1", + "json-schema": "^0.3.0", "luxon": "^1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -66,9 +67,9 @@ "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^13.1.8", "@types/humanize-duration": "^3.18.1", - "@testing-library/react-hooks": "^3.3.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 5ac663c7ea..ef0dd4082e 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { Routes, Route, useOutlet } from 'react-router'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; @@ -27,21 +27,24 @@ import { FIELD_EXTENSION_KEY, DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS, } from '../extensions'; -import { collectComponentData, collectChildren } from '../extensions/helpers'; +import { useElementFilter } from '@backstage/core-plugin-api'; export const Router = () => { const outlet = useOutlet(); - const fieldExtensions = useMemo(() => { - const registeredExtensions = collectComponentData( - collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - FIELD_EXTENSION_KEY, - ); + const foundExtensions = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: FIELD_EXTENSION_WRAPPER_KEY, + }) + .findComponentData({ + key: FIELD_EXTENSION_KEY, + }), + ); - return registeredExtensions.length - ? registeredExtensions - : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - }, [outlet]); + const fieldExtensions = foundExtensions.length + ? foundExtensions + : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; return ( diff --git a/plugins/scaffolder/src/extensions/helpers.test.tsx b/plugins/scaffolder/src/extensions/helpers.test.tsx deleted file mode 100644 index ed3b74a210..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.test.tsx +++ /dev/null @@ -1,107 +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 React from 'react'; -import { collectComponentData, collectChildren } from './helpers'; -import { attachComponentData } from '@backstage/core'; - -describe('Extension Helpers', () => { - const createElementWithComponentData = ({ - type, - data, - }: { - type: string; - data: any; - }) => { - const element: React.ComponentType = () => null; - attachComponentData(element, type, data); - return element; - }; - - describe('collectChildren', () => { - it('should return the children of the component which has the correct componentData flag', () => { - const SearchElement = createElementWithComponentData({ - type: 'find.me', - data: {}, - }); - - const DontCareAboutme = createElementWithComponentData({ - type: 'dont.find.me', - data: {}, - }); - - const child1 = ( -
- hello -
- ); - - const child2 = ( -
-

Hello2

-
- ); - - const testCase = ( -
- - {child1} - {child1} - - {child2} - -

Hello!

- {child1} -
-
- ); - - const children = collectChildren(testCase, 'find.me'); - - expect(children).toEqual([[child1, child1], child2, child1]); - }); - }); - - describe('collectComponentData', () => { - it('should return the componentData for particular nodes', () => { - const componentData1 = { help: 'im something' }; - const componentData2 = { help: 'im something else' }; - - const FirstElement = createElementWithComponentData({ - type: 'find.me', - data: componentData1, - }); - - const SecondElement = createElementWithComponentData({ - type: 'dont.find.me', - data: componentData2, - }); - - const testCase = [ - , - , - , - , - ]; - const returnedData = collectComponentData(testCase, 'find.me'); - - expect(returnedData).toEqual([ - componentData1, - componentData1, - componentData1, - ]); - }); - }); -}); diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts deleted file mode 100644 index 2eac9780e5..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.ts +++ /dev/null @@ -1,73 +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 React from 'react'; -import { getComponentData } from '@backstage/core'; - -export const collectComponentData = ( - children: React.ReactNode, - componentDataKey: string, -) => { - const stack = [children]; - const found: T[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - const data = getComponentData(child, componentDataKey); - if (data) { - found.push(data); - } - - if (child.props.children) { - stack.push(child.props.children); - } - }); - } - - return found; -}; - -export const collectChildren = ( - component: React.ReactNode, - componentDataKey: string, -) => { - const stack = [component]; - const found: React.ReactNode[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - if (child.props.children) { - if (getComponentData(child, componentDataKey)) { - found.push(child.props.children); - } - stack.push(child.props.children); - } - }); - } - - return found; -}; diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md new file mode 100644 index 0000000000..03585b6b99 --- /dev/null +++ b/plugins/search-backend-node/api-report.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/plugin-search-backend-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { DocumentCollator } from '@backstage/search-common'; +import { DocumentDecorator } from '@backstage/search-common'; +import { IndexableDocument } from '@backstage/search-common'; +import { Logger } from 'winston'; +import { default as lunr_2 } from 'lunr'; +import { SearchQuery } from '@backstage/search-common'; +import { SearchResultSet } from '@backstage/search-common'; + +// @public (undocumented) +export class IndexBuilder { + constructor({ logger, searchEngine }: IndexBuilderOptions); + addCollator({ collator, defaultRefreshIntervalSeconds, }: RegisterCollatorParameters): void; + addDecorator({ decorator }: RegisterDecoratorParameters): void; + build(): Promise<{ + scheduler: Scheduler; + }>; + // (undocumented) + getSearchEngine(): SearchEngine; + } + +// @public (undocumented) +export class LunrSearchEngine implements SearchEngine { + constructor({ logger }: { + logger: Logger; + }); + // (undocumented) + protected docStore: Record; + // (undocumented) + index(type: string, documents: IndexableDocument[]): void; + // (undocumented) + protected logger: Logger; + // (undocumented) + protected lunrIndices: Record; + // (undocumented) + query(query: SearchQuery): Promise; + // (undocumented) + setTranslator(translator: LunrQueryTranslator): void; + // (undocumented) + protected translator: QueryTranslator; +} + +// @public +export class Scheduler { + constructor({ logger }: { + logger: Logger; + }); + addToSchedule(task: Function, interval: number): void; + start(): void; + stop(): void; +} + +// @public +export interface SearchEngine { + index(type: string, documents: IndexableDocument[]): void; + query(query: SearchQuery): Promise; + setTranslator(translator: QueryTranslator): void; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md new file mode 100644 index 0000000000..9e617b73ed --- /dev/null +++ b/plugins/search-backend/api-report.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/plugin-search-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import express from 'express'; +import { Logger } from 'winston'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; + +// @public (undocumented) +export function createRouter({ engine, logger, }: RouterOptions): Promise; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md new file mode 100644 index 0000000000..181f049f63 --- /dev/null +++ b/plugins/search/api-report.md @@ -0,0 +1,102 @@ +## API Report File for "@backstage/plugin-search" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { BackstagePlugin } from '@backstage/core'; +import { IndexableDocument } from '@backstage/search-common'; +import { JsonObject } from '@backstage/config'; +import { default as React_2 } from 'react'; +import { ReactElement } from 'react'; +import { RouteRef } from '@backstage/core'; +import { SearchQuery } from '@backstage/search-common'; +import { SearchResult as SearchResult_2 } from '@backstage/search-common'; +import { SearchResultSet } from '@backstage/search-common'; + +// @public (undocumented) +export const DefaultResultListItem: ({ result }: { + result: IndexableDocument; +}) => JSX.Element; + +// @public (undocumented) +export const Filters: ({ filters, filterOptions, resetFilters, updateSelected, updateChecked, }: FiltersProps) => JSX.Element; + +// @public (undocumented) +export const FiltersButton: ({ numberOfSelectedFilters, handleToggleFilters, }: FiltersButtonProps) => JSX.Element; + +// @public (undocumented) +export type FiltersState = { + selected: string; + checked: Array; +}; + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export const searchApiRef: ApiRef; + +// @public (undocumented) +export const SearchBar: ({ className, debounceTime }: Props) => JSX.Element; + +// @public @deprecated (undocumented) +export const SearchBarNext: ({ className, debounceTime }: { + className?: string | undefined; + debounceTime?: number | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const SearchContextProvider: ({ initialState, children, }: React_2.PropsWithChildren<{ + initialState?: SettableSearchContext | undefined; +}>) => JSX.Element; + +// @public (undocumented) +export const SearchFilter: { + ({ component: Element, ...props }: Props_2): JSX.Element; + Checkbox(props: Omit & Component): JSX.Element; + Select(props: Omit & Component): JSX.Element; +}; + +// @public @deprecated (undocumented) +export const SearchFilterNext: { + ({ component: Element, ...props }: Props_2): JSX.Element; + Checkbox(props: Omit & Component): JSX.Element; + Select(props: Omit & Component): JSX.Element; +}; + +// @public (undocumented) +export const SearchPage: () => JSX.Element; + +// @public @deprecated (undocumented) +export const SearchPageNext: () => JSX.Element; + +// @public (undocumented) +const searchPlugin: BackstagePlugin<{ + root: RouteRef; + nextRoot: RouteRef; +}, {}>; + +export { searchPlugin as plugin } + +export { searchPlugin } + +// @public (undocumented) +export const SearchResult: ({ children }: { + children: (results: { + results: SearchResult_2[]; + }) => JSX.Element; +}) => JSX.Element; + +// @public (undocumented) +export const SidebarSearch: () => JSX.Element; + +// @public (undocumented) +export const useSearch: () => SearchContextValue; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/sentry/api-report.md b/plugins/sentry/api-report.md new file mode 100644 index 0000000000..ffd3071b92 --- /dev/null +++ b/plugins/sentry/api-report.md @@ -0,0 +1,100 @@ +## API Report File for "@backstage/plugin-sentry" + +> 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 { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntitySentryCard: () => JSX.Element; + +// @public (undocumented) +export const EntitySentryContent: () => JSX.Element; + +// @public (undocumented) +export class MockSentryApi implements SentryApi { + // (undocumented) + fetchIssues(): Promise; +} + +// @public (undocumented) +export class ProductionSentryApi implements SentryApi { + constructor(discoveryApi: DiscoveryApi, organization: string); + // (undocumented) + fetchIssues(project: string, statsFor: string): Promise; + } + +// @public (undocumented) +export const Router: ({ entity }: { + entity: Entity; +}) => JSX.Element; + +// @public (undocumented) +export interface SentryApi { + // (undocumented) + fetchIssues(project: string, statsFor: string): Promise; +} + +// @public (undocumented) +export const sentryApiRef: ApiRef; + +// @public (undocumented) +export type SentryIssue = { + platform: SentryPlatform; + lastSeen: string; + numComments: number; + userCount: number; + stats: { + '24h'?: EventPoint[]; + '12h'?: EventPoint[]; + }; + culprit: string; + title: string; + id: string; + assignedTo: any; + logger: any; + type: string; + annotations: any[]; + metadata: SentryIssueMetadata; + status: string; + subscriptionDetails: any; + isPublic: boolean; + hasSeen: boolean; + shortId: string; + shareId: string | null; + firstSeen: string; + count: string; + permalink: string; + level: string; + isSubscribed: boolean; + isBookmarked: boolean; + project: SentryProject; + statusDetails: any; +}; + +// @public (undocumented) +export const SentryIssuesWidget: ({ entity, statsFor, variant, }: { + entity: Entity; + statsFor?: "12h" | "24h" | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +const sentryPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { sentryPlugin as plugin } + +export { sentryPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/shortcuts/api-report.md b/plugins/shortcuts/api-report.md new file mode 100644 index 0000000000..4c0b3132d6 --- /dev/null +++ b/plugins/shortcuts/api-report.md @@ -0,0 +1,56 @@ +## API Report File for "@backstage/plugin-shortcuts" + +> 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 ObservableImpl from 'zen-observable'; +import { StorageApi } from '@backstage/core'; + +// @public +export class LocalStoredShortcuts implements ShortcutApi { + constructor(storageApi: StorageApi); + // (undocumented) + add(shortcut: Omit): Promise; + // (undocumented) + getColor(url: string): string; + // (undocumented) + remove(id: string): Promise; + // (undocumented) + shortcut$(): ObservableImpl; + // (undocumented) + update(shortcut: Shortcut): Promise; +} + +// @public (undocumented) +export type Shortcut = { + id: string; + url: string; + title: string; +}; + +// @public (undocumented) +export interface ShortcutApi { + add(shortcut: Omit): Promise; + getColor(url: string): string; + remove(id: string): Promise; + shortcut$(): Observable; + update(shortcut: Shortcut): Promise; +} + +// @public (undocumented) +export const Shortcuts: () => JSX.Element; + +// @public (undocumented) +export const shortcutsApiRef: ApiRef; + +// @public (undocumented) +export const shortcutsPlugin: BackstagePlugin<{}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/sonarqube/api-report.md b/plugins/sonarqube/api-report.md new file mode 100644 index 0000000000..6d4af3d548 --- /dev/null +++ b/plugins/sonarqube/api-report.md @@ -0,0 +1,41 @@ +## API Report File for "@backstage/plugin-sonarqube" + +> 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 { InfoCardVariants } from '@backstage/core'; + +// @public (undocumented) +export const EntitySonarQubeCard: ({ variant, duplicationRatings, }: { + entity?: Entity| undefined; + variant?: InfoCardVariants| undefined; + duplicationRatings?: { + greaterThan: number; + rating: "1.0" | "2.0" | "3.0" | "4.0" | "5.0"; + }[] | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const isSonarQubeAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const SonarQubeCard: ({ variant, duplicationRatings, }: { + entity?: Entity | undefined; + variant?: InfoCardVariants | undefined; + duplicationRatings?: DuplicationRating[] | undefined; +}) => JSX.Element; + +// @public (undocumented) +const sonarQubePlugin: BackstagePlugin<{}, {}>; + +export { sonarQubePlugin as plugin } + +export { sonarQubePlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/splunk-on-call/api-report.md b/plugins/splunk-on-call/api-report.md new file mode 100644 index 0000000000..3a2f085b0b --- /dev/null +++ b/plugins/splunk-on-call/api-report.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/plugin-splunk-on-call" + +> 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 { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntitySplunkOnCallCard: () => JSX.Element; + +// @public (undocumented) +export const isSplunkOnCallAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const splunkOnCallApiRef: ApiRef; + +// @public (undocumented) +export class SplunkOnCallClient implements SplunkOnCallApi { + constructor(config: ClientApiConfig); + // (undocumented) + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): SplunkOnCallClient; + // (undocumented) + getEscalationPolicies(): Promise; + // (undocumented) + getIncidents(): Promise; + // (undocumented) + getOnCallUsers(): Promise; + // (undocumented) + getTeams(): Promise; + // (undocumented) + getUsers(): Promise; + // (undocumented) + incidentAction({ routingKey, incidentType, incidentId, incidentDisplayName, incidentMessage, incidentStartTime, }: TriggerAlarmRequest): Promise; + } + +// @public (undocumented) +export const SplunkOnCallPage: { + ({ title, subtitle, pageTitle, }: SplunkOnCallPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +const splunkOnCallPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { splunkOnCallPlugin as plugin } + +export { splunkOnCallPlugin } + +// @public (undocumented) +export class UnauthorizedError extends Error { +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/tech-radar/api-report.md b/plugins/tech-radar/api-report.md new file mode 100644 index 0000000000..20367c5525 --- /dev/null +++ b/plugins/tech-radar/api-report.md @@ -0,0 +1,123 @@ +## API Report File for "@backstage/plugin-tech-radar" + +> 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 RadarEntry { + // (undocumented) + description?: string; + // (undocumented) + id: string; + // (undocumented) + key: string; + // (undocumented) + quadrant: string; + // (undocumented) + timeline: Array; + // (undocumented) + title: string; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface RadarEntrySnapshot { + // (undocumented) + date: Date; + // (undocumented) + description?: string; + // (undocumented) + moved?: MovedState; + // (undocumented) + ringId: string; +} + +// @public (undocumented) +export interface RadarQuadrant { + // (undocumented) + id: string; + // (undocumented) + name: string; +} + +// @public +export interface RadarRing { + // (undocumented) + color: string; + // (undocumented) + id: string; + // (undocumented) + name: string; +} + +// @public (undocumented) +export const Router: { + ({ title, subtitle, pageTitle, ...props }: TechRadarPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +export interface TechRadarApi { + // (undocumented) + load: () => Promise; +} + +// @public (undocumented) +export const techRadarApiRef: ApiRef; + +// @public (undocumented) +export const TechRadarComponent: (props: TechRadarComponentProps) => JSX.Element; + +// @public +export interface TechRadarComponentProps { + // (undocumented) + height: number; + // (undocumented) + svgProps?: object; + // (undocumented) + width: number; +} + +// @public +export interface TechRadarLoaderResponse { + // (undocumented) + entries: RadarEntry[]; + // (undocumented) + quadrants: RadarQuadrant[]; + // (undocumented) + rings: RadarRing[]; +} + +// @public (undocumented) +export const TechRadarPage: { + ({ title, subtitle, pageTitle, ...props }: TechRadarPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +const techRadarPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { techRadarPlugin as plugin } + +export { techRadarPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md new file mode 100644 index 0000000000..d433d428e0 --- /dev/null +++ b/plugins/techdocs-backend/api-report.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/plugin-techdocs-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 { GeneratorBuilder } from '@backstage/techdocs-common'; +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { PreparerBuilder } from '@backstage/techdocs-common'; +import { PublisherBase } from '@backstage/techdocs-common'; + +// @public (undocumented) +export function createRouter({ preparers, generators, publisher, config, logger, discovery, }: RouterOptions): Promise; + + +export * from "@backstage/techdocs-common"; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index dce391ff3f..24549f8b6f 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -68,7 +68,11 @@ export class DocsBuilder { this.config = config; } - public async build(): Promise { + /** + * Build the docs and return whether they have been newly generated or have been cached + * @returns true, if the docs have been built. false, if the cached docs are still up-to-date. + */ + public async build(): Promise { if (!this.entity.metadata.uid) { throw new Error( 'Trying to build documentation for entity not in service catalog', @@ -125,7 +129,7 @@ export class DocsBuilder { this.entity, )} are unmodified. Using cache, skipping generate and prepare`, ); - return; + return false; } throw new Error(err.message); } @@ -205,5 +209,7 @@ export class DocsBuilder { // Update the last check time for the entity new BuildMetadataStorage(this.entity.metadata.uid).setLastUpdated(); + + return true; } } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 1cd4075da0..ff47e16bc9 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -16,7 +16,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { NotFoundError } from '@backstage/errors'; +import { NotFoundError, NotModifiedError } from '@backstage/errors'; import { GeneratorBuilder, getLocationForEntity, @@ -172,10 +172,15 @@ export async function createRouter({ case 'awsS3': case 'azureBlobStorage': case 'openStackSwift': - case 'googleGcs': + case 'googleGcs': { // This block should be valid for all storage implementations. So no need to duplicate in future, // add the publisher type in the list here. - await docsBuilder.build(); + const updated = await docsBuilder.build(); + + if (!updated) { + throw new NotModifiedError(); + } + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched // on the user's page. If not, respond with a message asking them to check back later. // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second @@ -194,10 +199,13 @@ export async function createRouter({ 'Sorry! It took too long for the generated docs to show up in storage. Check back later.', ); } + res .status(201) .json({ message: 'Docs updated or did not need updating' }); break; + } + default: throw new NotFoundError( `Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`, diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md new file mode 100644 index 0000000000..801eabf368 --- /dev/null +++ b/plugins/techdocs/api-report.md @@ -0,0 +1,146 @@ +## API Report File for "@backstage/plugin-techdocs" + +> 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 { Config } from '@backstage/config'; +import { CSSProperties } from '@material-ui/styles'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core'; +import { Location as Location_2 } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const DocsCardGrid: ({ entities, }: { + entities: Entity[] | undefined; +}) => JSX.Element | null; + +// @public (undocumented) +export const DocsTable: ({ entities, title, }: { + entities: Entity[] | undefined; + title?: string | undefined; +}) => JSX.Element | null; + +// @public (undocumented) +export const EmbeddedDocsRouter: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityTechdocsContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export type PanelType = 'DocsCardGrid' | 'DocsTable'; + +// @public (undocumented) +export const Reader: ({ entityId, onReady }: Props_2) => JSX.Element; + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export interface TechDocsApi { + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getEntityMetadata(entityId: EntityName): Promise; + // (undocumented) + getTechDocsMetadata(entityId: EntityName): Promise; +} + +// @public (undocumented) +export const techdocsApiRef: ApiRef; + +// @public +export class TechDocsClient implements TechDocsApi { + constructor({ configApi, discoveryApi, identityApi, }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + configApi: Config; + // (undocumented) + discoveryApi: DiscoveryApi; + // (undocumented) + getApiOrigin(): Promise; + getEntityMetadata(entityId: EntityName): Promise; + getTechDocsMetadata(entityId: EntityName): Promise; + // (undocumented) + identityApi: IdentityApi; +} + +// @public (undocumented) +export const TechDocsCustomHome: ({ tabsConfig, }: { + tabsConfig: TabsConfig; +}) => JSX.Element; + +// @public (undocumented) +export const TechdocsPage: () => JSX.Element; + +// @public (undocumented) +const techdocsPlugin: BackstagePlugin<{ + root: RouteRef; + entityContent: RouteRef; +}, {}>; + +export { techdocsPlugin as plugin } + +export { techdocsPlugin } + +// @public (undocumented) +export const TechDocsReaderPage: () => JSX.Element; + +// @public (undocumented) +export interface TechDocsStorageApi { + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): Promise; + // (undocumented) + getBuilder(): Promise; + // (undocumented) + getEntityDocs(entityId: EntityName, path: string): Promise; + // (undocumented) + getStorageUrl(): Promise; + // (undocumented) + syncEntityDocs(entityId: EntityName): Promise; +} + +// @public (undocumented) +export const techdocsStorageApiRef: ApiRef; + +// @public +export class TechDocsStorageClient implements TechDocsStorageApi { + constructor({ configApi, discoveryApi, identityApi, }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + configApi: Config; + // (undocumented) + discoveryApi: DiscoveryApi; + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): Promise; + // (undocumented) + getBuilder(): Promise; + getEntityDocs(entityId: EntityName, path: string): Promise; + // (undocumented) + getStorageUrl(): Promise; + // (undocumented) + identityApi: IdentityApi; + syncEntityDocs(entityId: EntityName): Promise; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts deleted file mode 100644 index e764bf82bb..0000000000 --- a/plugins/techdocs/dev/api.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { EntityName } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import { DiscoveryApi, IdentityApi } from '@backstage/core'; -import { NotFoundError } from '@backstage/errors'; -import { TechDocsStorageApi } from '../src/api'; - -export class TechDocsDevStorageApi implements TechDocsStorageApi { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - async getStorageUrl() { - return ( - this.configApi.getOptionalString('techdocs.storageUrl') ?? - `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` - ); - } - - async getBuilder() { - return this.configApi.getString('techdocs.builder'); - } - - async fetchUrl(url: string) { - const token = await this.identityApi.getIdToken(); - return fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - } - - async getEntityDocs(entityId: EntityName, path: string) { - const { kind, namespace, name } = entityId; - - const storageUrl = await this.getStorageUrl(); - const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, - ); - - let errorMessage = ''; - switch (request.status) { - case 404: - errorMessage = 'Page not found. '; - // path is empty for the home page of an entity's docs site - if (!path) { - errorMessage += - 'This could be because there is no index.md file in the root of the docs directory of this repository.'; - } - throw new NotFoundError(errorMessage); - case 500: - errorMessage = - 'Could not generate documentation or an error in the TechDocs backend. '; - throw new Error(errorMessage); - default: - // Do nothing - break; - } - - return request.text(); - } - - /** - * Check if docs are the latest version and trigger rebuilds if not - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version - * @throws {Error} Throws error on error from sync endpoint - */ - async syncEntityDocs(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; - let request; - let attempts: number = 0; - // retry if request times out, up to 5 times - // can happen due to docs taking too long to generate - while (!request || (request.status === 408 && attempts < 5)) { - attempts++; - request = await this.fetchUrl( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - ); - } - - switch (request.status) { - case 404: - throw (await request.json()).error; - case 200: - case 201: - return true; - // for timeout and misc errors, handle without error to allow viewing older docs - // if older docs not available, - // Reader will show 404 error coming from getEntityDocs - case 408: - default: - return false; - } - } - - async getBaseUrl( - oldBaseUrl: string, - entityId: EntityName, - path: string, - ): Promise { - const { name } = entityId; - const apiOrigin = await this.getApiOrigin(); - return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString(); - } -} diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 3eefa0a814..a778161b35 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,11 +14,105 @@ * limitations under the License. */ -import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core'; +import { + configApiRef, + discoveryApiRef, + Header, + identityApiRef, + Page, + TabbedLayout, +} from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { techdocsPlugin } from '../src/plugin'; -import { TechDocsDevStorageApi } from './api'; -import { techdocsStorageApiRef } from '../src'; +import { NotFoundError } from '@backstage/errors'; +import React from 'react'; +import { + Reader, + SyncResult, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '../src'; + +// used so each route can provide it's own implementation in the constructor of the react component +let apiHolder: TechDocsStorageApi | undefined = undefined; + +const apiBridge: TechDocsStorageApi = { + getApiOrigin: async () => '', + getBaseUrl: (...args) => apiHolder!.getBaseUrl(...args), + getBuilder: () => apiHolder!.getBuilder(), + getStorageUrl: () => apiHolder!.getStorageUrl(), + getEntityDocs: (...args) => apiHolder!.getEntityDocs(...args), + syncEntityDocs: (...args) => apiHolder!.syncEntityDocs(...args), +}; + +const mockContent = ` +

Hello World!

+

This is an example content that will actually be provided by a MkDocs powered site

+`; + +function createPage({ + entityDocs, + syncDocs, + syncDocsDelay, +}: { + entityDocs?: (props: { + called: number; + content: string; + }) => string | Promise; + syncDocs: () => SyncResult; + syncDocsDelay?: number; +}) { + class Api implements TechDocsStorageApi { + private entityDocsCallCount: number = 0; + + getApiOrigin = async () => ''; + getBaseUrl = async () => ''; + getBuilder = async () => 'local'; + getStorageUrl = async () => ''; + + async getEntityDocs() { + await new Promise(resolve => setTimeout(resolve, 500)); + + if (!entityDocs) { + return mockContent; + } + + return entityDocs({ + called: this.entityDocsCallCount++, + content: mockContent, + }); + } + + async syncEntityDocs() { + if (syncDocsDelay) { + await new Promise(resolve => setTimeout(resolve, syncDocsDelay)); + } + + return syncDocs(); + } + } + + class Component extends React.Component { + constructor(props: {}) { + super(props); + + apiHolder = new Api(); + } + + render() { + return ( + + ); + } + } + + return ; +} createDevApp() .registerApi({ @@ -28,12 +122,89 @@ createDevApp() discoveryApi: discoveryApiRef, identityApi: identityApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsDevStorageApi({ - configApi, - discoveryApi, - identityApi, - }), + factory: () => apiBridge, + }) + + .addPage({ + title: 'TechDocs', + element: ( + +
+ + + {createPage({ + syncDocs: () => 'cached', + })} + + + + {createPage({ + syncDocs: () => 'updated', + syncDocsDelay: 2000, + })} + + + + {createPage({ + entityDocs: ({ called, content }) => { + if (called < 1) { + throw new NotFoundError(); + } + + return content; + }, + syncDocs: () => 'updated', + syncDocsDelay: 10000, + })} + + + + {createPage({ + entityDocs: () => { + throw new NotFoundError('Not found, some error message...'); + }, + syncDocs: () => 'cached', + })} + + + + {createPage({ + entityDocs: () => { + throw new Error('Another more critical error'); + }, + syncDocs: () => 'cached', + })} + + + + {createPage({ + syncDocs: () => { + throw new Error('Some random error'); + }, + syncDocsDelay: 2000, + })} + + + + {createPage({ + entityDocs: () => { + throw new Error('Some random error'); + }, + syncDocs: () => { + throw new Error('Some random error'); + }, + syncDocsDelay: 2000, + })} + + + + {createPage({ + syncDocs: () => 'timeout', + syncDocsDelay: 2000, + })} + + + + ), }) - .registerPlugin(techdocsPlugin) .render(); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 01b412b501..bc336ec976 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -56,6 +56,7 @@ "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.4.2", "@testing-library/user-event": "^13.1.8", "@types/react": "^16.9", "@types/jest": "^26.0.7", diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 204cc1b5a8..b9c8725ce9 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -28,12 +28,14 @@ export const techdocsApiRef = createApiRef({ description: 'Used to make requests towards techdocs API', }); +export type SyncResult = 'cached' | 'updated' | 'timeout'; + export interface TechDocsStorageApi { getApiOrigin(): Promise; getStorageUrl(): Promise; getBuilder(): Promise; getEntityDocs(entityId: EntityName, path: string): Promise; - syncEntityDocs(entityId: EntityName): Promise; + syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( oldBaseUrl: string, entityId: EntityName, diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 245cfb0154..83cfc88d56 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { NotFoundError } from '@backstage/errors'; -import { TechDocsApi, TechDocsStorageApi } from './api'; +import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api'; import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; /** @@ -192,10 +192,10 @@ export class TechDocsStorageClient implements TechDocsStorageApi { * Check if docs are on the latest version and trigger rebuild if not * * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version + * @returns {SyncResult} Whether documents are currently synchronized to newest version * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend */ - async syncEntityDocs(entityId: EntityName): Promise { + async syncEntityDocs(entityId: EntityName): Promise { const { kind, namespace, name } = entityId; const apiOrigin = await this.getApiOrigin(); @@ -215,16 +215,20 @@ export class TechDocsStorageClient implements TechDocsStorageApi { switch (request.status) { case 404: throw new NotFoundError((await request.json()).error); + case 200: - case 201: case 304: - return true; + return 'cached'; + + case 201: + return 'updated'; + // for timeout and misc errors, handle without error to allow viewing older docs // if older docs not available, // Reader will show 404 error coming from getEntityDocs case 408: default: - return false; + return 'timeout'; } } diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index c779985364..75dade20fa 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { EntityName } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { BackstageTheme } from '@backstage/theme'; import { useTheme } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; import { addBaseUrl, @@ -37,7 +37,7 @@ import { } from '../transformers'; import { TechDocsNotFound } from './TechDocsNotFound'; import TechDocsProgressBar from './TechDocsProgressBar'; -import { useRawPage } from './useRawPage'; +import { useReaderState } from './useReaderState'; type Props = { entityId: EntityName; @@ -49,61 +49,37 @@ export const Reader = ({ entityId, onReady }: Props) => { const { '*': path } = useParams(); const theme = useTheme(); + const { state, content: rawPage, errorMessage } = useReaderState( + kind, + namespace, + name, + path, + ); + const techdocsStorageApi = useApi(techdocsStorageApiRef); const [sidebars, setSidebars] = useState(); const navigate = useNavigate(); const shadowDomRef = useRef(null); - const [loadedPath, setLoadedPath] = useState(''); - const [atInitialLoad, setAtInitialLoad] = useState(true); - const [newerDocsExist, setNewerDocsExist] = useState(false); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const { - value: isSynced, - loading: syncInProgress, - error: syncError, - } = useAsync(async () => { - // Attempt to sync only if `techdocs.builder` in app config is set to 'local' - if ((await techdocsStorageApi.getBuilder()) !== 'local') { - return Promise.resolve({ - value: true, - loading: null, - error: null, + const updateSidebarPosition = useCallback(() => { + if (!!shadowDomRef.current && !!sidebars) { + const mdTabs = shadowDomRef.current!.querySelector( + '.md-container > .md-tabs', + ); + sidebars!.forEach(sidebar => { + const newTop = Math.max( + shadowDomRef.current!.getBoundingClientRect().top, + 0, + ); + sidebar.style.top = mdTabs + ? `${newTop + mdTabs.getBoundingClientRect().height}px` + : `${newTop}px`; }); } - return techdocsStorageApi.syncEntityDocs({ kind, namespace, name }); - }, [techdocsStorageApi, kind, namespace, name]); - - const { - value: rawPage, - loading: docLoading, - error: docLoadError, - retry, - } = useRawPage(path, kind, namespace, name); + }, [shadowDomRef, sidebars]); useEffect(() => { - if (isSynced && newerDocsExist && path !== loadedPath) { - retry(); - } - }); - - useEffect(() => { - const updateSidebarPosition = () => { - if (!!shadowDomRef.current && !!sidebars) { - const mdTabs = shadowDomRef.current!.querySelector( - '.md-container > .md-tabs', - ); - sidebars!.forEach(sidebar => { - const newTop = Math.max( - shadowDomRef.current!.getBoundingClientRect().top, - 0, - ); - sidebar.style.top = mdTabs - ? `${newTop + mdTabs.getBoundingClientRect().height}px` - : `${newTop}px`; - }); - } - }; updateSidebarPosition(); window.addEventListener('scroll', updateSidebarPosition); window.addEventListener('resize', updateSidebarPosition); @@ -111,28 +87,8 @@ export const Reader = ({ entityId, onReady }: Props) => { window.removeEventListener('scroll', updateSidebarPosition); window.removeEventListener('resize', updateSidebarPosition); }; - }, [shadowDomRef, sidebars]); - - useEffect(() => { - if (rawPage) { - setLoadedPath(path); - } - }, [rawPage, path]); - - useEffect(() => { - if (atInitialLoad === false) { - return; - } - setTimeout(() => { - setAtInitialLoad(false); - }, 5000); - }); - - useEffect(() => { - if (!atInitialLoad && !!rawPage && syncInProgress) { - setNewerDocsExist(true); - } - }, [atInitialLoad, rawPage, syncInProgress]); + // an update to "state" might lead to an updated UI so we include it as a trigger + }, [updateSidebarPosition, state]); useEffect(() => { if (!rawPage || !shadowDomRef.current) { @@ -142,12 +98,16 @@ export const Reader = ({ entityId, onReady }: Props) => { onReady(); } // Pre-render - const transformedElement = transformer(rawPage.content, [ + const transformedElement = transformer(rawPage, [ sanitizeDOM(), addBaseUrl({ techdocsStorageApi, - entityId: rawPage.entityId, - path: rawPage.path, + entityId: { + kind, + name, + namespace, + }, + path, }), rewriteDocLinks(), removeMkdocsHeader(), @@ -292,10 +252,6 @@ export const Reader = ({ entityId, onReady }: Props) => { baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { const parsedUrl = new URL(url); - if (newerDocsExist && isSynced) { - // link navigation will load newer docs - setNewerDocsExist(false); - } if (parsedUrl.hash) { navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); @@ -337,6 +293,10 @@ export const Reader = ({ entityId, onReady }: Props) => { }), ]); }, [ + path, + kind, + namespace, + name, rawPage, navigate, onReady, @@ -347,39 +307,40 @@ export const Reader = ({ entityId, onReady }: Props) => { theme.palette.primary.main, theme.palette.background.paper, theme.palette.background.default, - newerDocsExist, - isSynced, scmIntegrationsApi, ]); - // docLoadError not considered an error state if sync request is still ongoing - // or sync just completed and doc is loading again - if ((docLoadError && !syncInProgress && !docLoading) || syncError) { - let errMessage = ''; - if (docLoadError) { - errMessage += ` Load error: ${docLoadError}`; - } - if (syncError) errMessage += ` Build error: ${syncError}`; - return ; - } - return ( <> - {newerDocsExist && !isSynced ? ( + {(state === 'CHECKING' || state === 'INITIAL_BUILD') && ( + + )} + {state === 'CONTENT_STALE_REFRESHING' && ( A newer version of this documentation is being prepared and will be available shortly. - ) : null} - {newerDocsExist && isSynced ? ( + )} + {state === 'CONTENT_STALE_READY' && ( A newer version of this documentation is now available, please refresh to view. - ) : null} - {docLoading || (docLoadError && syncInProgress) ? ( - - ) : null} + )} + {state === 'CONTENT_STALE_TIMEOUT' && ( + + Building a newer version of this documentation took longer than + expected. Please refresh to try again. + + )} + {state === 'CONTENT_STALE_ERROR' && ( + + Building a newer version of this documentation failed. {errorMessage} + + )} + {state === 'CONTENT_NOT_FOUND' && ( + + )}
); diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx new file mode 100644 index 0000000000..8a09241588 --- /dev/null +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -0,0 +1,446 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { NotFoundError } from '@backstage/errors'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { techdocsStorageApiRef } from '../../api'; +import { + calculateDisplayState, + reducer, + useReaderState, +} from './useReaderState'; + +describe('useReaderState', () => { + let Wrapper: React.ComponentType; + + const techdocsStorageApi: jest.Mocked = { + getApiOrigin: jest.fn(), + getBaseUrl: jest.fn(), + getBuilder: jest.fn(), + getEntityDocs: jest.fn(), + getStorageUrl: jest.fn(), + syncEntityDocs: jest.fn(), + }; + + beforeEach(() => { + const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('calculateDisplayState', () => { + it.each` + contentLoading | content | activeSyncState | expected + ${true} | ${''} | ${''} | ${'CHECKING'} + ${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'} + ${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'} + ${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'} + ${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'} + ${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'} + ${false} | ${'asdf'} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'} + ${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'} + ${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'} + `( + 'should, when contentLoading=$contentLoading and content="$content" and activeSyncState=$activeSyncState, resolve to $expected', + ({ contentLoading, content, activeSyncState, expected }) => { + expect( + calculateDisplayState({ + contentLoading, + content, + activeSyncState, + }), + ).toEqual(expected); + }, + ); + }); + + describe('reducer', () => { + const oldState: Parameters[0] = { + activeSyncState: 'CHECKING', + contentLoading: false, + path: '', + }; + + it('should return a copy of the state', () => { + expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({ + activeSyncState: 'CHECKING', + contentLoading: false, + path: '/', + }); + + expect(oldState).toEqual({ + activeSyncState: 'CHECKING', + contentLoading: false, + path: '', + }); + }); + + it.each` + type | oldActiveSyncState | newActiveSyncState + ${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'} + ${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'} + ${'navigate'} | ${'BUILD_READY'} | ${'UP_TO_DATE'} + ${'navigate'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'} + ${'sync'} | ${'BUILD_READY'} | ${undefined} + ${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined} + `( + 'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState', + ({ type, oldActiveSyncState, newActiveSyncState }) => { + expect( + reducer( + { + ...oldState, + activeSyncState: oldActiveSyncState, + }, + { type }, + ).activeSyncState, + ).toEqual(newActiveSyncState); + }, + ); + + describe('"content" action', () => { + it('should set loading', () => { + expect( + reducer( + { + ...oldState, + content: 'some-old-content', + contentError: new Error(), + }, + { + type: 'content', + contentLoading: true, + }, + ), + ).toEqual({ + ...oldState, + contentLoading: true, + }); + }); + + it('should set content', () => { + expect( + reducer( + { + ...oldState, + contentLoading: true, + contentError: new Error(), + }, + { + type: 'content', + content: 'asdf', + }, + ), + ).toEqual({ + ...oldState, + contentLoading: false, + content: 'asdf', + }); + }); + + it('should set error', () => { + expect( + reducer( + { + ...oldState, + contentLoading: true, + content: 'asdf', + }, + { + type: 'content', + contentError: new Error(), + }, + ), + ).toEqual({ + ...oldState, + contentLoading: false, + contentError: new Error(), + }); + }); + }); + + describe('"navigate" action', () => { + it('should work', () => { + expect( + reducer(oldState, { + type: 'navigate', + path: '/', + }), + ).toEqual({ + ...oldState, + path: '/', + }); + }); + }); + + describe('"sync" action', () => { + it('should update state', () => { + expect( + reducer(oldState, { + type: 'sync', + state: 'BUILDING', + }), + ).toEqual({ + ...oldState, + activeSyncState: 'BUILDING', + }); + }); + }); + }); + + describe('hook', () => { + it('should handle up-to-date content', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + return 'cached'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should reload initially missing content', async () => { + techdocsStorageApi.getEntityDocs + .mockRejectedValueOnce(new NotFoundError('Page Not Found')) + .mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 500)); + return 'my content'; + }); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 1100)); + return 'updated'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'INITIAL_BUILD', + content: undefined, + errorMessage: ' Load error: NotFoundError: Page Not Found', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2); + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledTimes(1); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle stale content', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 1100)); + return 'updated'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content is returned but the sync is in progress + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + // the sync takes longer than 1 seconds so the refreshing state starts + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_REFRESHING', + content: 'my content', + errorMessage: '', + }); + + // the content is up-to-date + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_READY', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle timed-out refresh', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockResolvedValue('timeout'); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content is returned but the sync is in progress + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_TIMEOUT', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle content error', async () => { + techdocsStorageApi.getEntityDocs.mockRejectedValue( + new NotFoundError('Some error description'), + ); + techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached'); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content loading threw an error + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_NOT_FOUND', + content: undefined, + errorMessage: ' Load error: NotFoundError: Some error description', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts new file mode 100644 index 0000000000..1dc4bc2677 --- /dev/null +++ b/plugins/techdocs/src/reader/components/useReaderState.ts @@ -0,0 +1,323 @@ +/* + * 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 { useApi } from '@backstage/core'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; +import { useAsync, useAsyncRetry } from 'react-use'; +import { techdocsStorageApiRef } from '../../api'; + +/** + * A state representation that is used to configure the UI of + */ +type ContentStateTypes = + /** There is nothing to display but a loading indicator */ + | 'CHECKING' + + /** There is no content yet -> present a full screen loading page */ + | 'INITIAL_BUILD' + + /** There is content, but the backend is about to update it */ + | 'CONTENT_STALE_REFRESHING' + + /** There is content, but after a reload, the content will be different */ + | 'CONTENT_STALE_READY' + + /** There is content, the backend tried to update it, but it took too long */ + | 'CONTENT_STALE_TIMEOUT' + + /** There is content, the backend tried to update it, but failed */ + | 'CONTENT_STALE_ERROR' + + /** There is nothing to see but a "not found" page. Is also shown on page load errors */ + | 'CONTENT_NOT_FOUND' + + /** There is only the latest and greatest content */ + | 'CONTENT_FRESH'; + +/** + * Calculate the state that should be reported to the display component. + */ +export function calculateDisplayState({ + contentLoading, + content, + activeSyncState, +}: Pick< + ReducerState, + 'contentLoading' | 'content' | 'activeSyncState' +>): ContentStateTypes { + // we have nothing to display yet + if (contentLoading) { + return 'CHECKING'; + } + + // the build is ready, but it triggered a content reload and the content variable is not trusted + if (activeSyncState === 'BUILD_READY_RELOAD') { + return 'CHECKING'; + } + + // there is no content, but the sync process is still evaluating + if (!content && activeSyncState === 'CHECKING') { + return 'CHECKING'; + } + + // there is no content yet so we assume that we are building it for the first time + if (!content && activeSyncState === 'BUILDING') { + return 'INITIAL_BUILD'; + } + + // if there is still no content after building, it might just not exist + if (!content) { + return 'CONTENT_NOT_FOUND'; + } + + // we are still building, but we already show stale content + if (activeSyncState === 'BUILDING') { + return 'CONTENT_STALE_REFRESHING'; + } + + // the build is ready, but the content is still stale + if (activeSyncState === 'BUILD_READY') { + return 'CONTENT_STALE_READY'; + } + + // the build timed out, but the content is still stale + if (activeSyncState === 'BUILD_TIMED_OUT') { + return 'CONTENT_STALE_TIMEOUT'; + } + + // the build failed, but the content is still stale + if (activeSyncState === 'ERROR') { + return 'CONTENT_STALE_ERROR'; + } + + // seems like the content is up-to-date (or we don't know yet and the sync process is still evaluating in the background) + return 'CONTENT_FRESH'; +} + +/** + * The state of the synchronization task. It checks whether the docs are + * up-to-date. If they aren't, it triggers a build. + */ +type SyncStates = + /** Checking if it should be synced */ + | 'CHECKING' + + /** Building the documentation */ + | 'BUILDING' + + /** Finished building the documentation */ + | 'BUILD_READY' + + /** + * Finished building the documentation and triggered a content reload. + * This state is left toward UP_TO_DATE when the content loading has finished. + */ + | 'BUILD_READY_RELOAD' + + /** Building the documentation timed out */ + | 'BUILD_TIMED_OUT' + + /** No need for a sync. The content was already up-to-date. */ + | 'UP_TO_DATE' + + /** An error occurred */ + | 'ERROR'; + +type ReducerActions = + | { + type: 'sync'; + state: SyncStates; + syncError?: Error; + } + | { + type: 'content'; + content?: string; + contentLoading?: true; + contentError?: Error; + } + | { type: 'navigate'; path: string }; + +type ReducerState = { + /** + * The path of the current page + */ + path: string; + + /** + * The current sync state + */ + activeSyncState: SyncStates; + + /** + * If true, the content is downloading from the storage. + */ + contentLoading: boolean; + /** + * The content that has been downloaded and should be displayed. + */ + content?: string; + + contentError?: Error; + syncError?: Error; +}; + +export function reducer( + oldState: ReducerState, + action: ReducerActions, +): ReducerState { + const newState = { ...oldState }; + + switch (action.type) { + case 'sync': + newState.activeSyncState = action.state; + newState.syncError = action.syncError; + break; + + case 'content': + newState.content = action.content; + newState.contentLoading = action.contentLoading ?? false; + newState.contentError = action.contentError; + break; + + case 'navigate': + newState.path = action.path; + break; + + default: + throw new Error(); + } + + // a navigation or a content update loads fresh content so the build is updated to being up-to-date + if ( + ['BUILD_READY', 'BUILD_READY_RELOAD'].includes(newState.activeSyncState) && + ['content', 'navigate'].includes(action.type) + ) { + newState.activeSyncState = 'UP_TO_DATE'; + } + + return newState; +} + +export function useReaderState( + kind: string, + namespace: string, + name: string, + path: string, +): { state: ContentStateTypes; content?: string; errorMessage?: string } { + const [state, dispatch] = useReducer(reducer, { + activeSyncState: 'CHECKING', + path, + contentLoading: true, + }); + + const techdocsStorageApi = useApi(techdocsStorageApiRef); + + // convert all path changes into actions + useEffect(() => { + dispatch({ type: 'navigate', path }); + }, [path]); + + // try to load the content. the function will fire events and we don't care for the return values + const { retry: contentReload } = useAsyncRetry(async () => { + dispatch({ type: 'content', contentLoading: true }); + + try { + const entityDocs = await techdocsStorageApi.getEntityDocs( + { kind, namespace, name }, + path, + ); + + dispatch({ type: 'content', content: entityDocs }); + + return entityDocs; + } catch (e) { + dispatch({ type: 'content', contentError: e }); + } + + return undefined; + }, [techdocsStorageApi, kind, namespace, name, path]); + + // create a ref that holds the latest content. This provides a useAsync hook + // with the latest content without restarting the useAsync hook. + const contentRef = useRef<{ content?: string; reload: () => void }>({ + content: undefined, + reload: () => {}, + }); + contentRef.current = { content: state.content, reload: contentReload }; + + // try to derive the state. the function will fire events and we don't care for the return values + useAsync(async () => { + dispatch({ type: 'sync', state: 'CHECKING' }); + + // should only switch to BUILDING if the request takes more than 1 seconds + const buildingTimeout = setTimeout(() => { + dispatch({ type: 'sync', state: 'BUILDING' }); + }, 1000); + + try { + const result = await techdocsStorageApi.syncEntityDocs({ + kind, + namespace, + name, + }); + + if (result === 'updated') { + // if there was no content prior to building, retry the loading + if (!contentRef.current.content) { + contentRef.current.reload(); + dispatch({ type: 'sync', state: 'BUILD_READY_RELOAD' }); + } else { + dispatch({ type: 'sync', state: 'BUILD_READY' }); + } + } else if (result === 'cached') { + dispatch({ type: 'sync', state: 'UP_TO_DATE' }); + } else { + dispatch({ type: 'sync', state: 'BUILD_TIMED_OUT' }); + } + } catch (e) { + dispatch({ type: 'sync', state: 'ERROR', syncError: e }); + } finally { + // Cancel the timer that sets the state "BUILDING" + clearTimeout(buildingTimeout); + } + }, [kind, name, namespace, techdocsStorageApi, dispatch, contentRef]); + + const displayState = useMemo( + () => + calculateDisplayState({ + activeSyncState: state.activeSyncState, + contentLoading: state.contentLoading, + content: state.content, + }), + [state.activeSyncState, state.content, state.contentLoading], + ); + + const errorMessage = useMemo(() => { + let errMessage = ''; + if (state.contentError) { + errMessage += ` Load error: ${state.contentError}`; + } + if (state.syncError) errMessage += ` Build error: ${state.syncError}`; + + return errMessage; + }, [state.syncError, state.contentError]); + + return { + state: displayState, + content: state.content, + errorMessage, + }; +} diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 6b6eae4b0d..9bfcbe624a 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -27,7 +27,7 @@ const techdocsStorageApi: TechDocsStorageApi = { Promise.resolve(new URL(o, DOC_STORAGE_URL).toString()), ), getEntityDocs: () => new Promise(resolve => resolve('yes!')), - syncEntityDocs: () => new Promise(resolve => resolve(true)), + syncEntityDocs: () => new Promise(resolve => resolve('updated')), getApiOrigin: jest.fn(() => new Promise(resolve => resolve(API_ORIGIN_URL))), getBuilder: jest.fn(), getStorageUrl: jest.fn(), diff --git a/plugins/todo-backend/api-report.md b/plugins/todo-backend/api-report.md new file mode 100644 index 0000000000..8bcfe30aee --- /dev/null +++ b/plugins/todo-backend/api-report.md @@ -0,0 +1,98 @@ +## API Report File for "@backstage/plugin-todo-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 { EntityName } from '@backstage/catalog-model'; +import express from 'express'; +import { Logger } from 'winston'; +import { ScmIntegrations } from '@backstage/integration'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export function createTodoParser(options?: TodoParserOptions): TodoParser; + +// @public (undocumented) +export type ListTodosRequest = { + entity?: EntityName; + offset?: number; + limit?: number; + orderBy?: { + field: Fields; + direction: 'asc' | 'desc'; + }; + filters?: { + field: Fields; + value: string; + }[]; +}; + +// @public (undocumented) +export type ListTodosResponse = { + items: TodoItem[]; + totalCount: number; + offset: number; + limit: number; +}; + +// @public (undocumented) +export type ReadTodosOptions = { + url: string; +}; + +// @public (undocumented) +export type ReadTodosResult = { + items: TodoItem[]; +}; + +// @public (undocumented) +export type TodoItem = { + text: string; + tag: string; + author?: string; + viewUrl?: string; + lineNumber?: number; + repoFilePath?: string; +}; + +// @public (undocumented) +export interface TodoReader { + readTodos(options: ReadTodosOptions): Promise; +} + +// @public (undocumented) +export class TodoReaderService implements TodoService { + constructor(options: Options_2); + // (undocumented) + listTodos(req: ListTodosRequest, options?: { + token?: string; + }): Promise; + } + +// @public (undocumented) +export class TodoScmReader implements TodoReader { + constructor(options: Options); + // (undocumented) + static fromConfig(config: Config, options: Omit): TodoScmReader; + // (undocumented) + readTodos({ url }: ReadTodosOptions): Promise; +} + +// @public (undocumented) +export interface TodoService { + // (undocumented) + listTodos(req: ListTodosRequest, options?: { + token?: string; + }): Promise; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/todo/api-report.md b/plugins/todo/api-report.md new file mode 100644 index 0000000000..bdfea8b8f9 --- /dev/null +++ b/plugins/todo/api-report.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/plugin-todo" + +> 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 { Entity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const EntityTodoContent: () => JSX.Element; + +// @public (undocumented) +export const todoApiRef: ApiRef; + +// @public (undocumented) +export const todoPlugin: BackstagePlugin<{}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md new file mode 100644 index 0000000000..2f882f3742 --- /dev/null +++ b/plugins/user-settings/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/plugin-user-settings" + +> 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 { IconComponent } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; +import { SessionApi } from '@backstage/core'; + +// @public (undocumented) +export const AuthProviders: ({ providerSettings }: Props_2) => JSX.Element; + +// @public (undocumented) +export const DefaultProviderSettings: ({ configuredProviders }: Props_3) => JSX.Element; + +// @public (undocumented) +export const ProviderSettingsItem: ({ title, description, icon: Icon, apiRef, }: Props_4) => JSX.Element; + +// @public (undocumented) +export const Router: ({ providerSettings }: Props) => JSX.Element; + +// @public (undocumented) +export const Settings: () => JSX.Element; + +// @public (undocumented) +export const UserSettingsPage: ({ providerSettings }: { + providerSettings?: JSX.Element | undefined; +}) => JSX.Element; + +// @public (undocumented) +const userSettingsPlugin: BackstagePlugin<{ + settingsPage: RouteRef; +}, {}>; + +export { userSettingsPlugin as plugin } + +export { userSettingsPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/welcome/api-report.md b/plugins/welcome/api-report.md new file mode 100644 index 0000000000..8d6e1cb156 --- /dev/null +++ b/plugins/welcome/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-welcome" + +> 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 WelcomePage: () => JSX.Element; + +// @public (undocumented) +const welcomePlugin: BackstagePlugin<{}, {}>; + +export { welcomePlugin as plugin } + +export { welcomePlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index f4aa0a7ddc..9ba59543f8 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -60,34 +60,60 @@ PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = function tryGetPackag return old.call(this, path); }; -const DOCUMENTED_PACKAGES = [ - 'packages/backend-common', - 'packages/backend-test-utils', - 'packages/catalog-client', - 'packages/catalog-model', - 'packages/cli-common', - 'packages/config', - 'packages/config-loader', - 'packages/core-app-api', +const PACKAGE_ROOTS = ['packages', 'plugins']; + +const SKIPPED_PACKAGES = [ + 'packages/app', + 'packages/backend', + 'packages/cli', + 'packages/codemods', + 'packages/create-app', + 'packages/docgen', + 'packages/e2e-test', + 'packages/storybook', + 'packages/techdocs-cli', + // TODO(Rugvip): Enable these once `import * as ...` and `import()` PRs have landed, #1796 & #1916. - // 'packages/core-components', - 'packages/core-plugin-api', - 'packages/dev-utils', - 'packages/errors', - 'packages/integration', - 'packages/integration-react', - 'packages/search-common', - 'packages/techdocs-common', - 'packages/test-utils', - 'packages/test-utils-core', - 'packages/theme', + 'packages/core', + 'packages/core-api', + 'packages/core-components', + 'plugins/catalog', + 'plugins/catalog-backend', + 'plugins/catalog-react', + 'plugins/github-deployments', + 'plugins/sentry-backend', ]; +async function findPackageDirs() { + const packageDirs = new Array(); + const projectRoot = resolvePath(__dirname, '..'); + + for (const packageRoot of PACKAGE_ROOTS) { + const dirs = await fs.readdir(resolvePath(projectRoot, packageRoot)); + for (const dir of dirs) { + const fullPackageDir = resolvePath(packageRoot, dir); + + const stat = await fs.stat(fullPackageDir); + if (!stat.isDirectory()) { + continue; + } + + const packageDir = relativePath(projectRoot, fullPackageDir); + if (!SKIPPED_PACKAGES.includes(packageDir)) { + packageDirs.push(packageDir); + } + } + } + + return packageDirs; +} + interface ApiExtractionOptions { packageDirs: string[]; outputDir: string; isLocalBuild: boolean; } + async function runApiExtraction({ packageDirs, outputDir, @@ -110,7 +136,9 @@ async function runApiExtraction({ configObject: { mainEntryPointFilePath: resolvePath( __dirname, - '../dist-types/packages//src/index.d.ts', + '../dist-types', + packageDir, + 'src/index.d.ts', ), bundledPackages: [], @@ -307,9 +335,11 @@ async function main() { const isCiBuild = process.argv.includes('--ci'); const isDocsBuild = process.argv.includes('--docs'); + const packageDirs = await findPackageDirs(); + console.log('# Generating package API reports'); await runApiExtraction({ - packageDirs: DOCUMENTED_PACKAGES, + packageDirs, outputDir: tmpDir, isLocalBuild: !isCiBuild, }); diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/yarn.lock b/yarn.lock index cdfa538e80..208ad9e952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,7 @@ "@backstage/catalog-client" "^0.3.13" "@backstage/catalog-model" "^0.8.2" "@backstage/core" "^0.7.12" + "@backstage/core-plugin-api" "^0.1.2" "@backstage/errors" "^0.1.1" "@backstage/integration" "^0.5.6" "@backstage/integration-react" "^0.1.3"