Merge branch 'master' of github.com:backstage/backstage into blam/tubgoat
* 'master' of github.com:backstage/backstage: (94 commits) return same data for all product insights requests in mock client Feat: Bump GitHub Insights plugin version (#3509) chore: set the port as number if it comes through as a string Add tests for components in the api-docs plugin backend-common: allow port in config to be both a number or a string Update swagger-ui-react to 3.37.2 Add Kubernetes plugin (#3505) Use case-insensitive filters techdocs-backend: update Gitlab clone auth Fix review comments Update dark theme colors Drop fill opacity for lighter colors Fix another Windows test issue backend,yarnrc: bring yarn network-timeout down to 300s Refactor the hooks and also support users owning components backend: remove yarn cache from built docker image Make backend-commons tests work on Windows changeset stack rank product menu items; add mock data Filter the response headers in the proxy backend ...
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Optimized the `yarn install` step in the backend `Dockerfile`.
|
||||
|
||||
To apply these changes to an existing app, make the following changes to `packages/backend/Dockerfile`:
|
||||
|
||||
Replace the `RUN yarn install ...` line with the following:
|
||||
|
||||
```bash
|
||||
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
Update swagger-ui-react to 3.37.2
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
Add breakdown view to the Cost Overview panel
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-backend': patch
|
||||
---
|
||||
|
||||
Update URL auth format for Gitlab clone
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-sentry': patch
|
||||
'@backstage/plugin-welcome': patch
|
||||
---
|
||||
|
||||
Refactor route registration to remove deprecating code
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/config-loader': minor
|
||||
---
|
||||
|
||||
Fix typo of "visibility" in config schema reference
|
||||
|
||||
If you have defined a config element named `visiblity`, you
|
||||
will need to fix the spelling to `visibility`. For more info,
|
||||
see https://backstage.io/docs/conf/defining#visibility.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Allow the `backend.listen.port` config to be both a number or a string.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Bump versions of `esbuild` and `rollup-plugin-esbuild`
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-proxy-backend': patch
|
||||
---
|
||||
|
||||
Filter the headers that are sent from the proxied-targed back to the frontend to not forwarded unwanted authentication or
|
||||
monitoring contexts from other origins (like `Set-Cookie` with e.g. a google analytics context). The implementation reuses
|
||||
the `allowedHeaders` configuration that now controls both directions `frontend->target` and `target->frontend`.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Removed `"resolutions"` entry for `esbuild` in the root `package.json` in order to use the version specified by `@backstage/cli`.
|
||||
|
||||
To apply this change to an existing app, remove the following from your root `package.json`:
|
||||
|
||||
```json
|
||||
"resolutions": {
|
||||
"esbuild": "0.6.3"
|
||||
},
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Add [API docs plugin](https://github.com/backstage/backstage/tree/master/plugins/api-docs) to new apps being created through the CLI.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
'@backstage/plugin-catalog-import': minor
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file:
|
||||
|
||||
```ts
|
||||
export default async function createPlugin(env: PluginEnvironment) {
|
||||
const builder = new CatalogBuilder(env);
|
||||
const {
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer, //<--
|
||||
} = await builder.build();
|
||||
|
||||
return await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer, //<--
|
||||
logger: env.logger,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Gracefully handle missing codeowners.
|
||||
|
||||
The CodeOwnersProcessor now also takes a logger as a parameter.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/cli': minor
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
sort product panels and navigation menu by greatest cost
|
||||
update tsconfig.json to use ES2020 api
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Use type EntityName from catalog-model for entities
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-proxy-backend': patch
|
||||
---
|
||||
|
||||
Add configuration schema for the commonly used properties
|
||||
@@ -230,3 +230,4 @@ yaml
|
||||
Zalando
|
||||
Zhou
|
||||
Zolotusky
|
||||
zoomable
|
||||
|
||||
@@ -6,4 +6,4 @@ registry "https://registry.npmjs.org/"
|
||||
disable-self-update-check true
|
||||
lastUpdateCheck 1580389148099
|
||||
yarn-path ".yarn/releases/yarn-1.22.1.js"
|
||||
network-timeout 600000
|
||||
network-timeout 300000
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ proxy:
|
||||
headers:
|
||||
Authorization:
|
||||
$env: TRAVISCI_AUTH_TOKEN
|
||||
travis-api-version: 3
|
||||
travis-api-version: '3'
|
||||
|
||||
'/newrelic/apm/api':
|
||||
target: https://api.newrelic.com/v2
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Documentation on TechDocs Architecture
|
||||
When you deploy Backstage (with TechDocs enabled by default), you get a basic
|
||||
out-of-the box experience.
|
||||
|
||||

|
||||
<img data-zoomable src="../../assets/techdocs/architecture-basic.drawio.svg" alt="TechDocs Architecture diagram" />
|
||||
|
||||
> Note: See below for our recommended deployment architecture which takes care
|
||||
> of stability, scalability and speed.
|
||||
@@ -43,7 +43,7 @@ channel to talk about it.
|
||||
|
||||
This is how we recommend deploying TechDocs in production environment.
|
||||
|
||||

|
||||
<img data-zoomable src="../../assets/techdocs/architecture-recommended.drawio.svg" alt="TechDocs Architecture diagram" />
|
||||
|
||||
The key difference in the recommended deployment approach is where the docs are
|
||||
built.
|
||||
|
||||
@@ -6,7 +6,53 @@ description: Documentation on How Configuring App with plugins
|
||||
|
||||
## Adding existing plugins to your app
|
||||
|
||||
Coming soon!
|
||||
The following steps assume that you have created a new Backstage app and want to
|
||||
add an existing plugin to it. We are using the
|
||||
[CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md)
|
||||
plugin in this example.
|
||||
|
||||
1. Add the plugin's NPM package to the repo:
|
||||
|
||||
```bash
|
||||
yarn add @backstage/plugin-circleci
|
||||
```
|
||||
|
||||
2. Add the plugin itself:
|
||||
|
||||
```js
|
||||
// packages/app/src/plugins.ts
|
||||
export { plugin as Circleci } from '@backstage/plugin-circleci';
|
||||
```
|
||||
|
||||
3. Register the plugin router:
|
||||
|
||||
```jsx
|
||||
// packages/app/src/components/catalog/EntityPage.tsx
|
||||
|
||||
import { Router as CircleCIRouter } from '@backstage/plugin-circleci';
|
||||
|
||||
// Then somewhere inside <EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/ci-cd/*"
|
||||
title="CI/CD"
|
||||
element={<CircleCIRouter />}
|
||||
/>;
|
||||
```
|
||||
|
||||
Note that stand-alone plugins that are not "attached" to the Software Catalog
|
||||
would be added outside the `EntityPage`.
|
||||
|
||||
4. [Optional] Add proxy config:
|
||||
|
||||
```yaml
|
||||
// app-config.yaml
|
||||
proxy:
|
||||
'/circleci/api':
|
||||
target: https://circleci.com/api/v1.1
|
||||
headers:
|
||||
Circle-Token:
|
||||
$env: CIRCLECI_AUTH_TOKEN
|
||||
```
|
||||
|
||||
### Adding a plugin page to the Sidebar
|
||||
|
||||
|
||||
+20
-13
@@ -52,19 +52,7 @@ configuration will lead to the proxy acting on backend requests to
|
||||
|
||||
The value inside each route is either a simple URL string, or an object on the
|
||||
format accepted by
|
||||
[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). It
|
||||
is also possible to limit the forwarded HTTP methods with the configuration
|
||||
`allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only
|
||||
access.
|
||||
|
||||
By default, the proxy will only forward safe HTTP request headers to the target.
|
||||
Those are based on the headers that are considered safe for CORS and includes
|
||||
headers like `content-type` or `last-modified`, as well as all headers that are
|
||||
set by the proxy. If the proxy should forward other headers like
|
||||
`authorization`, this must be enabled by the `allowedHeaders` config, for
|
||||
example `allowedHeaders: ['Authorization']`. This should help to not
|
||||
accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to
|
||||
third-parties.
|
||||
[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware).
|
||||
|
||||
If the value is a string, it is assumed to correspond to:
|
||||
|
||||
@@ -85,3 +73,22 @@ except with the following caveats for convenience:
|
||||
`'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to
|
||||
`/api/proxy/larger-example/v1/some/path` will be translated to a request to
|
||||
`http://larger.example.com:8080/svc.v1/some/path`.
|
||||
|
||||
There are also additional settings:
|
||||
|
||||
- `allowedMethods`: Limit the forwarded HTTP methods. For example
|
||||
`allowedMethods: ['GET']` enforces read-only access.
|
||||
- `allowedHeaders`: A list of headers that should be forwarded to and received
|
||||
from the target.
|
||||
|
||||
By default, the proxy will only forward safe HTTP request headers to the target.
|
||||
Those are based on the headers that are considered safe for CORS and includes
|
||||
headers like `content-type` or `last-modified`, as well as all headers that are
|
||||
set by the proxy. If the proxy should forward other headers like
|
||||
`authorization`, this must be enabled by the `allowedHeaders` config, for
|
||||
example `allowedHeaders: ['Authorization']`. This should help to not
|
||||
accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to
|
||||
third-parties.
|
||||
|
||||
The same logic applies to headers that are sent from the target back to the
|
||||
frontend.
|
||||
|
||||
@@ -4,7 +4,7 @@ title: createPlugin
|
||||
description: Documentation on createPlugin
|
||||
---
|
||||
|
||||
Taking a plugin config as argument and returns a new plugin.
|
||||
Takes a plugin config as an argument and returns a new plugin.
|
||||
|
||||
## Plugin Config
|
||||
|
||||
@@ -28,18 +28,22 @@ type PluginHooks = {
|
||||
|
||||
### Creating a basic plugin
|
||||
|
||||
Showcasing adding multiple routes, a feature flag and a redirect.
|
||||
Showcasing adding a route and a feature flag.
|
||||
|
||||
```jsx
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/new-plugin',
|
||||
title: 'New Plugin',
|
||||
});
|
||||
|
||||
export default createPlugin({
|
||||
id: 'new-plugin',
|
||||
register({ router, featureFlags }) {
|
||||
router.addRoute(rootRouteRef, ExampleComponent);
|
||||
featureFlags.register('enable-example-component');
|
||||
|
||||
router.registerRoute('/new-plugin', ExampleComponent);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -204,3 +204,8 @@ For more information about custom pages, click [here](https://docusaurus.io/docs
|
||||
# Full Documentation
|
||||
|
||||
Full documentation can be found on the [website](https://docusaurus.io/).
|
||||
|
||||
## Additional notes
|
||||
|
||||
- If you want to make images zoomable on click, add the `data-zoomable` attribute to your `img` element.
|
||||
- In a docs or blog `.md` file, convert `` syntax to `<img data-zoomable src="/microsite/static/img/code.png" alt="This is image" />`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Kubernetes
|
||||
author: Spotify
|
||||
authorUrl: https://github.com/spotify
|
||||
category: Kubernetes
|
||||
description: Surfaces components in a Kubernetes container orchestration environment into the Backstage catalog.
|
||||
documentation: https://github.com/backstage/backstage/tree/master/plugins/kubernetes
|
||||
iconUrl: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png
|
||||
npmPackageName: '@backstage/plugin-kubernetes'
|
||||
tags:
|
||||
- kubernetes
|
||||
- k8s
|
||||
@@ -17,7 +17,7 @@
|
||||
"@spotify/prettier-config": "^9.0.0",
|
||||
"docusaurus": "^2.0.0-alpha.66",
|
||||
"js-yaml": "^3.14.0",
|
||||
"prettier": "^2.2.0"
|
||||
"prettier": "^2.2.1"
|
||||
},
|
||||
"prettier": "@spotify/prettier-config"
|
||||
}
|
||||
|
||||
@@ -86,7 +86,11 @@ const siteConfig = {
|
||||
},
|
||||
|
||||
// Add custom scripts here that would be placed in <script> tags.
|
||||
scripts: ['https://buttons.github.io/buttons.js'],
|
||||
scripts: [
|
||||
'https://buttons.github.io/buttons.js',
|
||||
'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js',
|
||||
'/js/medium-zoom.js',
|
||||
],
|
||||
|
||||
// On page navigation for the current documentation page.
|
||||
onPageNav: 'separate',
|
||||
|
||||
@@ -1094,3 +1094,8 @@ code {
|
||||
margin: 0 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoomed images using the medium-zoom library should be on top of screen. */
|
||||
.medium-zoom-image {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Ref: https://github.com/francoischalifour/medium-zoom#options
|
||||
window.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
mediumZoom('[data-zoomable]', {
|
||||
margin: 20,
|
||||
background: '#000',
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
+4
-4
@@ -5204,10 +5204,10 @@ prepend-http@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
|
||||
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
|
||||
|
||||
prettier@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.0.tgz#8a03c7777883b29b37fb2c4348c66a78e980418b"
|
||||
integrity sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==
|
||||
prettier@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"
|
||||
integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
|
||||
|
||||
prismjs@^1.17.1:
|
||||
version "1.21.0"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@backstage/core": "^0.3.2",
|
||||
"@backstage/plugin-api-docs": "^0.3.0",
|
||||
"@backstage/plugin-catalog": "^0.2.4",
|
||||
"@backstage/plugin-catalog-import": "^0.2.0",
|
||||
"@backstage/plugin-circleci": "^0.2.2",
|
||||
"@backstage/plugin-cloudbuild": "^0.2.2",
|
||||
"@backstage/plugin-cost-insights": "^0.4.1",
|
||||
@@ -35,7 +36,7 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@octokit/rest": "^18.0.0",
|
||||
"@roadiehq/backstage-plugin-github-insights": "^0.2.15",
|
||||
"@roadiehq/backstage-plugin-github-insights": "^0.2.16",
|
||||
"@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3",
|
||||
"@roadiehq/backstage-plugin-travis-ci": "^0.2.8",
|
||||
"@roadiehq/backstage-plugin-buildkite": "^0.1.3",
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
|
||||
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
|
||||
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
|
||||
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
|
||||
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
|
||||
import { Route, Routes, Navigate } from 'react-router';
|
||||
|
||||
import { EntityPage } from './components/catalog/EntityPage';
|
||||
@@ -67,6 +68,10 @@ const catalogRouteRef = createRouteRef({
|
||||
const AppRoutes = () => (
|
||||
<Routes>
|
||||
<Navigate key="/" to="/catalog" />
|
||||
<Route
|
||||
path="/catalog-import/*"
|
||||
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${catalogRouteRef.path}/*`}
|
||||
element={<CatalogRouter EntityPage={EntityPage} />}
|
||||
|
||||
@@ -37,6 +37,7 @@ export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
|
||||
export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild';
|
||||
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
|
||||
export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights';
|
||||
export { plugin as CatalogImport } from '@backstage/plugin-catalog-import';
|
||||
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
|
||||
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
|
||||
export { plugin as Search } from '@backstage/plugin-search';
|
||||
|
||||
Vendored
+1
-1
@@ -29,7 +29,7 @@ export interface Config {
|
||||
/** Address of the interface that the backend should bind to. */
|
||||
address?: string;
|
||||
/** Port that the backend should listen to. */
|
||||
port?: number;
|
||||
port?: string | number;
|
||||
};
|
||||
|
||||
/** HTTPS configuration for the backend. If omitted the backend will serve HTTP */
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('TarArchiveResponse', () => {
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
expect(dir).toMatch(/^\/tmp\/.*$/);
|
||||
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('ZipArchiveResponse', () => {
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
expect(dir).toMatch(/^\/tmp\/.*$/);
|
||||
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
|
||||
@@ -85,7 +85,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
|
||||
const baseOptions = readBaseOptions(backendConfig);
|
||||
if (baseOptions.listenPort) {
|
||||
this.port = baseOptions.listenPort;
|
||||
this.port =
|
||||
typeof baseOptions.listenPort === 'string'
|
||||
? parseInt(baseOptions.listenPort, 10)
|
||||
: baseOptions.listenPort;
|
||||
}
|
||||
if (baseOptions.listenHost) {
|
||||
this.host = baseOptions.listenHost;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Config } from '@backstage/config';
|
||||
import { CorsOptions } from 'cors';
|
||||
|
||||
export type BaseOptions = {
|
||||
listenPort?: number;
|
||||
listenPort?: string | number;
|
||||
listenHost?: string;
|
||||
};
|
||||
|
||||
@@ -89,8 +89,19 @@ export function readBaseOptions(config: Config): BaseOptions {
|
||||
});
|
||||
}
|
||||
|
||||
const port = config.getOptional('listen.port');
|
||||
if (
|
||||
typeof port !== 'undefined' &&
|
||||
typeof port !== 'number' &&
|
||||
typeof port !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid type in config for key 'backend.listen.post', got ${typeof port}, wanted string or number`,
|
||||
);
|
||||
}
|
||||
|
||||
return removeUnknown({
|
||||
listenPort: config.getOptionalNumber('listen.port'),
|
||||
listenPort: port,
|
||||
listenHost: config.getOptionalString('listen.host'),
|
||||
baseUrl: config.getOptionalString('baseUrl'),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /usr/src/app
|
||||
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
|
||||
ADD yarn.lock package.json skeleton.tar ./
|
||||
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
|
||||
|
||||
# This will copy the contents of the dist-workspace when running the build-image command.
|
||||
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
|
||||
|
||||
@@ -28,6 +28,7 @@ export default async function createPlugin(env: PluginEnvironment) {
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
} = await builder.build();
|
||||
|
||||
useHotCleanup(
|
||||
@@ -39,6 +40,7 @@ export default async function createPlugin(env: PluginEnvironment) {
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
logger: env.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,5 +15,9 @@
|
||||
*/
|
||||
|
||||
export type { Location, LocationSpec } from './types';
|
||||
export { locationSchema, locationSpecSchema } from './validation';
|
||||
export {
|
||||
locationSchema,
|
||||
locationSpecSchema,
|
||||
analyzeLocationSchema,
|
||||
} from './validation';
|
||||
export { LOCATION_ANNOTATION } from './annotation';
|
||||
|
||||
@@ -34,3 +34,10 @@ export const locationSchema = yup
|
||||
})
|
||||
.noUnknown()
|
||||
.required();
|
||||
|
||||
export const analyzeLocationSchema = yup
|
||||
.object<{ location: LocationSpec }>({
|
||||
location: locationSpecSchema,
|
||||
})
|
||||
.noUnknown()
|
||||
.required();
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react",
|
||||
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019", "ESNext.Promise"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": false,
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"css-loader": "^3.5.3",
|
||||
"dashify": "^2.0.0",
|
||||
"diff": "^4.0.2",
|
||||
"esbuild": "^0.7.7",
|
||||
"esbuild": "^0.8.16",
|
||||
"eslint": "^7.1.0",
|
||||
"eslint-config-prettier": "^6.0.0",
|
||||
"eslint-formatter-friendly": "^7.0.0",
|
||||
@@ -88,7 +88,7 @@
|
||||
"replace-in-file": "^6.0.0",
|
||||
"rollup": "2.33.x",
|
||||
"rollup-plugin-dts": "1.4.13",
|
||||
"rollup-plugin-esbuild": "2.3.x",
|
||||
"rollup-plugin-esbuild": "2.6.x",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.2",
|
||||
"rollup-plugin-postcss": "^3.1.1",
|
||||
"rollup-plugin-typescript2": "^0.27.3",
|
||||
|
||||
@@ -25,7 +25,7 @@ export default async (cmd: Command) => {
|
||||
args: cmd.config,
|
||||
fromPackage: cmd.package,
|
||||
});
|
||||
const visibility = getVisiblityOption(cmd);
|
||||
const visibility = getVisibilityOption(cmd);
|
||||
const data = serializeConfigData(appConfigs, schema, visibility);
|
||||
|
||||
if (cmd.format === 'json') {
|
||||
@@ -35,7 +35,7 @@ export default async (cmd: Command) => {
|
||||
}
|
||||
};
|
||||
|
||||
function getVisiblityOption(cmd: Command): ConfigVisibility {
|
||||
function getVisibilityOption(cmd: Command): ConfigVisibility {
|
||||
if (cmd.frontend && cmd.withSecrets) {
|
||||
throw new Error('Not allowed to combine frontend and secret config');
|
||||
}
|
||||
@@ -50,14 +50,14 @@ function getVisiblityOption(cmd: Command): ConfigVisibility {
|
||||
function serializeConfigData(
|
||||
appConfigs: AppConfig[],
|
||||
schema: ConfigSchema,
|
||||
visiblity: ConfigVisibility,
|
||||
visibility: ConfigVisibility,
|
||||
) {
|
||||
if (visiblity === 'frontend') {
|
||||
if (visibility === 'frontend') {
|
||||
const frontendConfigs = schema.process(appConfigs, {
|
||||
visiblity: ['frontend'],
|
||||
visibility: ['frontend'],
|
||||
});
|
||||
return ConfigReader.fromConfigs(frontendConfigs).get();
|
||||
} else if (visiblity === 'secret') {
|
||||
} else if (visibility === 'secret') {
|
||||
return ConfigReader.fromConfigs(appConfigs).get();
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,23 @@ async function build(config: RollupOptions) {
|
||||
}
|
||||
|
||||
export const buildPackage = async (options: BuildOptions) => {
|
||||
try {
|
||||
const { resolutions } = await fs.readJson(
|
||||
paths.resolveTargetRoot('package.json'),
|
||||
);
|
||||
if (resolutions?.esbuild) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
'Your root package.json contains a "resolutions" entry for "esbuild". This was ' +
|
||||
'included in older @backstage/create-app templates in order to work around build ' +
|
||||
'issues that have since been fixed. Please remove the entry and run `yarn install`',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* Errors ignored, this is just a warning */
|
||||
}
|
||||
|
||||
const configs = await makeConfigs(options);
|
||||
await fs.remove(paths.resolveTarget('dist'));
|
||||
await Promise.all(configs.map(build));
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function loadCliConfig(options: Options) {
|
||||
|
||||
try {
|
||||
const frontendAppConfigs = schema.process(appConfigs, {
|
||||
visiblity: ['frontend'],
|
||||
visibility: ['frontend'],
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import mockFs from 'mock-fs';
|
||||
import path from 'path';
|
||||
import * as runObj from '../run';
|
||||
import { paths } from '../paths';
|
||||
import { fetchPackageInfo, mapDependencies } from './packages';
|
||||
@@ -54,16 +55,16 @@ describe('mapDependencies', () => {
|
||||
await project.getPackages();
|
||||
|
||||
mockFs({
|
||||
'/lerna.json': JSON.stringify({
|
||||
'lerna.json': JSON.stringify({
|
||||
packages: ['pkgs/*'],
|
||||
}),
|
||||
'/pkgs/a/package.json': JSON.stringify({
|
||||
'pkgs/a/package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '1 || 2',
|
||||
},
|
||||
}),
|
||||
'/pkgs/b/package.json': JSON.stringify({
|
||||
'pkgs/b/package.json': JSON.stringify({
|
||||
name: 'b',
|
||||
dependencies: {
|
||||
'@backstage/core': '3',
|
||||
@@ -72,9 +73,6 @@ describe('mapDependencies', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const oldDir = paths.targetDir;
|
||||
paths.targetDir = '/';
|
||||
|
||||
const dependencyMap = await mapDependencies(paths.targetDir);
|
||||
expect(Array.from(dependencyMap)).toEqual([
|
||||
[
|
||||
@@ -83,12 +81,12 @@ describe('mapDependencies', () => {
|
||||
{
|
||||
name: 'a',
|
||||
range: '1 || 2',
|
||||
location: '/pkgs/a',
|
||||
location: path.resolve('pkgs', 'a'),
|
||||
},
|
||||
{
|
||||
name: 'b',
|
||||
range: '3',
|
||||
location: '/pkgs/b',
|
||||
location: path.resolve('pkgs', 'b'),
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -98,12 +96,10 @@ describe('mapDependencies', () => {
|
||||
{
|
||||
name: 'b',
|
||||
range: '^0',
|
||||
location: '/pkgs/b',
|
||||
location: path.resolve('pkgs', 'b'),
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
paths.targetDir = oldDir;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('compileConfigSchemas', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject visiblity conflicts', () => {
|
||||
it('should reject visibility conflicts', () => {
|
||||
expect(() =>
|
||||
compileConfigSchemas([
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ const data = {
|
||||
objS: { never: 'here' },
|
||||
};
|
||||
|
||||
const visiblity = new Map<string, ConfigVisibility>(
|
||||
const visibility = new Map<string, ConfigVisibility>(
|
||||
Object.entries({
|
||||
'.arr.0': 'frontend',
|
||||
'.arr.1': 'backend',
|
||||
@@ -100,6 +100,6 @@ describe('filterByVisibility', () => {
|
||||
],
|
||||
[['frontend', 'backend', 'secret'], data],
|
||||
])('should filter correctly with %p', (filter, expected) => {
|
||||
expect(filterByVisibility(data, filter, visiblity)).toEqual(expected);
|
||||
expect(filterByVisibility(data, filter, visibility)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,12 +61,12 @@ describe('loadConfigSchema', () => {
|
||||
const configs = [{ data: { key1: 'a', key2: 2 }, context: 'test' }];
|
||||
|
||||
expect(schema.process(configs)).toEqual(configs);
|
||||
expect(schema.process(configs, { visiblity: ['frontend'] })).toEqual([
|
||||
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
|
||||
{ data: { key1: 'a' }, context: 'test' },
|
||||
]);
|
||||
expect(
|
||||
schema.process(configs, {
|
||||
visiblity: ['frontend'],
|
||||
visibility: ['frontend'],
|
||||
valueTransform: () => 'X',
|
||||
}),
|
||||
).toEqual([{ data: { key1: 'X' }, context: 'test' }]);
|
||||
@@ -79,7 +79,7 @@ describe('loadConfigSchema', () => {
|
||||
const serialized = schema.serialize();
|
||||
|
||||
const schema2 = await loadConfigSchema({ serialized });
|
||||
expect(schema2.process(configs, { visiblity: ['frontend'] })).toEqual([
|
||||
expect(schema2.process(configs, { visibility: ['frontend'] })).toEqual([
|
||||
{ data: { key1: 'a' }, context: 'test' },
|
||||
]);
|
||||
expect(() =>
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function loadConfigSchema(
|
||||
return {
|
||||
process(
|
||||
configs: AppConfig[],
|
||||
{ visiblity, valueTransform } = {},
|
||||
{ visibility, valueTransform } = {},
|
||||
): AppConfig[] {
|
||||
const result = validate(configs);
|
||||
if (result.errors) {
|
||||
@@ -70,12 +70,12 @@ export async function loadConfigSchema(
|
||||
|
||||
let processedConfigs = configs;
|
||||
|
||||
if (visiblity) {
|
||||
if (visibility) {
|
||||
processedConfigs = processedConfigs.map(({ data, context }) => ({
|
||||
context,
|
||||
data: filterByVisibility(
|
||||
data,
|
||||
visiblity,
|
||||
visibility,
|
||||
result.visibilityByPath,
|
||||
valueTransform,
|
||||
),
|
||||
|
||||
@@ -87,7 +87,7 @@ type ConfigProcessingOptions = {
|
||||
* The visibilities that should be included in the output data.
|
||||
* If omitted, the data will not be filtered by visibility.
|
||||
*/
|
||||
visiblity?: ConfigVisibility[];
|
||||
visibility?: ConfigVisibility[];
|
||||
|
||||
/**
|
||||
* A transform function that can be used to transform primitive configuration values
|
||||
|
||||
@@ -184,7 +184,13 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
}
|
||||
|
||||
routes.push(<Route path="/*" element={<NotFoundErrorPage />} />);
|
||||
routes.push(
|
||||
<Route
|
||||
key="not-found-error-page"
|
||||
path="/*"
|
||||
element={<NotFoundErrorPage />}
|
||||
/>,
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export type RouterHooks = {
|
||||
|
||||
/**
|
||||
* @deprecated See the `addRoute` method
|
||||
* @see https://github.com/backstage/backstage/issues/418
|
||||
*/
|
||||
registerRoute(
|
||||
path: RoutePath,
|
||||
|
||||
@@ -32,9 +32,6 @@
|
||||
"lerna": "^3.20.2",
|
||||
"prettier": "^1.19.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"esbuild": "0.6.3"
|
||||
},
|
||||
"prettier": "@spotify/prettier-config",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}",
|
||||
"@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}",
|
||||
"@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}",
|
||||
"@backstage/plugin-explore": "^{{version '@backstage/plugin-explore'}}",
|
||||
"@backstage/plugin-lighthouse": "^{{version '@backstage/plugin-lighthouse'}}",
|
||||
"@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}",
|
||||
"@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}",
|
||||
|
||||
@@ -3,7 +3,6 @@ export { plugin as CatalogPlugin } from '@backstage/plugin-catalog';
|
||||
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
|
||||
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
|
||||
export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs';
|
||||
export { plugin as Explore } from '@backstage/plugin-explore';
|
||||
export { plugin as Circleci } from '@backstage/plugin-circleci';
|
||||
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
|
||||
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FC, useContext } from 'react';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import LibraryBooks from '@material-ui/icons/LibraryBooks';
|
||||
import ExtensionIcon from '@material-ui/icons/Extension';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import BuildIcon from '@material-ui/icons/BuildRounded';
|
||||
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
|
||||
@@ -26,6 +27,7 @@ export const AppSidebar = () => (
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="./" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /usr/src/app
|
||||
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
|
||||
ADD yarn.lock package.json skeleton.tar ./
|
||||
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
|
||||
|
||||
# This will copy the contents of the dist-workspace when running the build-image command.
|
||||
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3",
|
||||
"swagger-ui-react": "^3.31.1"
|
||||
"swagger-ui-react": "^3.37.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.3.2",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
|
||||
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
|
||||
import { ApiDefinitionCard } from './ApiDefinitionCard';
|
||||
|
||||
describe('<ApiDefinitionCard />', () => {
|
||||
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
|
||||
getApiDefinitionWidget: jest.fn(),
|
||||
} as any;
|
||||
let Wrapper: React.ComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
|
||||
|
||||
Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders API', async () => {
|
||||
const definition = `
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: Artist API
|
||||
license:
|
||||
name: MIT
|
||||
servers:
|
||||
- url: http://artist.spotify.net/v1
|
||||
paths:
|
||||
/artists:
|
||||
get:
|
||||
summary: List all artists
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
`;
|
||||
const apiEntity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: '...',
|
||||
owner: '...',
|
||||
definition,
|
||||
},
|
||||
};
|
||||
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
rawLanguage: 'yaml',
|
||||
component: definition => (
|
||||
<OpenApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ApiDefinitionCard apiEntity={apiEntity} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/my-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/OpenAPI/)).toBeInTheDocument();
|
||||
expect(getByText(/Raw/i)).toBeInTheDocument();
|
||||
expect(getByText(/List all artists/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fallback to plain view', async () => {
|
||||
const apiEntity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
},
|
||||
spec: {
|
||||
type: 'custom-type',
|
||||
lifecycle: '...',
|
||||
owner: '...',
|
||||
definition: 'Custom Definition',
|
||||
},
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ApiDefinitionCard apiEntity={apiEntity} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/my-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/custom-type/i)).toBeInTheDocument();
|
||||
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,50 +15,11 @@
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { CardTab, useApi, TabbedCard } from '@backstage/core';
|
||||
import { CardTab, TabbedCard, useApi } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
|
||||
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
|
||||
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
|
||||
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
|
||||
|
||||
export type ApiDefinitionWidget = {
|
||||
type: string;
|
||||
title: string;
|
||||
component: (definition: string) => React.ReactElement;
|
||||
rawLanguage?: string;
|
||||
};
|
||||
|
||||
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
|
||||
return [
|
||||
{
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
rawLanguage: 'yaml',
|
||||
component: definition => (
|
||||
<OpenApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'asyncapi',
|
||||
title: 'AsyncAPI',
|
||||
rawLanguage: 'yaml',
|
||||
component: definition => (
|
||||
<AsyncApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'graphql',
|
||||
title: 'GraphQL',
|
||||
rawLanguage: 'graphql',
|
||||
component: definition => (
|
||||
<GraphQlDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
apiEntity?: ApiEntity;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
|
||||
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
|
||||
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
|
||||
|
||||
export type ApiDefinitionWidget = {
|
||||
type: string;
|
||||
title: string;
|
||||
component: (definition: string) => React.ReactElement;
|
||||
rawLanguage?: string;
|
||||
};
|
||||
|
||||
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
|
||||
return [
|
||||
{
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
rawLanguage: 'yaml',
|
||||
component: definition => (
|
||||
<OpenApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'asyncapi',
|
||||
title: 'AsyncAPI',
|
||||
rawLanguage: 'yaml',
|
||||
component: definition => (
|
||||
<AsyncApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'graphql',
|
||||
title: 'GraphQL',
|
||||
rawLanguage: 'graphql',
|
||||
component: definition => (
|
||||
<GraphQlDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
|
||||
import { ApiTypeTitle } from './ApiTypeTitle';
|
||||
|
||||
describe('<ApiTypeTitle />', () => {
|
||||
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
|
||||
getApiDefinitionWidget: jest.fn(),
|
||||
} as any;
|
||||
let Wrapper: React.ComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
|
||||
|
||||
Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders API type title', async () => {
|
||||
const apiEntity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: '...',
|
||||
owner: '...',
|
||||
definition: '...',
|
||||
},
|
||||
};
|
||||
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
component: () => <div />,
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ApiTypeTitle apiEntity={apiEntity} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/OpenAPI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fallback if title is unknown', async () => {
|
||||
const apiEntity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
},
|
||||
spec: {
|
||||
type: 'custom-type',
|
||||
lifecycle: '...',
|
||||
owner: '...',
|
||||
definition: '...',
|
||||
},
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ApiTypeTitle apiEntity={apiEntity} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/custom-type/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
|
||||
export const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntity }) => {
|
||||
const config = useApi(apiDocsConfigRef);
|
||||
const definition = config.getApiDefinitionWidget(apiEntity);
|
||||
const type = definition ? definition.title : apiEntity.spec.type;
|
||||
|
||||
return <span>{type}</span>;
|
||||
};
|
||||
@@ -14,8 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
|
||||
export {
|
||||
ApiDefinitionCard,
|
||||
defaultDefinitionWidgets,
|
||||
} from './ApiDefinitionCard';
|
||||
export { ApiDefinitionCard } from './ApiDefinitionCard';
|
||||
export { defaultDefinitionWidgets } from './ApiDefinitionWidget';
|
||||
export type { ApiDefinitionWidget } from './ApiDefinitionWidget';
|
||||
export { ApiTypeTitle } from './ApiTypeTitle';
|
||||
|
||||
@@ -22,7 +22,7 @@ import * as React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { ApiExplorerTable } from './ApiExplorerTable';
|
||||
|
||||
const entites: Entity[] = [
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
@@ -70,7 +70,7 @@ describe('ApiCatalogTable component', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<ApiExplorerTable entities={entites} loading={false} />
|
||||
<ApiExplorerTable entities={entities} loading={false} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -20,23 +20,13 @@ import {
|
||||
TableColumn,
|
||||
TableFilter,
|
||||
TableState,
|
||||
useApi,
|
||||
useQueryParamState,
|
||||
} from '@backstage/core';
|
||||
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import { Chip, Link } from '@material-ui/core';
|
||||
import { Chip } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
|
||||
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
|
||||
const config = useApi(apiDocsConfigRef);
|
||||
const definition = config.getApiDefinitionWidget(apiEntity);
|
||||
const type = definition ? definition.title : apiEntity.spec.type;
|
||||
|
||||
return <span>{type}</span>;
|
||||
};
|
||||
import { ApiTypeTitle } from '../ApiDefinitionCard';
|
||||
import { EntityLink } from '../EntityLink';
|
||||
|
||||
const columns: TableColumn<Entity>[] = [
|
||||
{
|
||||
@@ -44,15 +34,7 @@ const columns: TableColumn<Entity>[] = [
|
||||
field: 'metadata.name',
|
||||
highlight: true,
|
||||
render: (entity: any) => (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
{entity.metadata.name}
|
||||
</Link>
|
||||
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
|
||||
|
||||
describe('<AsyncApiDefinitionWidget />', () => {
|
||||
it('renders asyncapi spec', async () => {
|
||||
const definition = `
|
||||
asyncapi: 2.0.0
|
||||
info:
|
||||
title: Account Service
|
||||
version: 1.0.0
|
||||
channels:
|
||||
user/signedup:
|
||||
subscribe:
|
||||
message:
|
||||
$ref: '#/components/messages/UserSignedUp'
|
||||
components:
|
||||
messages:
|
||||
UserSignedUp:
|
||||
payload:
|
||||
type: object
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
`;
|
||||
const { getByText, getAllByText } = await renderInTestApp(
|
||||
<AsyncApiDefinitionWidget definition={definition} />,
|
||||
);
|
||||
|
||||
expect(getByText(/Account Service/i)).toBeInTheDocument();
|
||||
expect(getByText(/user\/signedup/i)).toBeInTheDocument();
|
||||
expect(getByText(/UserSignedUp/i)).toBeInTheDocument();
|
||||
expect(getAllByText(/displayName/i)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders error if definition is missing', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<AsyncApiDefinitionWidget definition="" />,
|
||||
);
|
||||
expect(getByText(/Error/i)).toBeInTheDocument();
|
||||
expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { EntityLink } from './EntityLink';
|
||||
|
||||
describe('<EntityLink />', () => {
|
||||
it('provides link to entity page', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
};
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityLink entity={entity}>inner</EntityLink>,
|
||||
);
|
||||
expect(getByText(/inner/i)).toBeInTheDocument();
|
||||
expect(getByText(/inner/i)).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/my-namespace/component/my-name',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import { Link } from '@material-ui/core';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
// TODO: Could be useful for others too, as part of the catalog plugin
|
||||
export const EntityLink = ({ entity, children }: PropsWithChildren<Props>) => {
|
||||
return (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { EntityLink } from './EntityLink';
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget';
|
||||
|
||||
describe('<GraphQlDefinitionWidget />', () => {
|
||||
it('renders graphql schema', async () => {
|
||||
const definition = `
|
||||
"""Hello World!"""
|
||||
schema {
|
||||
query: Film
|
||||
}
|
||||
|
||||
"""A single film."""
|
||||
type Film {
|
||||
"""The ID of an object"""
|
||||
id: ID!
|
||||
"""The title of this film."""
|
||||
title: String
|
||||
}
|
||||
`;
|
||||
// CodeMirror uses features that jsdom doesn't support so we have to patch
|
||||
// it here, see: https://github.com/jsdom/jsdom/issues/3002
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
|
||||
range.getBoundingClientRect = jest.fn();
|
||||
|
||||
range.getClientRects = () => {
|
||||
return {
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<GraphQlDefinitionWidget definition={definition} />,
|
||||
);
|
||||
|
||||
expect(getByText(/Film/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
|
||||
|
||||
describe('<OpenApiDefinitionWidget />', () => {
|
||||
it('renders openapi spec', async () => {
|
||||
const definition = `
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: Artist API
|
||||
license:
|
||||
name: MIT
|
||||
servers:
|
||||
- url: http://artist.spotify.net/v1
|
||||
paths:
|
||||
/artists:
|
||||
get:
|
||||
summary: List all artists
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
`;
|
||||
const { getByText } = await renderInTestApp(
|
||||
<OpenApiDefinitionWidget definition={definition} />,
|
||||
);
|
||||
|
||||
// swagger-ui loads the documentation asynchronously
|
||||
await waitFor(() => {
|
||||
expect(getByText(/\/artists/i)).toBeInTheDocument();
|
||||
expect(getByText(/List all artists/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders error if definition is missing', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<OpenApiDefinitionWidget definition="" />,
|
||||
);
|
||||
expect(getByText(/No API definition provided/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
|
||||
|
||||
describe('<PlainApiDefinitionWidget />', () => {
|
||||
it('renders plain text', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
|
||||
);
|
||||
expect(getByText(/Hello World/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
|
||||
|
||||
const frontendConfigs = await schema.process(
|
||||
[{ data: config.get() as JsonObject, context: 'app' }],
|
||||
{ visiblity: ['frontend'] },
|
||||
{ visibility: ['frontend'] },
|
||||
);
|
||||
appConfigs.push(...frontendConfigs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 { Logger } from 'winston';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import {
|
||||
AnalyzeLocationRequest,
|
||||
AnalyzeLocationResponse,
|
||||
LocationAnalyzer,
|
||||
} from './types';
|
||||
|
||||
export class RepoLocationAnalyzer implements LocationAnalyzer {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
async analyzeLocation(
|
||||
request: AnalyzeLocationRequest,
|
||||
): Promise<AnalyzeLocationResponse> {
|
||||
const { owner, name, source } = parseGitUri(request.location.target);
|
||||
const entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: name,
|
||||
// Probably won't handle properly self-hosted git providers with custom url
|
||||
annotations: { [`${source}/project-slug`]: `${owner}/${name}` },
|
||||
},
|
||||
spec: { type: 'other', lifecycle: 'unknown' },
|
||||
};
|
||||
|
||||
this.logger.debug(`entity created for ${request.location.target}`);
|
||||
return {
|
||||
existingEntityFiles: [],
|
||||
generateEntities: [{ entity, fields: [] }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,5 @@
|
||||
|
||||
export { HigherOrderOperations } from './HigherOrderOperations';
|
||||
export { LocationReaders } from './LocationReaders';
|
||||
export type {
|
||||
AddLocationResult,
|
||||
HigherOrderOperation,
|
||||
LocationReader,
|
||||
ReadLocationEntity,
|
||||
ReadLocationError,
|
||||
ReadLocationResult,
|
||||
} from './types';
|
||||
export * from './types';
|
||||
export * from './processors';
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
import { createLogger } from 'winston';
|
||||
import {
|
||||
buildCodeOwnerUrl,
|
||||
buildUrl,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
resolveCodeOwner,
|
||||
} from './CodeOwnersProcessor';
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
describe('CodeOwnersProcessor', () => {
|
||||
const mockUrl = ({ basePath = '' } = {}): string =>
|
||||
`https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`;
|
||||
@@ -158,17 +161,20 @@ describe('CodeOwnersProcessor', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
const result = await findRawCodeOwners(mockLocation(), reader);
|
||||
const result = await findRawCodeOwners(mockLocation(), {
|
||||
reader,
|
||||
logger,
|
||||
});
|
||||
expect(result).toEqual(ownersText);
|
||||
});
|
||||
|
||||
it('should raise error when no codeowner', async () => {
|
||||
it('should return undefined when no codeowner', async () => {
|
||||
const read = jest.fn().mockRejectedValue(mockReadResult());
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
|
||||
await expect(
|
||||
findRawCodeOwners(mockLocation(), reader),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
findRawCodeOwners(mockLocation(), { reader, logger }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should look at known codeowner locations', async () => {
|
||||
@@ -180,7 +186,10 @@ describe('CodeOwnersProcessor', () => {
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
|
||||
const result = await findRawCodeOwners(mockLocation(), reader);
|
||||
const result = await findRawCodeOwners(mockLocation(), {
|
||||
reader,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(read.mock.calls.length).toBe(5);
|
||||
expect(read.mock.calls[0]).toEqual([mockReadUrl('')]);
|
||||
@@ -199,19 +208,19 @@ describe('CodeOwnersProcessor', () => {
|
||||
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
|
||||
const owner = await resolveCodeOwner(mockLocation(), reader);
|
||||
const owner = await resolveCodeOwner(mockLocation(), { reader, logger });
|
||||
expect(owner).toBe('backstage-core');
|
||||
});
|
||||
|
||||
it('should raise an error when no codeowner', async () => {
|
||||
it('should return undefined when no codeowner', async () => {
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
|
||||
await expect(
|
||||
resolveCodeOwner(mockLocation(), reader),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
resolveCodeOwner(mockLocation(), { reader, logger }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +231,7 @@ describe('CodeOwnersProcessor', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
|
||||
const reader = { read, readTree: jest.fn() };
|
||||
const processor = new CodeOwnersProcessor({ reader });
|
||||
const processor = new CodeOwnersProcessor({ reader, logger });
|
||||
|
||||
return { entity, processor, read };
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { NotFoundError, UrlReader } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import * as codeowners from 'codeowners-utils';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
@@ -22,6 +22,7 @@ import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
import 'core-js/features/promise';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { filter, get, head, pipe, reverse } from 'lodash/fp';
|
||||
import { Logger } from 'winston';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
const ALLOWED_LOCATION_TYPES = [
|
||||
@@ -41,6 +42,7 @@ const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab'];
|
||||
|
||||
type Options = {
|
||||
reader: UrlReader;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
@@ -60,7 +62,10 @@ export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const owner = await resolveCodeOwner(location, this.options.reader);
|
||||
const owner = await resolveCodeOwner(location, this.options);
|
||||
if (!owner) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
@@ -71,12 +76,11 @@ export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
|
||||
export async function resolveCodeOwner(
|
||||
location: LocationSpec,
|
||||
reader: UrlReader,
|
||||
options: Options,
|
||||
): Promise<string | undefined> {
|
||||
const ownersText = await findRawCodeOwners(location, reader);
|
||||
|
||||
const ownersText = await findRawCodeOwners(location, options);
|
||||
if (!ownersText) {
|
||||
throw Error(`Unable to find codeowners file for: ${location.target}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const owners = parseCodeOwners(ownersText);
|
||||
@@ -86,18 +90,33 @@ export async function resolveCodeOwner(
|
||||
|
||||
export async function findRawCodeOwners(
|
||||
location: LocationSpec,
|
||||
reader: UrlReader,
|
||||
options: Options,
|
||||
): Promise<string | undefined> {
|
||||
const readOwnerLocation = async (basePath: string): Promise<string> => {
|
||||
const ownerUrl = buildCodeOwnerUrl(
|
||||
location.target,
|
||||
`${basePath}/CODEOWNERS`,
|
||||
);
|
||||
const data = await reader.read(ownerUrl);
|
||||
const data = await options.reader.read(ownerUrl);
|
||||
return data.toString();
|
||||
};
|
||||
|
||||
return Promise.any(KNOWN_LOCATIONS.map(readOwnerLocation));
|
||||
const candidates = KNOWN_LOCATIONS.map(readOwnerLocation);
|
||||
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
|
||||
const hardError = aggregateError.errors.find(
|
||||
error => !(error instanceof NotFoundError),
|
||||
);
|
||||
if (hardError) {
|
||||
options.logger.warn(
|
||||
`Failed to read codeowners for location ${location.type}:${location.target}, ${hardError}`,
|
||||
);
|
||||
} else {
|
||||
options.logger.debug(
|
||||
`Failed to find codeowners for location ${location.type}:${location.target}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCodeOwnerUrl(
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
import {
|
||||
Entity,
|
||||
EntityRelationSpec,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { RecursivePartial } from '../util/RecursivePartial';
|
||||
|
||||
//
|
||||
// HigherOrderOperation
|
||||
@@ -68,3 +69,75 @@ export type ReadLocationError = {
|
||||
location: LocationSpec;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
//
|
||||
// LocationAnalyzer
|
||||
//
|
||||
|
||||
export type LocationAnalyzer = {
|
||||
/**
|
||||
* Generates an entity configuration for given git repository. It's used for
|
||||
* importing new component to the backstage app.
|
||||
*
|
||||
* @param location Git repository to analyze and generate config for.
|
||||
*/
|
||||
analyzeLocation(
|
||||
location: AnalyzeLocationRequest,
|
||||
): Promise<AnalyzeLocationResponse>;
|
||||
};
|
||||
|
||||
export type AnalyzeLocationRequest = {
|
||||
location: LocationSpec;
|
||||
};
|
||||
|
||||
export type AnalyzeLocationResponse = {
|
||||
existingEntityFiles: AnalyzeLocationExistingEntity[];
|
||||
generateEntities: AnalyzeLocationGenerateEntity[];
|
||||
};
|
||||
|
||||
// If the folder pointed to already contained catalog info yaml files, they are
|
||||
// read and emitted like this so that the frontend can inform the user that it
|
||||
// located them and can make sure to register them as well if they weren't
|
||||
// already
|
||||
type AnalyzeLocationExistingEntity = {
|
||||
location: LocationSpec;
|
||||
isRegistered: boolean;
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
// This is some form of representation of what the analyzer could deduce.
|
||||
// We should probably have a chat about how this can best be conveyed to
|
||||
// the frontend. It'll probably contain a (possibly incomplete) entity, plus
|
||||
// enough info for the frontend to know what form data to show to the user
|
||||
// for overriding/completing the info.
|
||||
type AnalyzeLocationGenerateEntity = {
|
||||
// Some form of partial representation of the entity
|
||||
entity: RecursivePartial<Entity>;
|
||||
// Lists the suggestions that the user may want to override
|
||||
fields: AnalyzeLocationEntityField[];
|
||||
};
|
||||
|
||||
// This is where I get really vague. Something like this perhaps? Or it could be
|
||||
// something like a json-schema that contains enough info for the frontend to
|
||||
// be able to present a form and explanations
|
||||
type AnalyzeLocationEntityField = {
|
||||
// e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the
|
||||
// entity again if the user wants to change it
|
||||
field: string;
|
||||
|
||||
// The outcome of the analysis for this particular field
|
||||
state:
|
||||
| 'analysisSuggestedValue'
|
||||
| 'analysisSuggestedNoValue'
|
||||
| 'needsUserInput';
|
||||
|
||||
// If the analysis did suggest a value, this is where it would be. Not sure if we want
|
||||
// to limit this to strings or if we want it to be any JsonValue
|
||||
value: string | null;
|
||||
|
||||
// A text to show to the user to inform about the choices made. Like, it could say
|
||||
// "Found a CODEOWNERS file that covers this target, so we suggest leaving this
|
||||
// field empty; which would currently make it owned by X" where X is taken from the
|
||||
// codeowners file.
|
||||
description: string;
|
||||
};
|
||||
|
||||
@@ -54,11 +54,13 @@ import {
|
||||
UrlReaderProcessor,
|
||||
} from '../ingestion';
|
||||
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
|
||||
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
|
||||
import {
|
||||
jsonPlaceholderResolver,
|
||||
textPlaceholderResolver,
|
||||
yamlPlaceholderResolver,
|
||||
} from '../ingestion/processors/PlaceholderProcessor';
|
||||
import { LocationAnalyzer } from '../ingestion/types';
|
||||
|
||||
export type CatalogEnvironment = {
|
||||
logger: Logger;
|
||||
@@ -202,6 +204,7 @@ export class CatalogBuilder {
|
||||
entitiesCatalog: EntitiesCatalog;
|
||||
locationsCatalog: LocationsCatalog;
|
||||
higherOrderOperation: HigherOrderOperation;
|
||||
locationAnalyzer: LocationAnalyzer;
|
||||
}> {
|
||||
const { config, database, logger } = this.env;
|
||||
|
||||
@@ -229,11 +232,13 @@ export class CatalogBuilder {
|
||||
locationReader,
|
||||
logger,
|
||||
);
|
||||
const locationAnalyzer = new RepoLocationAnalyzer(logger);
|
||||
|
||||
return {
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,7 +285,7 @@ export class CatalogBuilder {
|
||||
LdapOrgReaderProcessor.fromConfig(config, { logger }),
|
||||
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
|
||||
new UrlReaderProcessor({ reader, logger }),
|
||||
new CodeOwnersProcessor({ reader }),
|
||||
new CodeOwnersProcessor({ reader, logger }),
|
||||
new LocationRefProcessor(),
|
||||
new AnnotateLocationEntityProcessor(),
|
||||
);
|
||||
|
||||
@@ -15,29 +15,38 @@
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import {
|
||||
locationSpecSchema,
|
||||
analyzeLocationSchema,
|
||||
} from '@backstage/catalog-model';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { locationSpecSchema } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import yn from 'yn';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { HigherOrderOperation } from '../ingestion/types';
|
||||
import { EntityFilters } from './EntityFilters';
|
||||
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
|
||||
import { translateQueryToFieldMapper } from './filterQuery';
|
||||
import { EntityFilters } from './EntityFilters';
|
||||
import { requireRequestBody, validateRequestBody } from './util';
|
||||
|
||||
export interface RouterOptions {
|
||||
entitiesCatalog?: EntitiesCatalog;
|
||||
locationsCatalog?: LocationsCatalog;
|
||||
higherOrderOperation?: HigherOrderOperation;
|
||||
locationAnalyzer?: LocationAnalyzer;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
|
||||
const {
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
} = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
@@ -127,6 +136,14 @@ export async function createRouter(
|
||||
});
|
||||
}
|
||||
|
||||
if (locationAnalyzer) {
|
||||
router.post('/analyze-location', async (req, res) => {
|
||||
const input = await validateRequestBody(req, analyzeLocationSchema);
|
||||
const output = await locationAnalyzer.analyzeLocation(input);
|
||||
res.status(200).send(output);
|
||||
});
|
||||
}
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
# Catalog import plugin
|
||||
|
||||
Welcome to the catalog-import plugin!
|
||||
|
||||
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
|
||||
|
||||
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
|
||||
|
||||
<img src="./src/assets/catalog-import-screenshot.png" />
|
||||
|
||||
## Running
|
||||
|
||||
Just run the backstage.
|
||||
|
||||
```
|
||||
yarn start && yarn --cwd packages/backend start
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-import",
|
||||
"version": "0.2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"diff": "backstage-cli plugin:diff",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.3.0",
|
||||
"@backstage/core": "^0.3.2",
|
||||
"@backstage/plugin-catalog": "^0.2.0",
|
||||
"@backstage/plugin-catalog-backend": "^0.2.2",
|
||||
"@backstage/theme": "^0.2.1",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@octokit/rest": "^18.0.6",
|
||||
"git-url-parse": "^11.4.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^6.6.0",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.3.2",
|
||||
"@backstage/dev-utils": "^0.1.4",
|
||||
"@backstage/test-utils": "^0.1.3",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { PartialEntity } from '../util/types';
|
||||
|
||||
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
|
||||
id: 'plugin.catalogimport.service',
|
||||
description: 'Used by the catalog import plugin to make requests',
|
||||
});
|
||||
|
||||
export interface CatalogImportApi {
|
||||
submitPrToRepo(options: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
fileContent: string;
|
||||
}): Promise<{ link: string; location: string }>;
|
||||
createRepositoryLocation(options: { location: string }): Promise<void>;
|
||||
generateEntityDefinitions(options: {
|
||||
repo: string;
|
||||
}): Promise<PartialEntity[]>;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 { Octokit } from '@octokit/rest';
|
||||
import { DiscoveryApi, OAuthApi } from '@backstage/core';
|
||||
import { CatalogImportApi } from './CatalogImportApi';
|
||||
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
|
||||
import { PartialEntity } from '../util/types';
|
||||
|
||||
export class CatalogImportClient implements CatalogImportApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly githubAuthApi: OAuthApi;
|
||||
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
githubAuthApi: OAuthApi;
|
||||
}) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.githubAuthApi = options.githubAuthApi;
|
||||
}
|
||||
|
||||
async generateEntityDefinitions({
|
||||
repo,
|
||||
}: {
|
||||
repo: string;
|
||||
}): Promise<PartialEntity[]> {
|
||||
const response = await fetch(
|
||||
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
location: { type: 'github', target: repo },
|
||||
}),
|
||||
},
|
||||
).catch(e => {
|
||||
throw new Error(`Failed to generate entity definitions, ${e.message}`);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AnalyzeLocationResponse;
|
||||
return payload.generateEntities.map(x => x.entity);
|
||||
}
|
||||
|
||||
async createRepositoryLocation({
|
||||
location,
|
||||
}: {
|
||||
location: string;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'github',
|
||||
target: location,
|
||||
presence: 'optional',
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Received http response ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async submitPrToRepo({
|
||||
owner,
|
||||
repo,
|
||||
fileContent,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
fileContent: string;
|
||||
}): Promise<{ link: string; location: string }> {
|
||||
const token = await this.githubAuthApi.getAccessToken(['repo']);
|
||||
|
||||
const octo = new Octokit({
|
||||
auth: token,
|
||||
});
|
||||
|
||||
const branchName = 'backstage-integration';
|
||||
const fileName = 'catalog-info.yaml';
|
||||
|
||||
const repoData = await octo.repos
|
||||
.get({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
|
||||
});
|
||||
|
||||
const parentRef = await octo.git
|
||||
.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${repoData.data.default_branch}`,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage("Couldn't fetch default branch data", e),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.git
|
||||
.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: parentRef.data.object.sha,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a new branch with name '${branchName}'`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.repos
|
||||
.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path: fileName,
|
||||
message: `Add ${fileName} config file`,
|
||||
content: btoa(fileContent),
|
||||
branch: branchName,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a commit with ${fileName} file added`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const pullRequestRespone = await octo.pulls
|
||||
.create({
|
||||
owner,
|
||||
repo,
|
||||
title: `Add ${fileName} config file`,
|
||||
head: branchName,
|
||||
base: repoData.data.default_branch,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a pull request for ${branchName} branch`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
link: pullRequestRespone.data.html_url,
|
||||
location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatHttpErrorMessage(
|
||||
message: string,
|
||||
error: { status: number; message: string },
|
||||
) {
|
||||
return `${message}, received http response status code ${error.status}: ${error.message}`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './CatalogImportApi';
|
||||
export * from './CatalogImportClient';
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
Divider,
|
||||
} from '@material-ui/core';
|
||||
import { useGithubRepos } from '../util/useGithubRepos';
|
||||
import { ConfigSpec } from './ImportComponentPage';
|
||||
import {
|
||||
errorApiRef,
|
||||
RouteRef,
|
||||
StructuredMetadataTable,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { PartialEntity } from '../util/types';
|
||||
import { generatePath, resolvePath } from 'react-router';
|
||||
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
const getEntityCatalogPath = ({
|
||||
entity,
|
||||
catalogRouteRef,
|
||||
}: {
|
||||
entity: PartialEntity;
|
||||
catalogRouteRef: RouteRef;
|
||||
}) => {
|
||||
const relativeEntityPathInsideCatalog = generatePath(
|
||||
entityRoute.path,
|
||||
entityRouteParams(entity as Entity),
|
||||
);
|
||||
|
||||
const resolvedAbsolutePath = resolvePath(
|
||||
relativeEntityPathInsideCatalog,
|
||||
catalogRouteRef.path,
|
||||
)?.pathname;
|
||||
|
||||
return resolvedAbsolutePath;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
nextStep: (options?: { reset: boolean }) => void;
|
||||
configFile: ConfigSpec;
|
||||
savePRLink: (PRLink: string) => void;
|
||||
catalogRouteRef: RouteRef;
|
||||
};
|
||||
|
||||
const ComponentConfigDisplay = ({
|
||||
nextStep,
|
||||
configFile,
|
||||
savePRLink,
|
||||
catalogRouteRef,
|
||||
}: Props) => {
|
||||
const [errorOccured, setErrorOccured] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { submitPrToRepo, addLocation } = useGithubRepos();
|
||||
const onNext = useCallback(async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (!parseGitUri(configFile.location).filepathtype) {
|
||||
const result = await submitPrToRepo(configFile);
|
||||
savePRLink(result.link);
|
||||
setSubmitting(false);
|
||||
nextStep();
|
||||
} else {
|
||||
addLocation(configFile.location);
|
||||
setSubmitting(false);
|
||||
nextStep();
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorOccured(true);
|
||||
setSubmitting(false);
|
||||
errorApi.post(e);
|
||||
}
|
||||
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
|
||||
|
||||
return (
|
||||
<Grid container direction="column" spacing={1}>
|
||||
{!parseGitUri(configFile.location).filepathtype ? (
|
||||
<Typography>
|
||||
Following config object will be submitted in a pull request to the
|
||||
repository{' '}
|
||||
<Link
|
||||
href={configFile.location}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{configFile.location}
|
||||
</Link>{' '}
|
||||
and added as a new location to the backend
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography>
|
||||
Following config object will be added as a new location to the backend{' '}
|
||||
<Link
|
||||
href={configFile.location}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{configFile.location}
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Grid item>
|
||||
{!parseGitUri(configFile.location).filepathtype ? (
|
||||
<pre>{YAML.stringify(configFile.config)}</pre>
|
||||
) : (
|
||||
<List>
|
||||
{configFile.config.map((entity: any, index: number) => {
|
||||
const entityPath = getEntityCatalogPath({
|
||||
entity,
|
||||
catalogRouteRef,
|
||||
});
|
||||
return (
|
||||
<React.Fragment
|
||||
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
|
||||
>
|
||||
<ListItem>
|
||||
<StructuredMetadataTable
|
||||
dense
|
||||
metadata={{
|
||||
name: entity.metadata.name,
|
||||
type: entity.spec.type,
|
||||
link: (
|
||||
<Link component={RouterLink} to={entityPath}>
|
||||
{entityPath}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < configFile.config.length - 1 && (
|
||||
<Divider component="li" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item container spacing={1}>
|
||||
{submitting ? (
|
||||
<Grid item>
|
||||
<CircularProgress size="2rem" />
|
||||
</Grid>
|
||||
) : null}
|
||||
<Grid item>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onNext}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
{errorOccured ? (
|
||||
<Button
|
||||
style={{ marginLeft: '8px' }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => nextStep({ reset: true })}
|
||||
>
|
||||
Start again
|
||||
</Button>
|
||||
) : null}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentConfigDisplay;
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { errorApiRef, useApi } from '@backstage/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useMountedState } from 'react-use';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { ComponentIdValidators } from '../util/validate';
|
||||
import { useGithubRepos } from '../util/useGithubRepos';
|
||||
import { ConfigSpec } from './ImportComponentPage';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
form: {
|
||||
alignItems: 'flex-start',
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
},
|
||||
submit: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
nextStep: () => void;
|
||||
saveConfig: (configFile: ConfigSpec) => void;
|
||||
};
|
||||
|
||||
export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => {
|
||||
const { register, handleSubmit, errors, formState } = useForm({
|
||||
mode: 'onChange',
|
||||
});
|
||||
const classes = useStyles();
|
||||
const hasErrors = !!errors.componentLocation;
|
||||
const dirty = formState?.isDirty;
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const isMounted = useMountedState();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { generateEntityDefinitions } = useGithubRepos();
|
||||
|
||||
const onSubmit = async (formData: Record<string, string>) => {
|
||||
const { componentLocation: target } = formData;
|
||||
try {
|
||||
if (!isMounted()) return;
|
||||
const type = !parseGitUri(target).filepathtype ? 'repo' : 'file';
|
||||
|
||||
if (type === 'repo') {
|
||||
saveConfig({
|
||||
type,
|
||||
location: target,
|
||||
config: await generateEntityDefinitions(target),
|
||||
});
|
||||
} else {
|
||||
const data = await catalogApi.addLocation({ target });
|
||||
saveConfig({
|
||||
type,
|
||||
location: data.location.target,
|
||||
config: data.entities,
|
||||
});
|
||||
}
|
||||
nextStep();
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={classes.form}
|
||||
>
|
||||
<FormControl>
|
||||
<TextField
|
||||
id="registerComponentInput"
|
||||
variant="outlined"
|
||||
label="Repository URL"
|
||||
error={hasErrors}
|
||||
placeholder="https://github.com/spotify/backstage"
|
||||
name="componentLocation"
|
||||
required
|
||||
margin="normal"
|
||||
helperText="Enter the full path to the repository in GitHub to start tracking your component."
|
||||
inputRef={register({
|
||||
required: true,
|
||||
validate: ComponentIdValidators,
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors.componentLocation && (
|
||||
<FormHelperText error={hasErrors} id="register-component-helper-text">
|
||||
{errors.componentLocation.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || hasErrors}
|
||||
className={classes.submit}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
InfoCard,
|
||||
Page,
|
||||
Content,
|
||||
Header,
|
||||
SupportButton,
|
||||
ContentHeader,
|
||||
RouteRef,
|
||||
} from '@backstage/core';
|
||||
import { RegisterComponentForm } from './ImportComponentForm';
|
||||
import ImportStepper from './ImportStepper';
|
||||
import ComponentConfigDisplay from './ComponentConfigDisplay';
|
||||
import { ImportFinished } from './ImportFinished';
|
||||
import { PartialEntity } from '../util/types';
|
||||
|
||||
export type ConfigSpec = {
|
||||
type: 'repo' | 'file';
|
||||
location: string;
|
||||
config: PartialEntity[];
|
||||
};
|
||||
|
||||
export const ImportComponentPage = ({
|
||||
catalogRouteRef,
|
||||
}: {
|
||||
catalogRouteRef: RouteRef;
|
||||
}) => {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [configFile, setConfigFile] = useState<ConfigSpec>({
|
||||
type: 'repo',
|
||||
location: '',
|
||||
config: [],
|
||||
});
|
||||
const [endLink, setEndLink] = useState<string>('');
|
||||
const nextStep = (options?: { reset: boolean }) => {
|
||||
setActiveStep(step => (options?.reset ? 0 : step + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Register an existing component" />
|
||||
<Content>
|
||||
<ContentHeader title="Start tracking your component on backstage">
|
||||
<SupportButton>
|
||||
Start tracking your component in Backstage. TODO: Add more
|
||||
information about what this is.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard>
|
||||
<ImportStepper
|
||||
steps={[
|
||||
{
|
||||
step: 'Insert GitHub repo URL or Entity File URL',
|
||||
content: (
|
||||
<RegisterComponentForm
|
||||
nextStep={nextStep}
|
||||
saveConfig={setConfigFile}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
step: 'Review',
|
||||
content: (
|
||||
<ComponentConfigDisplay
|
||||
nextStep={nextStep}
|
||||
configFile={configFile}
|
||||
savePRLink={setEndLink}
|
||||
catalogRouteRef={catalogRouteRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
step: 'Finish',
|
||||
content: (
|
||||
<ImportFinished
|
||||
nextStep={nextStep}
|
||||
PRLink={endLink}
|
||||
type={configFile.type}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
activeStep={activeStep}
|
||||
nextStep={nextStep}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Button, Grid, Link } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
type: 'repo' | 'file';
|
||||
nextStep: (options?: { reset: boolean }) => void;
|
||||
PRLink: string;
|
||||
};
|
||||
|
||||
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
|
||||
return (
|
||||
<Grid container direction="column" spacing={1}>
|
||||
<Grid item>
|
||||
<Alert severity="success">
|
||||
{type === 'repo'
|
||||
? 'Pull requests have been successfully opened. You can start again to import more repositories'
|
||||
: 'Entity added to catalog successfully'}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{type === 'repo' ? (
|
||||
<Link
|
||||
href={PRLink}
|
||||
style={{ marginRight: '8px' }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View pull request on GitHub
|
||||
</Link>
|
||||
) : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => nextStep({ reset: true })}
|
||||
>
|
||||
Register another
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import Stepper from '@material-ui/core/Stepper';
|
||||
import Step from '@material-ui/core/Step';
|
||||
import StepLabel from '@material-ui/core/StepLabel';
|
||||
import { StepContent } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
steps: { step: string; content: React.ReactNode }[];
|
||||
activeStep: number;
|
||||
nextStep: (options?: { reset: boolean }) => void;
|
||||
};
|
||||
|
||||
export default function ImportStepper({ steps, activeStep }: Props) {
|
||||
return (
|
||||
<Stepper activeStep={activeStep} orientation="vertical">
|
||||
{steps.map(({ step }) => {
|
||||
const stepProps: { completed?: boolean } = {};
|
||||
return (
|
||||
<Step key={step} {...stepProps}>
|
||||
<StepLabel>{step}</StepLabel>
|
||||
<StepContent>{steps[activeStep].content}</StepContent>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 { RouteRef } from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { ImportComponentPage } from './ImportComponentPage';
|
||||
|
||||
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
|
||||
<Routes>
|
||||
<Route
|
||||
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { Router } from './components/Router';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user